diff --git a/.gitignore b/.gitignore index aef7edf..f065620 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ grpc docs docker/ssh-proxy/files wiki -reports \ No newline at end of file +reports +go.work +go.work.sum +common \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index f339503..22c6cb8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,6 +27,14 @@ ], "showLog": true //"stopOnEntry": true + }, + { + "name": "Connect to Delve server", + "type": "go", + "request": "attach", + "mode": "remote", + "port": 2345, + "host": "127.0.0.1" } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index cd2b029..f794bdd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,7 @@ { + "git.scanRepositories": [ + "common" + ], "[markdown]": { "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 120, diff --git a/Makefile b/Makefile index b05f939..fae81f3 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ REPORTS_DIR := reports SERVICE_NAME ?= $(shell basename $(CURDIR)) RUNTIME ?= alpine -.PHONY: all init install-test-deps test-static test build test-binary test-self vendor image image-debug image-release coverage clean help +.PHONY: all init install-test-deps test-static test build test-binary test-self vendor image image-debug image-release reload dlv coverage clean help # Default target all: build @@ -48,7 +48,7 @@ test: install-test-deps build: ##@ Build binaries @echo "Building binaries..." - go build -o bin/$(SERVICE_NAME) main.go + CGO_ENABLED=0 go build -gcflags="all=-N -l" -o bin/$(SERVICE_NAME) main.go @echo "Build complete!" test-binary: ##@ Run binary smoke tests @@ -96,6 +96,14 @@ image: vendor -f docker/$(SERVICE_NAME)/Dockerfile \ . +reload: build ##@ Hot-swap the running $(SERVICE_NAME) binary in place (dev only) + ##@ Finds the container's while-loop entrypoint via /proc, replaces /app/$(SERVICE_NAME), and kills the child so the loop respawns it + @./scripts/reload.sh + +dlv: ##@ Attach delve to the running $(SERVICE_NAME) process for remote debugging + ##@ Headless debug server on 127.0.0.1:2345 (override with DLV_LISTEN) — connect with `dlv connect` or your IDE + @./scripts/dlv.sh + coverage: ##@ Calculate test coverage percentage from coverage.out @go tool cover -func=$(REPORTS_DIR)/coverage.out | grep total | awk '{print $$3}' diff --git a/bin/ssh-proxy b/bin/ssh-proxy index 6f57851..92d6ba1 100755 Binary files a/bin/ssh-proxy and b/bin/ssh-proxy differ diff --git a/go.mod b/go.mod index e405632..e8c76a0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/k8shell-io/ssh-proxy go 1.24.5 require ( - github.com/k8shell-io/common v0.29.4 + github.com/k8shell-io/common v0.31.0 github.com/nats-io/nats.go v1.47.0 github.com/rs/zerolog v1.34.0 golang.org/x/crypto v0.43.0 @@ -20,6 +20,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/go.sum b/go.sum index d77edb3..182c816 100644 --- a/go.sum +++ b/go.sum @@ -31,48 +31,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/k8shell-io/common v0.20.20 h1:4Tze71ObbtFT6l45wwhDGw8u35w0gTRzuAvH55bEsJE= -github.com/k8shell-io/common v0.20.20/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.21.0 h1:EOwaQOFnHQJsHcDLDVEAwNnJJe7uvQCqcOiI6BTu3GE= -github.com/k8shell-io/common v0.21.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.22.4 h1:zii3NIHOldIrwIldtuwWFFqjEV6JYhbtT50na5pDxLk= -github.com/k8shell-io/common v0.22.4/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.23.0 h1:3v5KjcITfMpdKGVHinHASvV5Y3zAZiBA8y6Br+gusx8= -github.com/k8shell-io/common v0.23.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.23.1 h1:Mk0vlt8/J7w/KhYWNS5/lKhk7Dr14ER5UOkYdu4xsP4= -github.com/k8shell-io/common v0.23.1/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.23.2 h1:Gvnt6XzHq9tiAeuhCw64uw5RIUmVQZY57U2X55BLgg8= -github.com/k8shell-io/common v0.23.2/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.23.3 h1:m/1f8x2TfhIqY6hNFBkOthpieCsYtEwac8+QaYarX2c= -github.com/k8shell-io/common v0.23.3/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.23.4 h1:T4VEI0I8efV/Dz2dXmQMUdJ3wOWPy1R/+m/50IEC68A= -github.com/k8shell-io/common v0.23.4/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.23.6 h1:t9ltW+n6pMpxp1q51+7OSoRrM9qMpM108aO6WtVeGTk= -github.com/k8shell-io/common v0.23.6/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.24.0 h1:jnuojNP9uLF2EpxavZPsR7coCkRsczIJUOtxh2uBlNQ= -github.com/k8shell-io/common v0.24.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.24.1 h1:NRXjzT9JlHkfsBU4g0tGv24xJbl4Y3NdswFu2FmT8Q8= -github.com/k8shell-io/common v0.24.1/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.24.3 h1:/ni3OBj9WXpe4sPLauIrRzJud5UEAwZOUJxfQDD4yu4= -github.com/k8shell-io/common v0.24.3/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.24.4 h1:clB+fM0K6yZHnSPyGL9kAimPL9g8Blxr0aVwkaEMasg= -github.com/k8shell-io/common v0.24.4/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.24.5 h1:NwgsWDr4S8gLzXVVgVOHOk44m0eO3uCypmjeph+wBJg= -github.com/k8shell-io/common v0.24.5/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.24.6 h1:JbdxoOgYbx8D2zRNO5p/EyRZCrNbRtQj6RGetibW9lk= -github.com/k8shell-io/common v0.24.6/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.25.0 h1:wPVpNT5bjq2teOwWd6J6jIMLP4c5JrDeNCNjXXdIkbg= -github.com/k8shell-io/common v0.25.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.25.2 h1:RuxkyxlDhAXmTdOaI2KupZnS/ckQVAXLSat1L9ZmFmw= -github.com/k8shell-io/common v0.25.2/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.26.0 h1:IdqXufdCJOPw+HygsmNYSQlDJiMEJIPeGVAus5BIJw4= -github.com/k8shell-io/common v0.26.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.26.1 h1:hJQoq5pHd20pFjHWThnD+VIgPlrv9qXE37P7IH68/m4= -github.com/k8shell-io/common v0.26.1/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.26.2 h1:pGuamWyw+UoZI8Z5gEltwttfgHJiCHmy+2QSOgPWeL0= -github.com/k8shell-io/common v0.26.2/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.29.4 h1:patjuhCWs3g/JVNmx7SVa+wgvu46Z5RYAkD7YY6A4tY= -github.com/k8shell-io/common v0.29.4/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= +github.com/k8shell-io/common v0.30.10 h1:sIL7pjx38YE/KtRB2VXK+BHL7D+NcM88Lzop/xJfBT8= +github.com/k8shell-io/common v0.30.10/go.mod h1:40c5GkpS7Y0/aOFa37Lq8z/mLUn3k3GV/AHtFJFL28k= +github.com/k8shell-io/common v0.31.0 h1:iFo6IUdNQCU2cKnUPd2u2vJ14UKR+rodTPj9uax6Ay8= +github.com/k8shell-io/common v0.31.0/go.mod h1:40c5GkpS7Y0/aOFa37Lq8z/mLUn3k3GV/AHtFJFL28k= github.com/k8shell-io/crypto v0.41.1-ssh-proxy h1:8+q6Ofc2ky23Oc9iNyiq8aeiQBIP+y3+O6zzHqe1f48= github.com/k8shell-io/crypto v0.41.1-ssh-proxy/go.mod h1:RVZeOJCpqtogniULztSXQESKJCfcI8WCxsS0FagMA8U= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= diff --git a/internal/server/auth.go b/internal/server/auth.go index 5ec5e5b..feb8e37 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -12,6 +12,7 @@ import ( "time" identityv1 "github.com/k8shell-io/common/pkg/api/gen/go/identity/v1" + "github.com/k8shell-io/common/pkg/authz" "github.com/k8shell-io/common/pkg/gapi" "github.com/k8shell-io/common/pkg/models" "golang.org/x/crypto/ssh" @@ -21,13 +22,16 @@ import ( // AllowedAuthsCallback returns the available authentication methods for the user. func (s *Server) AllowedAuthsCallback(conn ssh.ConnMetadata) ssh.ServerAuthCallbacks { + ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second) + defer cancel() + connInfo, err := s.GetConnInfo(conn) if err != nil { s.log.Error().Msgf("Failed to get connection info: %v", err) return ssh.ServerAuthCallbacks{} } - s.updateUser(s.ctx, connInfo) - authMethods := s.getAvailableAuthMethods(connInfo) + s.updateUser(ctx, connInfo) + authMethods := s.getAvailableAuthMethods(ctx, connInfo) if authMethods == nil { return ssh.ServerAuthCallbacks{} } @@ -45,18 +49,21 @@ func (s *Server) AuthPublicKey(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ss } s.updateUser(ctx, connInfo) if connInfo.user != nil { - if slices.Contains(connInfo.user.Auths, "publickey") { - if s.authPublicKey(connInfo.user, pubKey) { - s.log.Info().Msgf("User %s authenticated with public key", connInfo.user.Username) - return &ssh.Permissions{}, nil - } else { - connInfo.AddFailureInfo("Public key authentication failed", nil) - return nil, fmt.Errorf("public key authentication failed for user %s", connInfo.user.Username) - } - } else { - connInfo.AddFailureInfo("Public key authentication not available", nil) + methods, err := s.resolveAuthMethods(ctx, connInfo) + if err != nil { + connInfo.AddFailureInfo("Failed to resolve authentication methods", err) + return nil, fmt.Errorf("public key authentication not available for user %s", connInfo.user.Username) + } + if !slices.Contains(methods, authz.UserAuthMethodPublicKey) { + connInfo.AddFailureInfo("Public key authentication not permitted by policy", nil) return nil, fmt.Errorf("public key authentication not available for user %s", connInfo.user.Username) } + if s.authPublicKey(connInfo.user, pubKey) { + s.log.Info().Msgf("User %s authenticated with public key", connInfo.user.Username) + return &ssh.Permissions{}, nil + } + connInfo.AddFailureInfo("Public key authentication failed", nil) + return nil, fmt.Errorf("public key authentication failed for user %s", connInfo.user.Username) } if connInfo.GetOnboardCap() != nil && connInfo.GetOnboardCap().CanOnboard { @@ -82,18 +89,21 @@ func (s *Server) AuthPassword(conn ssh.ConnMetadata, password []byte) (*ssh.Perm } s.updateUser(ctx, connInfo) if connInfo.user != nil { - if slices.Contains(connInfo.user.Auths, "password") { - if s.authPassword(connInfo.user) { - s.log.Info().Msgf("User %s authenticated with password", connInfo.user.Username) - return &ssh.Permissions{}, nil - } else { - connInfo.AddFailureInfo("Password authentication failed", nil) - return nil, fmt.Errorf("password authentication failed for user %s", connInfo.user.Username) - } - } else { - connInfo.AddFailureInfo("Password authentication not available", nil) + methods, err := s.resolveAuthMethods(ctx, connInfo) + if err != nil { + connInfo.AddFailureInfo("Failed to resolve authentication methods", err) return nil, fmt.Errorf("password authentication not available for user %s", connInfo.user.Username) } + if !slices.Contains(methods, authz.UserAuthMethodPassword) { + connInfo.AddFailureInfo("Password authentication not permitted by policy", nil) + return nil, fmt.Errorf("password authentication not available for user %s", connInfo.user.Username) + } + if s.authPassword(connInfo.user, password) { + s.log.Info().Msgf("User %s authenticated with password", connInfo.user.Username) + return &ssh.Permissions{}, nil + } + connInfo.AddFailureInfo("Password authentication failed", nil) + return nil, fmt.Errorf("password authentication failed for user %s", connInfo.user.Username) } if connInfo.GetOnboardCap() != nil && connInfo.GetOnboardCap().CanOnboard { @@ -188,7 +198,7 @@ func (s *Server) checkAuthInteractiveResponse(ctx context.Context, s.log.Error().Msgf("Failed to complete device flow for user %s: %v", onboardInfo.Username, err) } s.log.Info().Msgf("Onboarding completed for user %s", onboardInfo.Username) - return nil, s.getAvailableAuthMethods(auth) + return nil, s.getAvailableAuthMethods(ctx, auth) } // AuthPublicKey handles public key authentication via the identity service. @@ -213,9 +223,20 @@ func (s *Server) authPublicKey(user *models.User, pubKey ssh.PublicKey) bool { } // AuthPassword handles password authentication via the identity service. -func (s *Server) authPassword(_ *models.User) bool { - // TODO: Call identity service to validate password - return false +func (s *Server) authPassword(user *models.User, password []byte) bool { + s.log.Debug().Msgf("Authenticating user %s with password", user.Username) + authResponse, err := s.identity.AuthUserPassword(s.ctx, &identityv1.AuthUserPasswordRequest{ + Username: user.Username, Password: string(password)}) + if err != nil || authResponse == nil { + s.log.Error().Msgf("Failed to get authentication response for user %s: %v", user.Username, err) + return false + } + + if !authResponse.Valid { + s.log.Warn().Msgf("Password authentication failed for user %s", user.Username) + return false + } + return true } // updateUser fetches user from the identity service and updates user auth @@ -260,7 +281,7 @@ func (s *Server) updateUser(ctx context.Context, connInfo *Connection) { // getAvailableAuthMethods returns the available authentication methods for the user. // It returns callbacks for the SSH server authentication process. -func (s *Server) getAvailableAuthMethods(connInfo *Connection) *ssh.PartialSuccessError { +func (s *Server) getAvailableAuthMethods(ctx context.Context, connInfo *Connection) *ssh.PartialSuccessError { if connInfo.user == nil { onboardCap := connInfo.GetOnboardCap() if onboardCap != nil && onboardCap.CanOnboard { @@ -275,28 +296,75 @@ func (s *Server) getAvailableAuthMethods(connInfo *Connection) *ssh.PartialSucce return nil } + methods, err := s.resolveAuthMethods(ctx, connInfo) + if err != nil { + s.log.Error().Msgf("Failed to resolve authentication methods for user %s: %v", connInfo.user.Username, err) + connInfo.AddFailureInfo("Failed to resolve authentication methods", err) + return nil + } + callbacks := ssh.ServerAuthCallbacks{} - for _, authMethod := range connInfo.user.Auths { - switch string(authMethod) { - case "publickey": - s.log.Debug().Msgf("Enabling public key authentication for user %s", connInfo.user.Username) - callbacks.PublicKeyCallback = s.AuthPublicKey - case "password": - s.log.Debug().Msgf("Enabling password authentication for user %s", connInfo.user.Username) - callbacks.PasswordCallback = s.AuthPassword - } + if slices.Contains(methods, authz.UserAuthMethodPublicKey) { + s.log.Debug().Msgf("Enabling public key authentication for user %s", connInfo.user.Username) + callbacks.PublicKeyCallback = s.AuthPublicKey + } + if slices.Contains(methods, authz.UserAuthMethodPassword) { + s.log.Debug().Msgf("Enabling password authentication for user %s", connInfo.user.Username) + callbacks.PasswordCallback = s.AuthPassword } - if callbacks.PublicKeyCallback != nil || - callbacks.PasswordCallback != nil || - callbacks.KeyboardInteractiveCallback != nil { - return &ssh.PartialSuccessError{ - Next: callbacks, - } + if callbacks.PublicKeyCallback == nil && callbacks.PasswordCallback == nil { + s.log.Warn().Msgf("No available authentication methods for user %s", connInfo.userStr.Username()) + connInfo.AddFailureInfo("No available authentication methods", nil) + return nil } - s.log.Warn().Msgf("No available authentication methods for user %s", connInfo.userStr.Username()) - connInfo.AddFailureInfo("No available authentication methods", nil) + return &ssh.PartialSuccessError{ + Next: callbacks, + } +} + +// resolveAuthMethods returns the SSH authentication methods permitted for the +// connection's user, evaluating the user:auth policy at most once per +// connection (the result is cached on Connection, since it gates both +// advertising in getAvailableAuthMethods and enforcement in +// AuthPublicKey/AuthPassword). When authz is not configured, both methods are +// permitted. When authz is configured but the response carries no +// auth_methods obligation, no methods are permitted, per the user:auth +// contract. +func (s *Server) resolveAuthMethods(ctx context.Context, connInfo *Connection) ([]authz.UserAuthMethod, error) { + if methods, ok := connInfo.GetAuthMethods(); ok { + return methods, nil + } + + if s.authzClient == nil { + methods := []authz.UserAuthMethod{authz.UserAuthMethodPublicKey, authz.UserAuthMethodPassword} + connInfo.SetAuthMethods(methods) + return methods, nil + } - return nil + token, err := connInfo.GetUserToken() + if err != nil { + return nil, fmt.Errorf("failed to get user token for authz check: %w", err) + } + + req, err := authz.NewUserAuthEvalRequest(connInfo.user.Username). + WithIDP(connInfo.user.Source). + WithOrg(connInfo.user.Organization). + Build() + if err != nil { + return nil, fmt.Errorf("failed to build user:auth request: %w", err) + } + + ob, found, err := s.checkUserAuthMethodsAuthz(ctx, token, req) + if err != nil { + return nil, err + } + + methods := []authz.UserAuthMethod{} + if found { + methods = ob.Methods + } + connInfo.SetAuthMethods(methods) + return methods, nil } diff --git a/internal/server/authzcheck.go b/internal/server/authzcheck.go index 873a0d4..27e2777 100644 --- a/internal/server/authzcheck.go +++ b/internal/server/authzcheck.go @@ -54,3 +54,30 @@ func (s *Server) checkSessionAuthz(ctx context.Context, token string, req *authz ob, found := authz.ParseRecordObligation(resp.GetObligations()) return ob, found, nil } + +// checkUserAuthMethodsAuthz evaluates a user:auth request against the authz +// service and returns the auth_methods obligation naming which SSH +// authentication methods the policy permits for the user. When authz is not +// configured, returns (zero, false, nil) so callers fall back to their own +// default. When authz is configured but the response carries no auth_methods +// obligation, found is false and, per the user:auth contract, the caller must +// offer no authentication methods. +func (s *Server) checkUserAuthMethodsAuthz(ctx context.Context, token string, req *authz.UserAuthEvalRequest) (authz.AuthMethodsObligation, bool, error) { + if s.authzClient == nil { + return authz.AuthMethodsObligation{}, false, nil + } + if err := req.Validate(); err != nil { + return authz.AuthMethodsObligation{}, false, fmt.Errorf("authz: invalid request: %w", err) + } + protoReq := req.ToProto(token) + protoReq.Package = "user" + resp, err := s.authzClient.Evaluate(ctx, protoReq) + if err != nil { + return authz.AuthMethodsObligation{}, false, fmt.Errorf("authz: evaluate user:auth: %w", err) + } + if !resp.GetAllowed() { + return authz.AuthMethodsObligation{}, false, fmt.Errorf("user:auth denied: %s", resp.GetReason()) + } + ob, found := authz.ParseAuthMethodsObligation(resp.GetObligations()) + return ob, found, nil +} diff --git a/internal/server/connection.go b/internal/server/connection.go index e5448a2..85d09c7 100644 --- a/internal/server/connection.go +++ b/internal/server/connection.go @@ -67,6 +67,9 @@ type Connection struct { reportStopCh chan struct{} // channel to signal report goroutine to stop reportWg sync.WaitGroup // wait group for report goroutine ptyName string // name of the allocated pseudo-terminal (if any) + authMethodsMu sync.RWMutex // mutex for synchronizing access to authMethods + authMethods []authz.UserAuthMethod // SSH authentication methods permitted by policy (resolved once per connection) + authMethodsSet bool // whether authMethods has been resolved } // Session holds information about a user's SSH session @@ -320,7 +323,7 @@ func (c *Connection) updateSession(action string) (bool, error) { Workspace: c.workspaceName, BytesIn: curIn, BytesOut: curOut, - Channels: curChannels, + Operations: curChannels, UpdatedAt: &t, Blueprint: c.userStr.Blueprint(), } @@ -365,6 +368,25 @@ func (c *Connection) GetOnboardCap() *models.OnboardCapability { return c.onboardCap } +// SetAuthMethods caches the SSH authentication methods permitted by policy +// for this connection, so the user:auth policy is evaluated at most once per +// connection even though it gates both advertising (getAvailableAuthMethods) +// and enforcement (AuthPublicKey/AuthPassword). +func (c *Connection) SetAuthMethods(methods []authz.UserAuthMethod) { + c.authMethodsMu.Lock() + defer c.authMethodsMu.Unlock() + c.authMethods = methods + c.authMethodsSet = true +} + +// GetAuthMethods retrieves the cached policy-permitted authentication methods. +// The second return value is false when the methods have not been resolved yet. +func (c *Connection) GetAuthMethods() ([]authz.UserAuthMethod, bool) { + c.authMethodsMu.RLock() + defer c.authMethodsMu.RUnlock() + return c.authMethods, c.authMethodsSet +} + // grpcClientMessage returns a clean single-line message from an error, // collapsing newlines and extra whitespace. func grpcClientMessage(err error) string { @@ -447,7 +469,13 @@ func (c *Connection) Handshake(writer io.Writer, writerOptions *workspace.InfoWr c.log.Debug().Msgf("Connecting to k8shelld at %s:%d for user %s, version: %s", status.ServerName, status.Port, c.user.Username, status.AppVersion) - handshake, err := k8shelld.Handshake(c.ctx, "") + userToken, err := c.GetUserToken() + if err != nil { + infoWriter.WriteSystemError("Failed to obtain access token.") + return nil, fmt.Errorf("failed to get user token for user %s: %w", c.user.Username, err) + } + + handshake, err := k8shelld.Handshake(c.ctx, userToken) if err != nil { msg := grpcClientMessage(err) if s, ok := grpcstatus.FromError(err); ok && s.Code() == grpccodes.Unavailable { diff --git a/internal/server/session.go b/internal/server/session.go index 582ca48..dc8f20f 100644 --- a/internal/server/session.go +++ b/internal/server/session.go @@ -95,7 +95,7 @@ func (s *Server) handleSessionRequests(requests <-chan *ssh.Request, connInfo *C if err != nil { s.log.Error().Msgf("Failed to reply to subsystem request: %v", err) } - connInfo.AddChannelInfo(models.ChannelShortSf) + connInfo.AddChannelInfo(string(models.OpSFTP)) sessionType <- "sftp" sessionTypeSent = true } else { @@ -128,7 +128,7 @@ func (s *Server) handleSessionRequests(requests <-chan *ssh.Request, connInfo *C session.hasPTY = true accepted = true - connInfo.AddChannelInfo(models.ChannelShortPt) + connInfo.AddChannelInfo(string(models.OpPty)) s.log.Debug().Msgf("PTY request accepted for user %s", session.username) case "env": @@ -161,7 +161,7 @@ func (s *Server) handleSessionRequests(requests <-chan *ssh.Request, connInfo *C s.log.Debug().Msgf("Shell request accepted for user %s", session.username) sessionType <- "shell" sessionTypeSent = true - connInfo.AddChannelInfo(models.ChannelShortSh) + connInfo.AddChannelInfo(string(models.OpShell)) case "exec": command, err := s.parseExecRequest(req.Payload) @@ -173,7 +173,7 @@ func (s *Server) handleSessionRequests(requests <-chan *ssh.Request, connInfo *C s.log.Debug().Msgf("Exec request accepted for user %s: %s", session.username, command) sessionType <- "exec" sessionTypeSent = true - connInfo.AddChannelInfo(models.ChannelShortEx) + connInfo.AddChannelInfo(string(models.OpExec)) } case "signal": @@ -223,7 +223,9 @@ func (s *Server) handleSessionRequests(requests <-chan *ssh.Request, connInfo *C session.termWidth = width session.termHeight = height - if err := k8shelld.ResizeTerminal(connInfo.ctx, session.sessionId, width, height); err != nil { + if userToken, err := connInfo.GetUserToken(); err != nil { + s.log.Error().Msgf("Failed to get user token for resize: %v", err) + } else if err := k8shelld.ResizeTerminal(connInfo.ctx, userToken, session.sessionId, width, height); err != nil { s.log.Error().Msgf("Failed to resize terminal: %v", err) } } else { @@ -243,7 +245,7 @@ func (s *Server) handleSessionRequests(requests <-chan *ssh.Request, connInfo *C s.log.Debug().Msgf("SSH agent forwarding request accepted for user %s", session.username) session.env = append(session.env, fmt.Sprintf("SSH_AUTH_SOCK=%s", session.sshAuthSock)) - connInfo.AddChannelInfo(models.ChannelShortAf) + connInfo.AddChannelInfo(string(models.OpForwardAgent)) default: s.log.Warn().Msgf("Unsupported session request type: %s for user %s", req.Type, session.username) diff --git a/internal/server/ssh.go b/internal/server/ssh.go index 455ca49..ac2fa4e 100644 --- a/internal/server/ssh.go +++ b/internal/server/ssh.go @@ -68,6 +68,12 @@ func (bc *BufferedConn) Read(b []byte) (int, error) { return bc.reader.Read(b) } +// forkingEnabled reports whether accepted connections are handled by forking +// a child process (see startSubProcess) rather than directly in this process. +func (s *Server) forkingEnabled() bool { + return s.Config.Server.Forking //&& false +} + // NewServer creates a new SSH server instance. func NewServer(configPath string) (*Server, error) { log := log.NewLogger("ssh-server") @@ -90,7 +96,7 @@ func NewServer(configPath string) (*Server, error) { return nil, fmt.Errorf("failed to initialize SSH config: %w", err) } - if !server.Config.Server.Forking { + if !server.forkingEnabled() { server.fpub, err = NewNatsFailuresPublisher(config.Nats, config.Server.PublishSshFailures) if err != nil { server.log.Error().Msgf("failed to create NATS client: %v", err) @@ -407,7 +413,7 @@ func (s *Server) handleChannels(sshConn *ssh.ServerConn, connInfo *Connection, c case "session": go s.handleSessionChannel(sshConn, connInfo, channel) case "direct-tcpip": - connInfo.AddChannelInfo(models.ChannelShortPf) + connInfo.AddChannelInfo(string(models.OpPortForward)) go s.handleDirectTCPIPChannel(sshConn, connInfo, channel) case "direct-streamlocal@openssh.com": connInfo.AddChannelInfo("ux") @@ -496,7 +502,7 @@ func (s *Server) acceptConnections() { } } - if s.Config.Server.Forking { + if s.forkingEnabled() { s.wg.Add(1) go s.startSubProcess(conn) } else { diff --git a/internal/workspace/client.go b/internal/workspace/client.go index c84f9d1..1f87011 100644 --- a/internal/workspace/client.go +++ b/internal/workspace/client.go @@ -23,7 +23,7 @@ type K8shelldClient interface { RunShell(ctx context.Context, userToken string, asUser string, upstream k8shelldClient.BufferedReadWriter, sessionId string, envVars []string, width, height uint32, usePty bool, lockId string, detachOnClose bool, showInitScriptsStatus bool, enableRecording bool, notifyPtyName k8shelld.NotifyPtyNameFunc) error - ResizeTerminal(ctx context.Context, sessionId string, width, height uint32) error + ResizeTerminal(ctx context.Context, userToken string, sessionId string, width, height uint32) error RunUnixSocket(ctx context.Context, userToken string, upstream k8shelldClient.BufferedReadWriter, unixSocketId, socketPath, mode string) error RunPortForward(ctx context.Context, userToken string, upstream k8shelldClient.BufferedReadWriter, portForwardID, sourceIP string, sourcePort uint32, destinationIP string, destinationPort uint32, enableRecording bool) error diff --git a/scripts/dlv.sh b/scripts/dlv.sh new file mode 100755 index 0000000..d0783d8 --- /dev/null +++ b/scripts/dlv.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Attaches delve to the running api-server process for remote debugging. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_NAME="$(basename -s .git "$(git -C "$SCRIPT_DIR" config --get remote.origin.url)")" + +BIN_NAME="$REPO_NAME" +DLV="/go/bin/dlv" +LISTEN_ADDR="${DLV_LISTEN:-127.0.0.1:2345}" + +mapfile -t CHILD_PIDS < <(pgrep -x "$BIN_NAME") +if [[ ${#CHILD_PIDS[@]} -eq 0 ]]; then + echo "error: no running ${BIN_NAME} process found" >&2 + exit 1 +elif [[ ${#CHILD_PIDS[@]} -gt 1 ]]; then + echo "error: multiple ${BIN_NAME} processes found (${CHILD_PIDS[*]}) — expected exactly one" >&2 + exit 1 +fi +CHILD_PID="${CHILD_PIDS[0]}" + +echo "attaching dlv to pid ${CHILD_PID}, listening on ${LISTEN_ADDR}" +exec sudo "$DLV" attach "$CHILD_PID" \ + --listen="$LISTEN_ADDR" \ + --headless=true \ + --api-version=2 \ + --accept-multiclient \ + --log \ + --only-same-user=false diff --git a/scripts/reload.sh b/scripts/reload.sh new file mode 100755 index 0000000..bc448f2 --- /dev/null +++ b/scripts/reload.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Hot-swaps a freshly built $HOME//bin/ into a running +# container without restarting it. Relies on the alpine runtime's while-loop +# entrypoint (see docker/${REPO_NAME}/Dockerfile): killing the child process +# leaves the `while true; do /app/; done` parent alive, which +# immediately respawns using whatever binary is now at /app/. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_NAME="$(basename -s .git "$(git -C "$SCRIPT_DIR" config --get remote.origin.url)")" +REPO_DIR="${HOME}/${REPO_NAME}" + +BIN_NAME="$REPO_NAME" +LOCAL_BIN="${REPO_DIR}/bin/${BIN_NAME}" +CONTAINER_BIN="/app/${BIN_NAME}" + +if [[ ! -f "$LOCAL_BIN" ]]; then + echo "error: ${LOCAL_BIN} not found — run 'make build' first" >&2 + exit 1 +fi + +mapfile -t CHILD_PIDS < <(pgrep -x "$BIN_NAME") +if [[ ${#CHILD_PIDS[@]} -eq 0 ]]; then + echo "error: no running ${BIN_NAME} process found" >&2 + exit 1 +elif [[ ${#CHILD_PIDS[@]} -gt 1 ]]; then + echo "error: multiple ${BIN_NAME} processes found (${CHILD_PIDS[*]}) — expected exactly one" >&2 + exit 1 +fi +CHILD_PID="${CHILD_PIDS[0]}" + +PARENT_PID=$(ps -o ppid= -p "$CHILD_PID" | tr -d ' ') +if [[ -z "$PARENT_PID" ]]; then + echo "error: could not determine parent pid of ${CHILD_PID}" >&2 + exit 1 +fi + +ROOT="/proc/${PARENT_PID}/root" +TARGET="${ROOT}${CONTAINER_BIN}" +TARGET_NEW="${TARGET}.new" + +if ! sudo test -e "$ROOT"; then + echo "error: ${ROOT} not accessible — is pid ${PARENT_PID} the loop's namespace root?" >&2 + exit 1 +fi + +echo "${BIN_NAME}: child=${CHILD_PID} parent(loop)=${PARENT_PID}" +echo "copying ${LOCAL_BIN} -> ${TARGET_NEW}" +sudo cp "$LOCAL_BIN" "$TARGET_NEW" + +echo "installing: ${TARGET_NEW} -> ${TARGET}" +sudo mv "$TARGET_NEW" "$TARGET" + +echo "killing pid ${CHILD_PID} to trigger respawn" +sudo kill "$CHILD_PID" + +echo "done — loop (pid ${PARENT_PID}) should respawn ${CONTAINER_BIN} momentarily"