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:
parent
58faccbb27
commit
c169887fed
3 changed files with 165 additions and 28 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ wallarm
|
||||||
bin/
|
bin/
|
||||||
wallarm-upx
|
wallarm-upx
|
||||||
bin/
|
bin/
|
||||||
|
wallarm-linux-amd64
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type Config struct {
|
||||||
RemotePort int // Port on the jumphost that forwards to target's SSH
|
RemotePort int // Port on the jumphost that forwards to target's SSH
|
||||||
LocalSSHPort int // SSH port on the target VM (usually 22)
|
LocalSSHPort int // SSH port on the target VM (usually 22)
|
||||||
User string // SSH user on the jumphost
|
User string // SSH user on the jumphost
|
||||||
|
Password string // Password auth (takes lowest priority)
|
||||||
KeyPath string // Path to private key for authentication
|
KeyPath string // Path to private key for authentication
|
||||||
KeyBytes []byte // Raw private key bytes (takes precedence over KeyPath)
|
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.
|
// Start opens a reverse SSH tunnel over TLS and keeps it alive.
|
||||||
// It blocks until SIGINT or connection failure.
|
// It blocks until SIGINT or connection failure.
|
||||||
func Start(cfg Config) error {
|
func Start(cfg Config) error {
|
||||||
// Load the private key
|
// Build auth methods
|
||||||
var signer ssh.Signer
|
var authMethods []ssh.AuthMethod
|
||||||
|
|
||||||
if len(cfg.KeyBytes) > 0 {
|
if len(cfg.KeyBytes) > 0 {
|
||||||
var err error
|
signer, err := ssh.ParsePrivateKey(cfg.KeyBytes)
|
||||||
signer, err = ssh.ParsePrivateKey(cfg.KeyBytes)
|
if err == nil {
|
||||||
if err != nil {
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||||
return fmt.Errorf("parse embedded key: %w", err)
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if cfg.KeyPath != "" {
|
||||||
keyBytes, err := os.ReadFile(cfg.KeyPath)
|
keyBytes, err := os.ReadFile(cfg.KeyPath)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return fmt.Errorf("read key %s: %w", cfg.KeyPath, err)
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||||
}
|
if err == nil {
|
||||||
signer, err = ssh.ParsePrivateKey(keyBytes)
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("parse key: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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{
|
sshConfig := &ssh.ClientConfig{
|
||||||
User: cfg.User,
|
User: cfg.User,
|
||||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
Auth: authMethods,
|
||||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // trusted infrastructure
|
||||||
return nil // Accept all host keys (trusted infrastructure)
|
Timeout: 10 * time.Second,
|
||||||
},
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLS dial to the Zoraxy edge (port 443)
|
// TLS dial to the Zoraxy edge (port 443)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"git.sechpoint.app/customer-engineering/wallarm/internal/native"
|
"git.sechpoint.app/customer-engineering/wallarm/internal/native"
|
||||||
"git.sechpoint.app/customer-engineering/wallarm/internal/preflight"
|
"git.sechpoint.app/customer-engineering/wallarm/internal/preflight"
|
||||||
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
|
"git.sechpoint.app/customer-engineering/wallarm/internal/state"
|
||||||
|
"git.sechpoint.app/customer-engineering/wallarm/internal/tunnel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
|
|
@ -29,11 +30,14 @@ var (
|
||||||
|
|
||||||
// Model is the top-level bubbletea model.
|
// Model is the top-level bubbletea model.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
state stateView
|
state stateView
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
status string // feedback message during deployment
|
status string // feedback message during deployment
|
||||||
deploying bool // true while deployment is in progress
|
deploying bool // true while deployment is in progress
|
||||||
|
tunnelUser string // tunnel credentials (collected in remote config)
|
||||||
|
tunnelPass string
|
||||||
|
tunnelHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
type stateView int
|
type stateView int
|
||||||
|
|
@ -41,6 +45,9 @@ type stateView int
|
||||||
const (
|
const (
|
||||||
viewPreflight stateView = iota
|
viewPreflight stateView = iota
|
||||||
viewRoute
|
viewRoute
|
||||||
|
viewDeployChoice // new: local deploy or remote assist?
|
||||||
|
viewRemoteConfig // new: prompt for tunnel credentials
|
||||||
|
viewTunnelActive // new: tunnel is running, waiting for remote admin
|
||||||
viewWizard
|
viewWizard
|
||||||
viewDashboard
|
viewDashboard
|
||||||
viewDone
|
viewDone
|
||||||
|
|
@ -73,10 +80,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return m, tea.Quit
|
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 {
|
if m.state == viewWizard && !m.deploying {
|
||||||
return m, m.startDeploy()
|
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":
|
case "enter":
|
||||||
if m.state == viewDone || m.state == viewError {
|
if m.state == viewDone || m.state == viewError {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
@ -92,6 +115,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.state = viewDone
|
m.state = viewDone
|
||||||
}
|
}
|
||||||
return m, nil
|
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:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
|
|
@ -102,7 +133,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if state.HasDeployment() {
|
if state.HasDeployment() {
|
||||||
m.state = viewDashboard
|
m.state = viewDashboard
|
||||||
} else {
|
} else {
|
||||||
m.state = viewWizard
|
m.state = viewDeployChoice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
@ -112,6 +143,12 @@ func (m Model) View() string {
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case viewRoute:
|
case viewRoute:
|
||||||
return ""
|
return ""
|
||||||
|
case viewDeployChoice:
|
||||||
|
return deployChoiceView(m)
|
||||||
|
case viewRemoteConfig:
|
||||||
|
return remoteConfigView(m)
|
||||||
|
case viewTunnelActive:
|
||||||
|
return tunnelActiveView(m)
|
||||||
case viewWizard:
|
case viewWizard:
|
||||||
return wizardView(m)
|
return wizardView(m)
|
||||||
case viewDashboard:
|
case viewDashboard:
|
||||||
|
|
@ -124,7 +161,57 @@ func (m Model) View() string {
|
||||||
return ""
|
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 {
|
func (m Model) startDeploy() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
m.deploying = true
|
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 {
|
func wizardView(m Model) string {
|
||||||
s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n"
|
s := titleStyle.Render("🛡️ Wallarm Setup Wizard") + "\n"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue