More paste fixes

This commit is contained in:
Andy Dixon
2026-06-02 11:56:18 +01:00
parent 74f16bfd0d
commit fb230ee35d
4 changed files with 262 additions and 33 deletions
+16 -18
View File
@@ -9,21 +9,22 @@ import (
"runtime" "runtime"
"sync" "sync"
"github.com/andydixon/nexus/internal/terminalenv"
"github.com/creack/pty" "github.com/creack/pty"
) )
// Client manages a local shell PTY session. // Client manages a local shell PTY session.
type Client struct { type Client struct {
mu sync.Mutex mu sync.Mutex
cmd *exec.Cmd cmd *exec.Cmd
ptmx *os.File ptmx *os.File
shell string shell string
onData func([]byte) onData func([]byte)
onError func(error) onError func(error)
onClose func() onClose func()
closed bool closed bool
cols uint16 cols uint16
rows uint16 rows uint16
} }
// Config holds configuration for a local shell session. // Config holds configuration for a local shell session.
@@ -67,10 +68,10 @@ func NewClient(cfg Config) *Client {
rows = 24 rows = 24
} }
return &Client{ return &Client{
shell: shell, shell: shell,
cols: cols, cols: cols,
rows: rows, rows: rows,
onData: cfg.OnData, onData: cfg.OnData,
onError: cfg.OnError, onError: cfg.OnError,
onClose: cfg.OnClose, onClose: cfg.OnClose,
} }
@@ -86,16 +87,13 @@ func (c *Client) Start() error {
} }
cmd := exec.Command(c.shell) cmd := exec.Command(c.shell)
cmd.Env = os.Environ() cmd.Env = terminalenv.LocalShellEnv(os.Environ())
// Start in the user's home directory. // Start in the user's home directory.
if home, err := os.UserHomeDir(); err == nil { if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home cmd.Dir = home
} }
// Set TERM for proper terminal behaviour.
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: c.cols, Cols: c.cols,
Rows: c.rows, Rows: c.rows,
+24 -15
View File
@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/terminalenv"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
@@ -23,21 +24,21 @@ import (
// It handles connection establishment, authentication, PTY allocation, // It handles connection establishment, authentication, PTY allocation,
// terminal resize propagation, port forwarding, and clean shutdown. // terminal resize propagation, port forwarding, and clean shutdown.
type Client struct { type Client struct {
mu sync.Mutex mu sync.Mutex
config *models.Profile config *models.Profile
sshClient *ssh.Client sshClient *ssh.Client
session *ssh.Session session *ssh.Session
stdin io.WriteCloser stdin io.WriteCloser
stdout io.Reader stdout io.Reader
stderr io.Reader stderr io.Reader
connected bool connected bool
onData func(data []byte) onData func(data []byte)
onError func(err error) onError func(err error)
onClose func() onClose func()
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
forwards *ForwardManager // port forwarding tunnels forwards *ForwardManager // port forwarding tunnels
agentCleanup func() // closes agent socket connection agentCleanup func() // closes agent socket connection
} }
// NewClient creates a new SSH client bound to the given profile. // NewClient creates a new SSH client bound to the given profile.
@@ -164,6 +165,14 @@ func (c *Client) connectInternal(
ssh.IUTF8: 1, 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 { if err := session.RequestPty(termType, rows, cols, modes); err != nil {
session.Close() session.Close()
sshConn.Close() sshConn.Close()
+137
View File
@@ -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
}
+85
View File
@@ -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 ""
}