//go:build unix package server import ( "errors" "fmt" "net" "net/netip" "os" "os/exec" "os/user" "regexp" "runtime" "strconv" "github.com/gliderlabs/ssh" log "github.com/sirupsen/logrus" ) // POSIX portable filename character set regex: [a-zA-Z0-9._-] // First character cannot be hyphen (POSIX requirement) var posixUsernameRegex = regexp.MustCompile(`^[a-zA-Z0-9._][a-zA-Z0-9._-]*$`) // validateUsername validates that a username conforms to POSIX standards with security considerations func validateUsername(username string) error { if username == "" { return errors.New("username cannot be empty") } // POSIX allows up to 256 characters, but practical limit is 32 for compatibility if len(username) > 32 { return errors.New("username too long (max 32 characters)") } if !posixUsernameRegex.MatchString(username) { return errors.New("username contains invalid characters (must match POSIX portable filename character set)") } if username == "." || username == ".." { return fmt.Errorf("username cannot be '.' or '..'") } // Warn if username is fully numeric (can cause issues with UID/username ambiguity) if isFullyNumeric(username) { log.Warnf("fully numeric username '%s' may cause issues with some commands", username) } return nil } // isFullyNumeric checks if username contains only digits func isFullyNumeric(username string) bool { for _, char := range username { if char < '0' || char > '9' { return false } } return true } // createPtyLoginCommand creates a Pty command using login for privileged processes func (s *Server) createPtyLoginCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) { loginPath, args, err := s.getLoginCmd(localUser.Username, session.RemoteAddr()) if err != nil { return nil, fmt.Errorf("get login command: %w", err) } execCmd := exec.CommandContext(session.Context(), loginPath, args...) execCmd.Dir = localUser.HomeDir execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session) return execCmd, nil } // getLoginCmd returns the login command and args for privileged Pty user switching func (s *Server) getLoginCmd(username string, remoteAddr net.Addr) (string, []string, error) { loginPath, err := exec.LookPath("login") if err != nil { return "", nil, fmt.Errorf("login command not available: %w", err) } addrPort, err := netip.ParseAddrPort(remoteAddr.String()) if err != nil { return "", nil, fmt.Errorf("parse remote address: %w", err) } switch runtime.GOOS { case "linux": p, a := s.getLinuxLoginCmd(loginPath, username, addrPort.Addr().String()) return p, a, nil case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly": return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), username}, nil default: return "", nil, fmt.Errorf("unsupported Unix platform for login command: %s", runtime.GOOS) } } // getLinuxLoginCmd returns the login command for Linux systems. // Handles differences between util-linux and shadow-utils login implementations. func (s *Server) getLinuxLoginCmd(loginPath, username, remoteIP string) (string, []string) { // Special handling for Arch Linux without /etc/pam.d/remote var loginArgs []string if s.fileExists("/etc/arch-release") && !s.fileExists("/etc/pam.d/remote") { loginArgs = []string{"-f", username, "-p"} } else { loginArgs = []string{"-f", username, "-h", remoteIP, "-p"} } // util-linux login requires setsid -c to create a new session and set the // controlling terminal. Without this, vhangup() kills the parent process. // See https://bugs.debian.org/1078023 for details. // TODO: handle this via the executor using syscall.Setsid() + TIOCSCTTY + syscall.Exec() // to avoid external setsid dependency. if !s.loginIsUtilLinux { return loginPath, loginArgs } setsidPath, err := exec.LookPath("setsid") if err != nil { log.Warnf("setsid not available but util-linux login detected, login may fail: %v", err) return loginPath, loginArgs } args := append([]string{"-w", "-c", loginPath}, loginArgs...) return setsidPath, args } // fileExists checks if a file exists func (s *Server) fileExists(path string) bool { _, err := os.Stat(path) return err == nil } // parseUserCredentials extracts numeric UID, GID, and supplementary groups func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []uint32, error) { uid64, err := strconv.ParseUint(localUser.Uid, 10, 32) if err != nil { return 0, 0, nil, fmt.Errorf("invalid UID %s: %w", localUser.Uid, err) } uid := uint32(uid64) gid64, err := strconv.ParseUint(localUser.Gid, 10, 32) if err != nil { return 0, 0, nil, fmt.Errorf("invalid GID %s: %w", localUser.Gid, err) } gid := uint32(gid64) groups, err := s.getSupplementaryGroups(localUser) if err != nil || len(groups) == 0 { if err != nil { log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err) } groups = []uint32{gid} } return uid, gid, groups, nil } // getSupplementaryGroups retrieves supplementary group IDs for a user. // Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds. func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) { groupIDStrings, err := groupIdsWithFallback(u) if err != nil { return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) } groups := make([]uint32, len(groupIDStrings)) for i, gidStr := range groupIDStrings { gid64, err := strconv.ParseUint(gidStr, 10, 32) if err != nil { return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, u.Username, err) } groups[i] = uint32(gid64) } return groups, nil } // createExecutorCommand creates a command that spawns netbird ssh exec for privilege dropping. // Returns the command and a cleanup function (no-op on Unix). func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) { logger.Debugf("creating executor command for user %s (Pty: %v)", localUser.Username, hasPty) if err := validateUsername(localUser.Username); err != nil { return nil, nil, fmt.Errorf("invalid username %q: %w", localUser.Username, err) } uid, gid, groups, err := s.parseUserCredentials(localUser) if err != nil { return nil, nil, fmt.Errorf("parse user credentials: %w", err) } privilegeDropper := NewPrivilegeDropper(WithLogger(logger)) config := ExecutorConfig{ UID: uid, GID: gid, Groups: groups, WorkingDir: localUser.HomeDir, Shell: getUserShell(localUser.Uid), Command: session.RawCommand(), PTY: hasPty, } cmd, err := privilegeDropper.CreateExecutorCommand(session.Context(), config) return cmd, func() {}, err } // enableUserSwitching is a no-op on Unix systems func enableUserSwitching() error { return nil } // createPtyCommand creates the exec.Cmd for Pty execution respecting privilege check results func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) { localUser := privilegeResult.User if localUser == nil { return nil, errors.New("no user in privilege result") } if privilegeResult.UsedFallback { return s.createDirectPtyCommand(session, localUser, ptyReq), nil } return s.createPtyLoginCommand(localUser, ptyReq, session) } // createDirectPtyCommand creates a direct Pty command without privilege dropping func (s *Server) createDirectPtyCommand(session ssh.Session, localUser *user.User, ptyReq ssh.Pty) *exec.Cmd { log.Debugf("creating direct Pty command for user %s (no user switching needed)", localUser.Username) shell := getUserShell(localUser.Uid) args := s.getShellCommandArgs(shell, session.RawCommand()) cmd := s.createShellCommand(session.Context(), shell, args) cmd.Dir = localUser.HomeDir cmd.Env = s.preparePtyEnv(localUser, ptyReq, session) return cmd } // preparePtyEnv prepares environment variables for Pty execution func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) []string { termType := ptyReq.Term if termType == "" { termType = "xterm-256color" } env := prepareUserEnv(localUser, getUserShell(localUser.Uid)) env = append(env, prepareSSHEnv(session)...) env = append(env, fmt.Sprintf("TERM=%s", termType)) for _, v := range session.Environ() { if acceptEnv(v) { env = append(env, v) } } return env }