Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Harbor Satellite — Software Distribution to the Edge

[![Go Report Card](https://goreportcard.com/badge/github.com/container-registry/harbor-satellite)](https://goreportcard.com/report/github.com/container-registry/harbor-satellite)

Harbor Satellite brings the power of the Harbor container registry to edge computing. Satellite is a registry fleet management and artifact distribution solution around a central source of truth Harbor cluster.  

A lightweight, standalone registry at edge locations is acting as both a primary registry for local workloads and a fallback for the central Harbor instance. This stateful satellite registry ensures consistent, available, and integrity-checked container images for edge devices, even when network connectivity is intermittent or unavailable (air-gapped). Harbor Satellite optimizes image distribution and management for edge environments, addressing challenges like bandwidth limitations, remote fleet orchestration and artifact distribution.
Expand Down
14 changes: 13 additions & 1 deletion ground-control/internal/auth/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,21 @@ import (
"github.com/container-registry/harbor-satellite/ground-control/pkg/crypto"
)

type PasswordPolicyError struct {
Err error
}

func (e *PasswordPolicyError) Error() string { return e.Err.Error() }
func (e *PasswordPolicyError) Unwrap() error { return e.Err }

// HashPassword creates an Argon2id hash of the password.
func HashPassword(password string) (string, error) {
return crypto.HashSecret(password)
policy := LoadPolicyFromEnv()
if err := policy.Validate(password); err != nil {
return "", &PasswordPolicyError{Err: err}
}

return crypto.HashSecret(password)
}

// VerifyPassword compares a password against an Argon2id hash.
Expand Down
21 changes: 17 additions & 4 deletions ground-control/internal/auth/password_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

func TestVerifyPassword(t *testing.T) {
password := "test-password-123"
password := "Test-password-123"
hash, err := HashPassword(password)
require.NoError(t, err)

Expand Down Expand Up @@ -61,28 +61,41 @@ func TestHashPassword(t *testing.T) {
tests := []struct {
name string
password string
wantErr bool
}{
{
name: "normal password",
password: "MySecurePassword123!",
wantErr: false,
},
{
name: "empty password",
password: "",
wantErr: true,
},
{
// Make it long AND policy-compliant (upper/lower/number) so if it fails,
// it's likely due to max length, not missing character classes.
// NOTE: if this ends up under 128 chars in your policy, it will pass.
name: "long password",
password: "a very long password that exceeds typical password length requirements for testing purposes",
password: "A1" + "this-is-a-very-long-password-used-for-testing-length-behavior-and-it-has-lowercase-too",
wantErr: false, // flip to true if you change this to exceed policy MaxLength
},
{
name: "special characters",
password: "p@$$w0rd!#%&*()[]{}",
password: "Test@#$%^&*123",
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash, err := HashPassword(tt.password)
if tt.wantErr {
require.Error(t, err)
return
}

require.NoError(t, err)
require.NotEmpty(t, hash)
require.Contains(t, hash, "$argon2id$")
Expand All @@ -95,7 +108,7 @@ func TestHashPassword(t *testing.T) {
}

func TestHashPassword_UniqueHashes(t *testing.T) {
password := "same-password"
password := "SamePassword1!"
hash1, err := HashPassword(password)
require.NoError(t, err)

Expand Down
10 changes: 6 additions & 4 deletions ground-control/internal/server/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"errors"

"github.com/container-registry/harbor-satellite/ground-control/internal/auth"
"github.com/container-registry/harbor-satellite/ground-control/internal/database"
Expand All @@ -29,15 +30,16 @@ func (s *Server) BootstrapSystemAdmin(ctx context.Context) error {
return fmt.Errorf("ADMIN_PASSWORD environment variable is required for initial setup")
}

if err := s.passwordPolicy.Validate(password); err != nil {
return fmt.Errorf("ADMIN_PASSWORD invalid: %w", err)
}

hash, err := auth.HashPassword(password)
if err != nil {
var pe *auth.PasswordPolicyError
if errors.As(err, &pe) {
return fmt.Errorf("ADMIN_PASSWORD invalid: %w", pe)
}
return fmt.Errorf("failed to hash admin password: %w", err)
}


_, err = s.dbQueries.CreateUser(ctx, database.CreateUserParams{
Username: systemAdminUsername,
PasswordHash: hash,
Expand Down
47 changes: 28 additions & 19 deletions ground-control/internal/server/user_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,19 @@ func (s *Server) createUserHandler(w http.ResponseWriter, r *http.Request) {
return
}

if err := s.passwordPolicy.Validate(req.Password); err != nil {
WriteJSONError(w, err.Error(), http.StatusBadRequest)
return
}

hash, err := auth.HashPassword(req.Password)
if err != nil {
var pe *auth.PasswordPolicyError
if errors.As(err, &pe) {
WriteJSONError(w, pe.Error(), http.StatusBadRequest)
return
}

WriteJSONError(w, "Internal server error", http.StatusInternalServerError)
return
}


user, err := s.dbQueries.CreateUser(r.Context(), database.CreateUserParams{
Username: req.Username,
PasswordHash: hash,
Expand Down Expand Up @@ -201,11 +203,19 @@ func (s *Server) changeOwnPasswordHandler(w http.ResponseWriter, r *http.Request
return
}

if err := s.passwordPolicy.Validate(req.NewPassword); err != nil {
WriteJSONError(w, err.Error(), http.StatusBadRequest)
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
var pe *auth.PasswordPolicyError
if errors.As(err, &pe) {
WriteJSONError(w, pe.Error(), http.StatusBadRequest)
return
}

WriteJSONError(w, "Internal server error", http.StatusInternalServerError)
return
}


// Verify current password
user, err := s.dbQueries.GetUserByUsername(r.Context(), currentUser.Username)
if err != nil {
Expand All @@ -219,11 +229,7 @@ func (s *Server) changeOwnPasswordHandler(w http.ResponseWriter, r *http.Request
return
}

hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
WriteJSONError(w, "Internal server error", http.StatusInternalServerError)
return
}


if err := s.dbQueries.UpdateUserPassword(r.Context(), database.UpdateUserPasswordParams{
Username: currentUser.Username,
Expand Down Expand Up @@ -253,11 +259,19 @@ func (s *Server) changeUserPasswordHandler(w http.ResponseWriter, r *http.Reques
return
}

if err := s.passwordPolicy.Validate(req.NewPassword); err != nil {
WriteJSONError(w, err.Error(), http.StatusBadRequest)
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
var pe *auth.PasswordPolicyError
if errors.As(err, &pe) {
WriteJSONError(w, pe.Error(), http.StatusBadRequest)
return
}

WriteJSONError(w, "Internal server error", http.StatusInternalServerError)
return
}


// Check if user exists
user, err := s.dbQueries.GetUserByUsername(r.Context(), username)
if err != nil {
Expand All @@ -269,11 +283,6 @@ func (s *Server) changeUserPasswordHandler(w http.ResponseWriter, r *http.Reques
return
}

hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
WriteJSONError(w, "Internal server error", http.StatusInternalServerError)
return
}

if err := s.dbQueries.UpdateUserPassword(r.Context(), database.UpdateUserPasswordParams{
Username: username,
Expand Down