Add credentials store and use the auth daemon info

Former-commit-id: 631eab53d1
This commit is contained in:
Owen
2026-05-21 21:09:51 -07:00
parent 677146e20e
commit 647a6d2635
4 changed files with 118 additions and 83 deletions

View File

@@ -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)
})

View File

@@ -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)

27
main.go
View File

@@ -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

View File

@@ -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