mirror of
https://github.com/fosrl/newt.git
synced 2026-07-19 22:31:24 +02:00
Add credentials store and use the auth daemon info
Former-commit-id: 631eab53d1
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
27
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user