fix: harden SSH trust and dependency security
This commit is contained in:
+53
-32
@@ -680,6 +680,9 @@ func (s *Service) ExportProfiles(includeSecrets bool) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list profiles for export: %w", err)
|
||||
}
|
||||
if !includeSecrets {
|
||||
profiles = redactProfileSecrets(profiles)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(profiles, "", " ")
|
||||
if err != nil {
|
||||
@@ -1604,28 +1607,17 @@ func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
|
||||
|
||||
// GenerateSSHKey generates a new SSH key pair.
|
||||
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("key name is required")
|
||||
}
|
||||
// Sanitise name — only allow alphanumeric, hyphens, underscores
|
||||
for _, ch := range name {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
|
||||
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
keyPath, _, err := sshKeyPairPaths(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine home directory: %w", err)
|
||||
return err
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
sshDir := filepath.Dir(keyPath)
|
||||
|
||||
// Ensure .ssh directory exists
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
return fmt.Errorf("unable to create .ssh directory: %w", err)
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(sshDir, name)
|
||||
// Check if key already exists
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return fmt.Errorf("key %q already exists", name)
|
||||
@@ -1659,11 +1651,10 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
|
||||
|
||||
// GetSSHPublicKey returns the content of a public key file.
|
||||
func (s *Service) GetSSHPublicKey(name string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
_, pubPath, err := sshKeyPairPaths(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to determine home directory: %w", err)
|
||||
return "", err
|
||||
}
|
||||
pubPath := filepath.Join(home, ".ssh", name+".pub")
|
||||
data, err := os.ReadFile(pubPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to read public key: %w", err)
|
||||
@@ -1673,22 +1664,9 @@ func (s *Service) GetSSHPublicKey(name string) (string, error) {
|
||||
|
||||
// DeleteSSHKey removes an SSH key pair.
|
||||
func (s *Service) DeleteSSHKey(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("key name is required")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
privPath, pubPath, err := sshKeyPairPaths(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine home directory: %w", err)
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
privPath := filepath.Join(sshDir, name)
|
||||
pubPath := privPath + ".pub"
|
||||
|
||||
// Verify paths are within .ssh directory (prevent directory traversal)
|
||||
absPriv, _ := filepath.Abs(privPath)
|
||||
absSsh, _ := filepath.Abs(sshDir)
|
||||
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("invalid key name")
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []string
|
||||
@@ -1703,3 +1681,46 @@ func (s *Service) DeleteSSHKey(name string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshKeyPairPaths(name string) (string, string, error) {
|
||||
keyName, err := validateSSHKeyName(name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unable to determine home directory: %w", err)
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
privPath := filepath.Join(sshDir, keyName)
|
||||
pubPath := privPath + ".pub"
|
||||
return privPath, pubPath, nil
|
||||
}
|
||||
|
||||
func validateSSHKeyName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("key name is required")
|
||||
}
|
||||
for _, ch := range name {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
|
||||
return "", fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
|
||||
redacted := make([]*models.Profile, len(profiles))
|
||||
for i, profile := range profiles {
|
||||
cp := *profile
|
||||
if cp.SSH != nil {
|
||||
sshCfg := *cp.SSH
|
||||
sshCfg.ProxyPassword = ""
|
||||
sshCfg.TOTPSecret = ""
|
||||
cp.SSH = &sshCfg
|
||||
}
|
||||
redacted[i] = &cp
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
)
|
||||
|
||||
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
privPath, pubPath, err := sshKeyPairPaths("deploy_key-01")
|
||||
if err != nil {
|
||||
t.Fatalf("sshKeyPairPaths() error = %v", err)
|
||||
}
|
||||
|
||||
wantPriv := filepath.Join(home, ".ssh", "deploy_key-01")
|
||||
if privPath != wantPriv {
|
||||
t.Fatalf("private path = %q, want %q", privPath, wantPriv)
|
||||
}
|
||||
if pubPath != wantPriv+".pub" {
|
||||
t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
_, _, err := sshKeyPairPaths("../outside")
|
||||
if err == nil {
|
||||
t.Fatal("expected traversal key name to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid characters") {
|
||||
t.Fatalf("error = %q, want invalid characters", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
_, _, err := sshKeyPairPaths("nested/key")
|
||||
if err == nil {
|
||||
t.Fatal("expected nested key path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
|
||||
profile := models.NewProfile(models.ProtocolSSH)
|
||||
profile.SSH.ProxyPassword = "proxy-secret"
|
||||
profile.SSH.TOTPSecret = "totp-secret"
|
||||
|
||||
got := redactProfileSecrets([]*models.Profile{&profile})
|
||||
if got[0].SSH.ProxyPassword != "" {
|
||||
t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword)
|
||||
}
|
||||
if got[0].SSH.TOTPSecret != "" {
|
||||
t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret)
|
||||
}
|
||||
if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" {
|
||||
t.Fatal("redaction mutated the original profile")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/githubclient"
|
||||
@@ -653,7 +654,7 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Connect(getPassword, getPassphrase); err != nil {
|
||||
if err := client.ConnectWithHostKeyCallback(m.hostKeyCallback(sess.Info.ID, sshConf.HostKeyPolicy), getPassword, getPassphrase); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1012,6 +1013,7 @@ func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (strin
|
||||
cfg.SSHUsername = vncConf.SSHUsername
|
||||
cfg.SSHAuthMethod = vncConf.SSHAuthMethod
|
||||
cfg.SSHAgentAuth = vncConf.SSHAgentAuth
|
||||
cfg.SSHHostKeyCallback = m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk)
|
||||
|
||||
// Resolve SSH credentials
|
||||
switch vncConf.SSHAuthMethod {
|
||||
@@ -1445,6 +1447,60 @@ func (m *Manager) PingSession(sessionID string) (int64, error) {
|
||||
// Internal: helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *Manager) hostKeyCallback(sessionID string, policy models.HostKeyPolicy) ssh.HostKeyCallback {
|
||||
if policy == "" {
|
||||
policy = models.HostKeyAsk
|
||||
}
|
||||
verifier := sshclient.NewHostKeyVerifier(m.store, policy)
|
||||
verifier.SetPromptHandler(func(host string, keyType string, fingerprint string) (bool, error) {
|
||||
return m.promptHostKey(sessionID, host, keyType, fingerprint)
|
||||
})
|
||||
return verifier.HostKeyCallback()
|
||||
}
|
||||
|
||||
func (m *Manager) promptHostKey(sessionID string, host string, keyType string, fingerprint string) (bool, error) {
|
||||
if m.ctx == nil {
|
||||
return false, fmt.Errorf("host key prompt unavailable")
|
||||
}
|
||||
|
||||
requestID := uuid.New().String()
|
||||
ch := make(chan bool, 1)
|
||||
cancel := wailsRuntime.EventsOn(m.ctx, "session:hostkey:response", func(optionalData ...interface{}) {
|
||||
if len(optionalData) == 0 {
|
||||
return
|
||||
}
|
||||
data, ok := optionalData[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rid, _ := data["requestID"].(string)
|
||||
if rid != requestID {
|
||||
return
|
||||
}
|
||||
accepted, _ := data["accepted"].(bool)
|
||||
select {
|
||||
case ch <- accepted:
|
||||
default:
|
||||
}
|
||||
})
|
||||
defer cancel()
|
||||
|
||||
m.emitEvent("session:hostkey:verify", map[string]interface{}{
|
||||
"requestID": requestID,
|
||||
"sessionID": sessionID,
|
||||
"host": host,
|
||||
"keyType": keyType,
|
||||
"fingerprint": fingerprint,
|
||||
})
|
||||
|
||||
select {
|
||||
case accepted := <-ch:
|
||||
return accepted, nil
|
||||
case <-time.After(5 * time.Minute):
|
||||
return false, fmt.Errorf("host key prompt timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// buildSecretCallback returns a func() (string, error) that resolves a
|
||||
// credential according to the profile's SecretMode:
|
||||
// - "saved": check keychain/vault first, prompt if missing, then persist
|
||||
@@ -1806,11 +1862,11 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
|
||||
}
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionReconnecting),
|
||||
"attempt": attempt,
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionReconnecting),
|
||||
"attempt": attempt,
|
||||
"maxRetries": maxTries,
|
||||
"delaySec": delaySec,
|
||||
"delaySec": delaySec,
|
||||
})
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,3 +153,61 @@ func TestCloseAllEmpty(t *testing.T) {
|
||||
t.Error("expected 0 sessions after CloseAll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) {
|
||||
store := newSessionTestStore(t)
|
||||
mgr := NewManager(store, nil, nil, nil, nil)
|
||||
callback := mgr.hostKeyCallback("session-1", models.HostKeyAcceptNew)
|
||||
|
||||
key := testPublicKey(t)
|
||||
if err := callback("jump.example:2222", nil, key); err != nil {
|
||||
t.Fatalf("HostKeyCallback() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := store.GetKnownHost("jump.example", 2222)
|
||||
if err != nil {
|
||||
t.Fatalf("GetKnownHost() error = %v", err)
|
||||
}
|
||||
if saved == nil {
|
||||
t.Fatal("expected host key to be saved")
|
||||
}
|
||||
if saved.Host != "jump.example" {
|
||||
t.Fatalf("saved host = %q, want jump.example", saved.Host)
|
||||
}
|
||||
if saved.Port != 2222 {
|
||||
t.Fatalf("saved port = %d, want 2222", saved.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyCallbackStrictRejectsUnknownHost(t *testing.T) {
|
||||
mgr := NewManager(newSessionTestStore(t), nil, nil, nil, nil)
|
||||
callback := mgr.hostKeyCallback("session-1", models.HostKeyStrict)
|
||||
|
||||
err := callback("unknown.example:22", nil, testPublicKey(t))
|
||||
if err == nil {
|
||||
t.Fatal("expected strict policy to reject unknown host")
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicKey(t *testing.T) ssh.PublicKey {
|
||||
t.Helper()
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey() error = %v", err)
|
||||
}
|
||||
key, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPublicKey() error = %v", err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func newSessionTestStore(t *testing.T) *storage.Store {
|
||||
t.Helper()
|
||||
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -596,12 +596,7 @@ func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.Clien
|
||||
jumpAuth = append(jumpAuth, ssh.PublicKeys(signer))
|
||||
}
|
||||
|
||||
jumpConfig := &ssh.ClientConfig{
|
||||
User: jump.Username,
|
||||
Auth: jumpAuth,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: finalConfig.Timeout,
|
||||
}
|
||||
jumpConfig := jumpHostClientConfig(jump, jumpAuth, finalConfig)
|
||||
|
||||
if currentClient == nil {
|
||||
client, err := ssh.Dial("tcp", jumpAddr, jumpConfig)
|
||||
@@ -648,6 +643,15 @@ func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.Clien
|
||||
return ssh.NewClient(ncc, chans, reqs), nil
|
||||
}
|
||||
|
||||
func jumpHostClientConfig(jump models.JumpHost, auth []ssh.AuthMethod, finalConfig *ssh.ClientConfig) *ssh.ClientConfig {
|
||||
return &ssh.ClientConfig{
|
||||
User: jump.Username,
|
||||
Auth: auth,
|
||||
HostKeyCallback: finalConfig.HostKeyCallback,
|
||||
Timeout: finalConfig.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// parsePrivateKey loads and parses an SSH private key file. If the key is
|
||||
// encrypted and getPassphrase is provided, it will attempt decryption.
|
||||
func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package sshclient
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestJumpHostClientConfigUsesFinalHostKeyCallback(t *testing.T) {
|
||||
called := false
|
||||
hostKeyCallback := func(hostname string, _ net.Addr, _ ssh.PublicKey) error {
|
||||
called = true
|
||||
if hostname != "jump.example:2222" {
|
||||
t.Fatalf("hostname = %q, want jump.example:2222", hostname)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
finalConfig := &ssh.ClientConfig{
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: 12 * time.Second,
|
||||
}
|
||||
jump := models.JumpHost{
|
||||
Host: "jump.example",
|
||||
Port: 2222,
|
||||
Username: "alice",
|
||||
}
|
||||
|
||||
got := jumpHostClientConfig(jump, nil, finalConfig)
|
||||
if got.User != jump.Username {
|
||||
t.Fatalf("User = %q, want %q", got.User, jump.Username)
|
||||
}
|
||||
if got.Timeout != finalConfig.Timeout {
|
||||
t.Fatalf("Timeout = %s, want %s", got.Timeout, finalConfig.Timeout)
|
||||
}
|
||||
if got.HostKeyCallback == nil {
|
||||
t.Fatal("HostKeyCallback is nil")
|
||||
}
|
||||
|
||||
if err := got.HostKeyCallback("jump.example:2222", nil, nil); err != nil {
|
||||
t.Fatalf("HostKeyCallback() error = %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("final host key callback was not used")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -455,6 +456,32 @@ func TestExportProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportProfilesRedactsInlineSSHSecrets(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
profile := makeSSHProfile("Export Secret", "secret.example.com")
|
||||
profile.SSH.ProxyPassword = "proxy-secret"
|
||||
profile.SSH.TOTPSecret = "totp-secret"
|
||||
if err := store.CreateProfile(profile); err != nil {
|
||||
t.Fatalf("CreateProfile() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := store.ExportProfiles(false)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportProfiles(false) error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "proxy-secret") || strings.Contains(string(data), "totp-secret") {
|
||||
t.Fatalf("ExportProfiles(false) leaked inline SSH secrets: %s", string(data))
|
||||
}
|
||||
|
||||
data, err = store.ExportProfiles(true)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportProfiles(true) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "proxy-secret") || !strings.Contains(string(data), "totp-secret") {
|
||||
t.Fatalf("ExportProfiles(true) did not include inline SSH secrets: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportProfiles(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
p := makeSSHProfile("Import Me", "import.local")
|
||||
|
||||
+27
-10
@@ -346,13 +346,15 @@ func (s *Store) UpdateLastConnected(id string) error {
|
||||
}
|
||||
|
||||
// ExportProfiles serialises all profiles as JSON. If includeSecrets is false
|
||||
// the export omits credential-related fields (currently a placeholder — all
|
||||
// secrets live in the SecretStore, not in profiles).
|
||||
// the export omits inline credential-related fields.
|
||||
func (s *Store) ExportProfiles(includeSecrets bool) ([]byte, error) {
|
||||
profiles, err := s.ListProfiles()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: export failed: %w", err)
|
||||
}
|
||||
if !includeSecrets {
|
||||
profiles = redactProfileSecrets(profiles)
|
||||
}
|
||||
return json.MarshalIndent(profiles, "", " ")
|
||||
}
|
||||
|
||||
@@ -567,11 +569,11 @@ func (s *Store) SavePreferences(prefs *models.AppPreferences) error {
|
||||
func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
@@ -634,11 +636,11 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
for rows.Next() {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -720,6 +722,21 @@ func nullableJSON(ptr interface{}, data []byte) interface{} {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
|
||||
redacted := make([]*models.Profile, len(profiles))
|
||||
for i, profile := range profiles {
|
||||
cp := *profile
|
||||
if cp.SSH != nil {
|
||||
sshCfg := *cp.SSH
|
||||
sshCfg.ProxyPassword = ""
|
||||
sshCfg.TOTPSecret = ""
|
||||
cp.SSH = &sshCfg
|
||||
}
|
||||
redacted[i] = &cp
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snippet CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,8 @@ package vncclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,14 +19,14 @@ import (
|
||||
|
||||
// Client manages a VNC session, optionally tunnelled through SSH.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
listener net.Listener
|
||||
sshClient *ssh.Client
|
||||
wsURL string
|
||||
connected bool
|
||||
onError func(error)
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
listener net.Listener
|
||||
sshClient *ssh.Client
|
||||
wsURL string
|
||||
connected bool
|
||||
onError func(error)
|
||||
}
|
||||
|
||||
// Config holds the configuration for a VNC connection.
|
||||
@@ -34,16 +36,17 @@ type Config struct {
|
||||
VNCPort int
|
||||
|
||||
// SSH tunnel settings
|
||||
SSHTunnel bool
|
||||
SSHHost string
|
||||
SSHPort int
|
||||
SSHUsername string
|
||||
SSHAuthMethod string // "password", "publickey"
|
||||
SSHPassword string
|
||||
SSHPrivateKey []byte
|
||||
SSHPassphrase string
|
||||
SSHAgentAuth bool
|
||||
SSHAgentConn net.Conn
|
||||
SSHTunnel bool
|
||||
SSHHost string
|
||||
SSHPort int
|
||||
SSHUsername string
|
||||
SSHAuthMethod string // "password", "publickey"
|
||||
SSHPassword string
|
||||
SSHPrivateKey []byte
|
||||
SSHPassphrase string
|
||||
SSHAgentAuth bool
|
||||
SSHAgentConn net.Conn
|
||||
SSHHostKeyCallback ssh.HostKeyCallback
|
||||
|
||||
// Callbacks
|
||||
OnError func(error)
|
||||
@@ -83,9 +86,12 @@ func (c *Client) Connect(cfg Config) (string, error) {
|
||||
if cfg.SSHTunnel {
|
||||
// Establish SSH connection first
|
||||
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
|
||||
if cfg.SSHHostKeyCallback == nil {
|
||||
return "", fmt.Errorf("SSH host key callback is required")
|
||||
}
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: cfg.SSHUsername,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts
|
||||
HostKeyCallback: cfg.SSHHostKeyCallback,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
@@ -162,13 +168,19 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := newProxyToken()
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
return "", err
|
||||
}
|
||||
c.listener = listener
|
||||
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
wsURL := fmt.Sprintf("ws://127.0.0.1:%d", port)
|
||||
proxyPath := "/" + token
|
||||
wsURL := fmt.Sprintf("ws://127.0.0.1:%d%s", port, proxyPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc(proxyPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -240,6 +252,14 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
|
||||
return wsURL, nil
|
||||
}
|
||||
|
||||
func newProxyToken() (string, error) {
|
||||
token := make([]byte, 16)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", fmt.Errorf("generate proxy token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
// Close shuts down the VNC proxy and any SSH tunnel.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package vncclient
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewProxyTokenIsRandomHex(t *testing.T) {
|
||||
first, err := newProxyToken()
|
||||
if err != nil {
|
||||
t.Fatalf("newProxyToken() error = %v", err)
|
||||
}
|
||||
second, err := newProxyToken()
|
||||
if err != nil {
|
||||
t.Fatalf("newProxyToken() second error = %v", err)
|
||||
}
|
||||
|
||||
if len(first) != 32 {
|
||||
t.Fatalf("token length = %d, want 32", len(first))
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("expected separate tokens to differ")
|
||||
}
|
||||
for _, ch := range first {
|
||||
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
|
||||
t.Fatalf("token contains non-hex character %q", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user