diff --git a/internal/localshell/localshell.go b/internal/localshell/localshell.go index d9a1542..53cb84a 100644 --- a/internal/localshell/localshell.go +++ b/internal/localshell/localshell.go @@ -9,21 +9,22 @@ import ( "runtime" "sync" + "github.com/andydixon/nexus/internal/terminalenv" "github.com/creack/pty" ) // Client manages a local shell PTY session. type Client struct { - mu sync.Mutex - cmd *exec.Cmd - ptmx *os.File - shell string - onData func([]byte) - onError func(error) - onClose func() - closed bool - cols uint16 - rows uint16 + mu sync.Mutex + cmd *exec.Cmd + ptmx *os.File + shell string + onData func([]byte) + onError func(error) + onClose func() + closed bool + cols uint16 + rows uint16 } // Config holds configuration for a local shell session. @@ -67,10 +68,10 @@ func NewClient(cfg Config) *Client { rows = 24 } return &Client{ - shell: shell, - cols: cols, - rows: rows, - onData: cfg.OnData, + shell: shell, + cols: cols, + rows: rows, + onData: cfg.OnData, onError: cfg.OnError, onClose: cfg.OnClose, } @@ -86,16 +87,13 @@ func (c *Client) Start() error { } cmd := exec.Command(c.shell) - cmd.Env = os.Environ() + cmd.Env = terminalenv.LocalShellEnv(os.Environ()) // Start in the user's home directory. if home, err := os.UserHomeDir(); err == nil { cmd.Dir = home } - // Set TERM for proper terminal behaviour. - cmd.Env = append(cmd.Env, "TERM=xterm-256color") - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ Cols: c.cols, Rows: c.rows, diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index 35e58e0..7fb0bc9 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -14,6 +14,7 @@ import ( "time" "github.com/andydixon/nexus/internal/models" + "github.com/andydixon/nexus/internal/terminalenv" "github.com/pquerna/otp/totp" "golang.org/x/crypto/ssh" "golang.org/x/net/proxy" @@ -23,21 +24,21 @@ import ( // It handles connection establishment, authentication, PTY allocation, // terminal resize propagation, port forwarding, and clean shutdown. type Client struct { - mu sync.Mutex - config *models.Profile - sshClient *ssh.Client - session *ssh.Session - stdin io.WriteCloser - stdout io.Reader - stderr io.Reader - connected bool - onData func(data []byte) - onError func(err error) - onClose func() - ctx context.Context - cancel context.CancelFunc - forwards *ForwardManager // port forwarding tunnels - agentCleanup func() // closes agent socket connection + mu sync.Mutex + config *models.Profile + sshClient *ssh.Client + session *ssh.Session + stdin io.WriteCloser + stdout io.Reader + stderr io.Reader + connected bool + onData func(data []byte) + onError func(err error) + onClose func() + ctx context.Context + cancel context.CancelFunc + forwards *ForwardManager // port forwarding tunnels + agentCleanup func() // closes agent socket connection } // NewClient creates a new SSH client bound to the given profile. @@ -164,6 +165,14 @@ func (c *Client) connectInternal( ssh.IUTF8: 1, } + sessionEnv := terminalenv.SSHEnvVars(sc.EnvVars) + for _, key := range terminalenv.SortedEnvKeys(sessionEnv) { + // Servers may reject env requests unless AcceptEnv permits them. Locale + // requests improve Unicode input when accepted, but rejection should not + // prevent the terminal session from starting. + _ = session.Setenv(key, sessionEnv[key]) + } + if err := session.RequestPty(termType, rows, cols, modes); err != nil { session.Close() sshConn.Close() diff --git a/internal/terminalenv/terminalenv.go b/internal/terminalenv/terminalenv.go new file mode 100644 index 0000000..8a1f2b2 --- /dev/null +++ b/internal/terminalenv/terminalenv.go @@ -0,0 +1,137 @@ +// Package terminalenv prepares environment variables for terminal sessions. +package terminalenv + +import ( + "runtime" + "sort" + "strings" +) + +const ( + envLANG = "LANG" + envLCAll = "LC_ALL" + envLCType = "LC_CTYPE" + envTerm = "TERM" + defaultTerm = "xterm-256color" +) + +// LocalShellEnv returns an environment suitable for a local interactive PTY. +func LocalShellEnv(env []string) []string { + env = setEnv(env, envTerm, defaultTerm) + return ensureUTF8CharacterLocale(env, runtime.GOOS) +} + +// SSHEnvVars returns environment variables to request for an SSH session. +// Profile locale values are preserved; otherwise a UTF-8 character locale is +// requested by default. +func SSHEnvVars(profileEnv map[string]string) map[string]string { + result := map[string]string{} + for key, value := range profileEnv { + key = strings.TrimSpace(key) + if key == "" { + continue + } + result[key] = value + } + + if hasAnyLocaleMap(result) { + return result + } + result[envLCType] = DefaultUTF8Locale(runtime.GOOS) + return result +} + +// SortedEnvKeys returns stable keys for deterministic environment requests. +func SortedEnvKeys(env map[string]string) []string { + keys := make([]string, 0, len(env)) + for key := range env { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// DefaultUTF8Locale returns a locale name broadly available on the target OS. +func DefaultUTF8Locale(goos string) string { + if goos == "darwin" { + return "en_US.UTF-8" + } + return "C.UTF-8" +} + +func ensureUTF8CharacterLocale(env []string, goos string) []string { + if hasEffectiveUTF8Locale(env) { + return env + } + + locale := DefaultUTF8Locale(goos) + if hasEnv(env, envLCAll) { + return setEnv(env, envLCAll, locale) + } + if hasEnv(env, envLCType) { + return setEnv(env, envLCType, locale) + } + if hasEnv(env, envLANG) { + return setEnv(env, envLCType, locale) + } + return setEnv(env, envLANG, locale) +} + +func hasEffectiveUTF8Locale(env []string) bool { + values := envToMap(env) + if value, ok := values[envLCAll]; ok { + return isUTF8Locale(value) + } + if value, ok := values[envLCType]; ok { + return isUTF8Locale(value) + } + if value, ok := values[envLANG]; ok { + return isUTF8Locale(value) + } + return false +} + +func hasAnyLocaleMap(env map[string]string) bool { + _, hasLCAll := env[envLCAll] + _, hasLCType := env[envLCType] + _, hasLANG := env[envLANG] + return hasLCAll || hasLCType || hasLANG +} + +func isUTF8Locale(value string) bool { + normalized := strings.ToLower(strings.ReplaceAll(value, "-", "")) + return strings.Contains(normalized, "utf8") +} + +func setEnv(env []string, key, value string) []string { + prefix := key + "=" + result := make([]string, 0, len(env)+1) + for _, item := range env { + if strings.HasPrefix(item, prefix) { + continue + } + result = append(result, item) + } + return append(result, prefix+value) +} + +func hasEnv(env []string, key string) bool { + prefix := key + "=" + for _, item := range env { + if strings.HasPrefix(item, prefix) { + return true + } + } + return false +} + +func envToMap(env []string) map[string]string { + result := map[string]string{} + for _, item := range env { + key, value, ok := strings.Cut(item, "=") + if ok { + result[key] = value + } + } + return result +} diff --git a/internal/terminalenv/terminalenv_test.go b/internal/terminalenv/terminalenv_test.go new file mode 100644 index 0000000..1c3b90c --- /dev/null +++ b/internal/terminalenv/terminalenv_test.go @@ -0,0 +1,85 @@ +package terminalenv + +import "testing" + +func TestLocalShellEnvSetsTermAndUTF8WhenMissing(t *testing.T) { + got := LocalShellEnv([]string{"SHELL=/bin/zsh"}) + + if value := envValue(got, envTerm); value != defaultTerm { + t.Fatalf("TERM = %q, want %q", value, defaultTerm) + } + if value := envValue(got, envLANG); value == "" || !isUTF8Locale(value) { + t.Fatalf("LANG = %q, want UTF-8 locale", value) + } +} + +func TestLocalShellEnvUsesLCTypeWhenLangIsNonUTF8(t *testing.T) { + got := ensureUTF8CharacterLocale([]string{"LANG=C"}, "linux") + + if value := envValue(got, envLANG); value != "C" { + t.Fatalf("LANG = %q, want C", value) + } + if value := envValue(got, envLCType); value != "C.UTF-8" { + t.Fatalf("LC_CTYPE = %q, want C.UTF-8", value) + } +} + +func TestLocalShellEnvOverridesNonUTF8LCAll(t *testing.T) { + got := ensureUTF8CharacterLocale([]string{"LC_ALL=C", "LANG=C"}, "darwin") + + if value := envValue(got, envLCAll); value != "en_US.UTF-8" { + t.Fatalf("LC_ALL = %q, want en_US.UTF-8", value) + } +} + +func TestSSHEnvVarsPreservesProfileUTF8Locale(t *testing.T) { + got := SSHEnvVars(map[string]string{ + "LANG": "de_DE.UTF-8", + "FOO": "bar", + }) + + if got["LANG"] != "de_DE.UTF-8" { + t.Fatalf("LANG = %q, want profile value", got["LANG"]) + } + if got["FOO"] != "bar" { + t.Fatalf("FOO = %q, want bar", got["FOO"]) + } + if _, ok := got[envLCType]; ok { + t.Fatalf("unexpected LC_CTYPE default when LANG is already UTF-8") + } +} + +func TestSSHEnvVarsPreservesProfileNonUTF8Locale(t *testing.T) { + got := SSHEnvVars(map[string]string{ + envLANG: "C", + "FOO": "bar", + }) + + if got[envLANG] != "C" { + t.Fatalf("LANG = %q, want C", got[envLANG]) + } + if _, ok := got[envLCType]; ok { + t.Fatalf("unexpected LC_CTYPE default when profile sets locale") + } +} + +func TestSSHEnvVarsAddsUTF8LCTypeByDefault(t *testing.T) { + got := SSHEnvVars(map[string]string{"FOO": "bar"}) + + if got["FOO"] != "bar" { + t.Fatalf("FOO = %q, want bar", got["FOO"]) + } + if value := got[envLCType]; value == "" || !isUTF8Locale(value) { + t.Fatalf("LC_CTYPE = %q, want UTF-8 locale", value) + } +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if len(item) >= len(prefix) && item[:len(prefix)] == prefix { + return item[len(prefix):] + } + } + return "" +}