From 647a6d2635e103ea0a2903e9975d528a7eb36fa3 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 21 May 2026 21:09:51 -0700 Subject: [PATCH] Add credentials store and use the auth daemon info Former-commit-id: 631eab53d1f1d663d5bb458e13403a5742e5e1f8 --- clients.go | 5 +- clients/clients.go | 16 ++++- main.go | 27 +++++++- nativessh/server.go | 153 ++++++++++++++++++++++---------------------- 4 files changed, 118 insertions(+), 83 deletions(-) diff --git a/clients.go b/clients.go index d650eeb..4162eef 100644 --- a/clients.go +++ b/clients.go @@ -7,6 +7,7 @@ import ( wgnetstack "github.com/fosrl/newt/clients" "github.com/fosrl/newt/clients/permissions" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/websocket" "golang.zx2c4.com/wireguard/tun/netstack" ) @@ -14,7 +15,7 @@ import ( var wgService *clients.WireGuardService var ready bool -func setupClients(client *websocket.Client) { +func setupClients(client *websocket.Client, credStore *nativessh.CredentialStore) { var host = endpoint if strings.HasPrefix(host, "http://") { host = strings.TrimPrefix(host, "http://") @@ -42,6 +43,8 @@ func setupClients(client *websocket.Client) { logger.Fatal("Failed to create WireGuard service: %v", err) } + wgService.SetCredentialStore(credStore) + client.OnTokenUpdate(func(token string) { wgService.SetToken(token) }) diff --git a/clients/clients.go b/clients/clients.go index 6c4d968..325a828 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -111,6 +111,7 @@ type WireGuardService struct { useNativeInterface bool // SSH server running on the clients' netstack sshServer *sshServerHandle + credStore *nativessh.CredentialStore // Direct UDP relay from main tunnel to clients' WireGuard directRelayStop chan struct{} directRelayWg sync.WaitGroup @@ -196,6 +197,13 @@ func NewWireGuardService(interfaceName string, port uint16, mtu int, host string return service, nil } +// SetCredentialStore sets the in-memory SSH credential store used by the +// native SSH server. It must be called before the netstack is configured +// (i.e. before the first newt/wg/receive-config message is processed). +func (s *WireGuardService) SetCredentialStore(store *nativessh.CredentialStore) { + s.credStore = store +} + // ReportRTT allows reporting native RTTs to telemetry, rate-limited externally. func (s *WireGuardService) ReportRTT(seconds float64) { if s.serverPubKey == "" { @@ -900,7 +908,7 @@ func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error { } // Start the SSH server on the clients' netstack (port 22). - if h, sshErr := startSSHOnNetstack(s.tnet); sshErr != nil { + if h, sshErr := startSSHOnNetstack(s.tnet, s.credStore); sshErr != nil { logger.Warn("nativessh: not starting SSH server on clients netstack: %v", sshErr) } else { s.sshServer = h @@ -1593,8 +1601,10 @@ func (h *sshServerHandle) stop() { // startSSHOnNetstack creates a TCP listener on port 22 of the clients' netstack // and starts serving SSH connections on it in the background. The returned // handle can be used to stop the server by closing the listener. -func startSSHOnNetstack(tnet *netstack2.Net) (*sshServerHandle, error) { - srv := nativessh.NewServer(nativessh.ServerConfig{}) +func startSSHOnNetstack(tnet *netstack2.Net, creds *nativessh.CredentialStore) (*sshServerHandle, error) { + srv := nativessh.NewServer(nativessh.ServerConfig{ + Credentials: creds, + }) ln, err := tnet.ListenTCP(&net.TCPAddr{Port: 22}) if err != nil { return nil, fmt.Errorf("listen on netstack port 22: %w", err) diff --git a/main.go b/main.go index cfeacf2..cc03bc2 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "github.com/fosrl/newt/docker" "github.com/fosrl/newt/healthcheck" "github.com/fosrl/newt/logger" + "github.com/fosrl/newt/nativessh" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/updates" "github.com/fosrl/newt/util" @@ -714,8 +715,12 @@ func runNewtMain(ctx context.Context) { var wgData WgData var dockerEventMonitor *docker.EventMonitor + // In-memory SSH credentials shared with the native SSH server started in + // the clients netstack once the WireGuard interface is ready. + sshCredStore := nativessh.NewCredentialStore() + if !disableClients { - setupClients(client) + setupClients(client, sshCredStore) } // Initialize health check monitor with status change callback @@ -1740,6 +1745,26 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } + var useNativeSSH = true + + if useNativeSSH { + // Update in-memory credentials used by the native SSH server. + if err := sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + + // Acknowledge the PAM connection to the cloud. + if err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ + "messageId": certData.MessageId, + "complete": true, + }); err != nil { + logger.Error("nativessh: failed to send round-trip complete: %v", err) + } + return + } + // Check if we're running the auth daemon internally if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled // Call ProcessConnection directly when running internally diff --git a/nativessh/server.go b/nativessh/server.go index db91d08..46eb804 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -1,38 +1,80 @@ package nativessh import ( - "bufio" "crypto/ed25519" "crypto/rand" "fmt" "io" "log" "net" - "os" "strings" + "sync" "golang.org/x/crypto/ssh" ) -const ( - // DefaultCAKeyPath is the path to the SSH CA public key used to validate - // client certificates. - DefaultCAKeyPath = "/tmp/newt/ssh_ca.pub" - // DefaultPrincipalsPath is the path to a file listing allowed SSH - // certificate principals, one per line. - DefaultPrincipalsPath = "/tmp/newt/ssh_principals" -) +// CredentialStore holds in-memory SSH credentials that can be updated at runtime. +// It is safe for concurrent use. +type CredentialStore struct { + mu sync.RWMutex + caKey ssh.PublicKey + principals map[string]map[string]struct{} // username -> set of allowed principals +} + +// NewCredentialStore returns an empty, ready-to-use CredentialStore. +func NewCredentialStore() *CredentialStore { + return &CredentialStore{ + principals: make(map[string]map[string]struct{}), + } +} + +// SetCAKey parses and stores the CA public key from authorized_keys-format data. +func (s *CredentialStore) SetCAKey(authorizedKeyData string) error { + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKeyData)) + if err != nil { + return fmt.Errorf("parse CA key: %w", err) + } + s.mu.Lock() + s.caKey = key + s.mu.Unlock() + return nil +} + +// AddPrincipals records username and niceId as allowed principals for username. +// Both values are stored; either can appear in the certificate's ValidPrincipals +// field to satisfy the standard cert-auth principal check. +func (s *CredentialStore) AddPrincipals(username, niceId string) { + username = strings.TrimSpace(username) + niceId = strings.TrimSpace(niceId) + if username == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.principals[username] == nil { + s.principals[username] = make(map[string]struct{}) + } + s.principals[username][username] = struct{}{} + if niceId != "" { + s.principals[username][niceId] = struct{}{} + } +} + +// get returns the CA key and the principal set for username under a read lock. +func (s *CredentialStore) get(username string) (ssh.PublicKey, map[string]struct{}) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.caKey, s.principals[username] +} // ServerConfig holds configuration for the native SSH server. type ServerConfig struct { // ListenAddr is the TCP address to listen on. Defaults to ":2222". ListenAddr string - // CAKeyPath is the path to the CA public key file (authorized_keys format). - // Defaults to DefaultCAKeyPath. - CAKeyPath string - // PrincipalsPath is the path to a file of allowed principals, one per line. - // Defaults to DefaultPrincipalsPath. - PrincipalsPath string + // Credentials provides in-memory CA key and per-user principals. + // Updates to the store are reflected immediately for new connections. + // If nil or the store has no CA key set, all connections are rejected. + Credentials *CredentialStore } // Server is a simple SSH server that authenticates clients via SSH certificate @@ -43,40 +85,22 @@ type Server struct { cfg ServerConfig } -// NewServer creates a new Server. Zero-value fields in cfg are replaced with -// defaults. +// NewServer creates a new Server. The ListenAddr defaults to ":2222" when empty. func NewServer(cfg ServerConfig) *Server { if cfg.ListenAddr == "" { cfg.ListenAddr = ":2222" } - if cfg.CAKeyPath == "" { - cfg.CAKeyPath = DefaultCAKeyPath - } - if cfg.PrincipalsPath == "" { - cfg.PrincipalsPath = DefaultPrincipalsPath - } return &Server{cfg: cfg} } -// buildSSHConfig loads keys/principals and builds the ssh.ServerConfig. +// buildSSHConfig builds the ssh.ServerConfig backed by the in-memory CredentialStore. func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { - caKey, err := loadCAPublicKey(s.cfg.CAKeyPath) - if err != nil { - return nil, fmt.Errorf("load CA public key from %s: %w", s.cfg.CAKeyPath, err) - } - - principals, err := loadPrincipals(s.cfg.PrincipalsPath) - if err != nil { - return nil, fmt.Errorf("load principals from %s: %w", s.cfg.PrincipalsPath, err) - } - hostSigner, err := generateHostKey() if err != nil { return nil, fmt.Errorf("host key: %w", err) } - cfg := &ssh.ServerConfig{ - PublicKeyCallback: makeCertAuthCallback(caKey, principals), + PublicKeyCallback: makeCredentialStoreCallback(s.cfg.Credentials), } cfg.AddHostKey(hostSigner) return cfg, nil @@ -216,23 +240,25 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { } } -// makeCertAuthCallback returns an ssh.PublicKeyCallback that accepts only -// SSH user certificates that are: -// 1. Signed by caKey. -// 2. Listing the connecting username in ValidPrincipals (standard cert auth). -// 3. Whose connecting username is also in the local allowedPrincipals set. -func makeCertAuthCallback(caKey ssh.PublicKey, allowedPrincipals map[string]struct{}) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { - checker := &ssh.CertChecker{ - IsUserAuthority: func(auth ssh.PublicKey) bool { - return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) - }, - } +// makeCredentialStoreCallback returns an ssh.PublicKeyCallback that reads +// the CA key and per-user principals from store on every auth attempt, so +// credentials updated via AddPrincipals/SetCAKey are applied immediately. +func makeCredentialStoreCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + caKey, userPrincipals := store.get(meta.User()) + if caKey == nil { + return nil, fmt.Errorf("no CA key configured") + } + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } perms, err := checker.Authenticate(meta, key) if err != nil { return nil, err } - if _, ok := allowedPrincipals[meta.User()]; !ok { + if len(userPrincipals) == 0 { return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) } return perms, nil @@ -250,35 +276,6 @@ func generateHostKey() (ssh.Signer, error) { return ssh.NewSignerFromKey(priv) } -func loadCAPublicKey(path string) (ssh.PublicKey, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - key, _, _, _, err := ssh.ParseAuthorizedKey(data) - if err != nil { - return nil, err - } - return key, nil -} - -func loadPrincipals(path string) (map[string]struct{}, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - principals := make(map[string]struct{}) - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" && !strings.HasPrefix(line, "#") { - principals[line] = struct{}{} - } - } - return principals, scanner.Err() -} - // ptyRequestMsg mirrors the SSH wire format for pty-req (RFC 4254 ยง6.2). type ptyRequestMsg struct { Term string