From c169887fede3a1b59873f13e26f7abd7ead5f877 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 1 Aug 2026 14:47:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20remote=20assistance=20=E2=80=94=20deplo?= =?UTF-8?q?y=20locally=20or=20open=20tunnel=20for=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup flow now offers: - [1] Deploy locally — config wizard, deploys on this server - [2] Remote assistance — prompts for jumphost URL, user, password (or SSH key path), opens reverse TLS:443 tunnel so a remote admin can connect and run deployment through their terminal. Tunnel now supports password auth in addition to key auth. --- .gitignore | 1 + internal/tunnel/tunnel.go | 44 +++++++----- internal/ui/ui.go | 148 +++++++++++++++++++++++++++++++++++--- 3 files changed, 165 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index bdc1425..e2f374a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ wallarm bin/ wallarm-upx bin/ +wallarm-linux-amd64 diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 83467cb..f23e4af 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -22,6 +22,7 @@ type Config struct { RemotePort int // Port on the jumphost that forwards to target's SSH LocalSSHPort int // SSH port on the target VM (usually 22) User string // SSH user on the jumphost + Password string // Password auth (takes lowest priority) KeyPath string // Path to private key for authentication KeyBytes []byte // Raw private key bytes (takes precedence over KeyPath) } @@ -39,32 +40,37 @@ func DefaultConfig() Config { // Start opens a reverse SSH tunnel over TLS and keeps it alive. // It blocks until SIGINT or connection failure. func Start(cfg Config) error { - // Load the private key - var signer ssh.Signer + // Build auth methods + var authMethods []ssh.AuthMethod + if len(cfg.KeyBytes) > 0 { - var err error - signer, err = ssh.ParsePrivateKey(cfg.KeyBytes) - if err != nil { - return fmt.Errorf("parse embedded key: %w", err) + signer, err := ssh.ParsePrivateKey(cfg.KeyBytes) + if err == nil { + authMethods = append(authMethods, ssh.PublicKeys(signer)) } - } else { + } + if cfg.KeyPath != "" { keyBytes, err := os.ReadFile(cfg.KeyPath) - if err != nil { - return fmt.Errorf("read key %s: %w", cfg.KeyPath, err) - } - signer, err = ssh.ParsePrivateKey(keyBytes) - if err != nil { - return fmt.Errorf("parse key: %w", err) + if err == nil { + signer, err := ssh.ParsePrivateKey(keyBytes) + if err == nil { + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } } } + if cfg.Password != "" { + authMethods = append(authMethods, ssh.Password(cfg.Password)) + } + + if len(authMethods) == 0 { + return fmt.Errorf("no authentication method configured (provide key or password)") + } sshConfig := &ssh.ClientConfig{ - User: cfg.User, - Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, - HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { - return nil // Accept all host keys (trusted infrastructure) - }, - Timeout: 10 * time.Second, + User: cfg.User, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // trusted infrastructure + Timeout: 10 * time.Second, } // TLS dial to the Zoraxy edge (port 443) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 4752b27..ed9be2d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -15,6 +15,7 @@ import ( "git.sechpoint.app/customer-engineering/wallarm/internal/native" "git.sechpoint.app/customer-engineering/wallarm/internal/preflight" "git.sechpoint.app/customer-engineering/wallarm/internal/state" + "git.sechpoint.app/customer-engineering/wallarm/internal/tunnel" ) // Styles @@ -29,11 +30,14 @@ var ( // Model is the top-level bubbletea model. type Model struct { - state stateView - width int - height int - status string // feedback message during deployment - deploying bool // true while deployment is in progress + state stateView + width int + height int + status string // feedback message during deployment + deploying bool // true while deployment is in progress + tunnelUser string // tunnel credentials (collected in remote config) + tunnelPass string + tunnelHost string } type stateView int @@ -41,6 +45,9 @@ type stateView int const ( viewPreflight stateView = iota viewRoute + viewDeployChoice // new: local deploy or remote assist? + viewRemoteConfig // new: prompt for tunnel credentials + viewTunnelActive // new: tunnel is running, waiting for remote admin viewWizard viewDashboard viewDone @@ -73,10 +80,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit - case "1", "2": + case "1": + if m.state == viewDeployChoice { + m.state = viewWizard + return m, nil + } if m.state == viewWizard && !m.deploying { return m, m.startDeploy() } + case "2": + if m.state == viewDeployChoice { + m.state = viewRemoteConfig + return m, nil + } + case "s": + if m.state == viewRemoteConfig && m.tunnelHost == "" { + return m, m.collectTunnelCreds() + } + if m.state == viewRemoteConfig && m.tunnelHost != "" { + return m, m.startTunnel() + } case "enter": if m.state == viewDone || m.state == viewError { return m, tea.Quit @@ -92,6 +115,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = viewDone } return m, nil + case tunnelCredsMsg: + if msg.err != nil { + m.status = fmt.Sprintf("Could not collect credentials: %v", msg.err) + m.state = viewError + } else { + m.state = viewRemoteConfig // refresh to show collected creds + } + return m, nil case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -102,7 +133,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if state.HasDeployment() { m.state = viewDashboard } else { - m.state = viewWizard + m.state = viewDeployChoice } } return m, nil @@ -112,6 +143,12 @@ func (m Model) View() string { switch m.state { case viewRoute: return "" + case viewDeployChoice: + return deployChoiceView(m) + case viewRemoteConfig: + return remoteConfigView(m) + case viewTunnelActive: + return tunnelActiveView(m) case viewWizard: return wizardView(m) case viewDashboard: @@ -124,7 +161,57 @@ func (m Model) View() string { return "" } -// startDeploy runs the Huh form in a goroutine, then deploys. +// collectTunnelCreds runs a huh form to collect tunnel endpoint details. +func (m Model) collectTunnelCreds() tea.Cmd { + return func() tea.Msg { + var host, user, pass string + err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Jumphost URL"). + Placeholder("ssh.sechpoint.app:443"). + Value(&host), + huh.NewInput(). + Title("Username"). + Placeholder("wallarm-tunnel"). + Value(&user), + huh.NewInput(). + Title("Password (or SSH key path)"). + Placeholder("password or /path/to/key"). + EchoMode(huh.EchoModePassword). + Value(&pass), + ), + ).WithTheme(huh.ThemeCharm()).Run() + + m.tunnelHost = host + m.tunnelUser = user + m.tunnelPass = pass + return tunnelCredsMsg{err: err} + } +} + +type tunnelCredsMsg struct{ err error } + +// startTunnel opens the reverse SSH tunnel with collected credentials. +func (m Model) startTunnel() tea.Cmd { + return func() tea.Msg { + cfg := tunnel.Config{ + Jumphost: m.tunnelHost, + User: m.tunnelUser, + Password: m.tunnelPass, + RemotePort: 9042, + LocalSSHPort: 22, + } + // If password looks like a file path, use key auth instead + if strings.HasPrefix(m.tunnelPass, "/") { + cfg.Password = "" + cfg.KeyPath = m.tunnelPass + } + + err := tunnel.Start(cfg) + return deployCompleteMsg{err: err} + } +} func (m Model) startDeploy() tea.Cmd { return func() tea.Msg { m.deploying = true @@ -145,7 +232,50 @@ func (m Model) startDeploy() tea.Cmd { } } -// ─── Wizard ────────────────────────────────────────────────────────── +// ─── Deploy Choice ─────────────────────────────────────────────────── + +func deployChoiceView(m Model) string { + s := titleStyle.Render("🛡️ Wallarm Setup") + "\n\n" + s += "How would you like to proceed?\n\n" + s += activeStyle.Render(" [1] Deploy locally") + "\n" + s += " Configure and deploy the Wallarm node on this server now.\n\n" + s += activeStyle.Render(" [2] Remote assistance") + "\n" + s += " Open a secure tunnel so a remote admin can deploy for you.\n" + s += " No credentials are exposed — you provide the tunnel endpoint.\n\n" + s += dimStyle.Render("Press 1 or 2, q to quit") + return s +} + +// ─── Remote Config ─────────────────────────────────────────────────── + +func remoteConfigView(m Model) string { + s := titleStyle.Render("🔗 Remote Assistance Setup") + "\n\n" + s += "Enter the tunnel endpoint where the remote admin will connect:\n\n" + + if m.tunnelHost == "" { + s += dimStyle.Render(" (Form will appear below — enter values and press Enter to continue)") + "\n" + s += dimStyle.Render(" Press 's' to start the tunnel after filling in credentials") + "\n" + } else { + s += fmt.Sprintf(" Jumphost: %s\n", goodStyle.Render(m.tunnelHost)) + s += fmt.Sprintf(" User: %s\n", m.tunnelUser) + s += dimStyle.Render("\n Tunnel credentials collected. Press 's' to start.") + "\n" + } + return s +} + +// ─── Tunnel Active ─────────────────────────────────────────────────── + +func tunnelActiveView(m Model) string { + s := titleStyle.Render("🔗 Tunnel Active") + "\n\n" + s += goodStyle.Render("Secure tunnel is running.") + "\n\n" + s += "Share this with your admin:\n" + s += dimStyle.Render(" ┌─────────────────────────────────────────┐") + "\n" + s += fmt.Sprintf(" │ %s", activeStyle.Render("ssh -p 9042 root@"+m.tunnelHost)) + dimStyle.Render(" │") + "\n" + s += dimStyle.Render(" └─────────────────────────────────────────┘") + "\n\n" + s += dimStyle.Render("The admin will see the deployment wizard on their terminal.") + "\n" + s += dimStyle.Render("Tunnel stays open until you press q.") + "\n" + return s +} func wizardView(m Model) string { s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n"