feat: remote assistance — deploy locally or open tunnel for admin

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.
This commit is contained in:
admin 2026-08-01 14:47:44 +00:00
parent 58faccbb27
commit c169887fed
3 changed files with 165 additions and 28 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ wallarm
bin/
wallarm-upx
bin/
wallarm-linux-amd64

View file

@ -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)

View file

@ -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"