340 lines
7.3 KiB
Go
340 lines
7.3 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"tsrun/internal/embedded"
|
|
base "tsrun/internal/runtime"
|
|
"tsrun/internal/runtime/full"
|
|
"tsrun/internal/runtime/proxy"
|
|
"tsrun/internal/vault"
|
|
)
|
|
|
|
type screen int
|
|
|
|
const (
|
|
screenLogin screen = iota
|
|
screenConnecting
|
|
screenDashboard
|
|
screenError
|
|
)
|
|
|
|
type runtimeEventMsg struct{ event base.Event }
|
|
type runtimeDoneMsg struct{ err error }
|
|
|
|
type App struct {
|
|
useProxy bool
|
|
prefill string
|
|
|
|
screen screen
|
|
spinner spinner.Model
|
|
username textinput.Model
|
|
password textinput.Model
|
|
focus int
|
|
|
|
status string
|
|
errText string
|
|
event base.Event
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
eventCh chan base.Event
|
|
doneCh chan error
|
|
started bool
|
|
connected bool
|
|
quitting bool
|
|
}
|
|
|
|
func Run(useProxy bool, prefill string) error {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
sp := spinner.New()
|
|
sp.Spinner = spinner.Spinner{
|
|
Frames: []string{"◇", "◈", "◆", "◈"},
|
|
FPS: spinner.Line.FPS,
|
|
}
|
|
|
|
username := textinput.New()
|
|
username.Placeholder = "username"
|
|
username.Prompt = "Username: "
|
|
username.CharLimit = 64
|
|
username.SetValue(prefill)
|
|
|
|
password := textinput.New()
|
|
password.Placeholder = "password"
|
|
password.Prompt = "Password: "
|
|
password.CharLimit = 128
|
|
password.EchoMode = textinput.EchoPassword
|
|
password.EchoCharacter = '•'
|
|
|
|
if prefill == "" {
|
|
username.Focus()
|
|
} else {
|
|
password.Focus()
|
|
}
|
|
|
|
app := &App{
|
|
useProxy: useProxy,
|
|
prefill: prefill,
|
|
screen: screenLogin,
|
|
spinner: sp,
|
|
username: username,
|
|
password: password,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
_, err := tea.NewProgram(app, tea.WithAltScreen()).Run()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if app.errText != "" {
|
|
return errors.New(app.errText)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Init() tea.Cmd { return nil }
|
|
|
|
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch a.screen {
|
|
case screenLogin:
|
|
return a.updateLogin(msg)
|
|
case screenConnecting, screenDashboard:
|
|
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
|
if !a.quitting {
|
|
a.quitting = true
|
|
a.status = "disconnecting"
|
|
a.screen = screenConnecting
|
|
a.cancel()
|
|
}
|
|
return a, nil
|
|
}
|
|
case screenError:
|
|
if msg.String() == "enter" || msg.String() == "q" || msg.String() == "esc" || msg.String() == "ctrl+c" {
|
|
return a, tea.Quit
|
|
}
|
|
}
|
|
case spinner.TickMsg:
|
|
if a.screen == screenConnecting {
|
|
var cmd tea.Cmd
|
|
a.spinner, cmd = a.spinner.Update(msg)
|
|
return a, cmd
|
|
}
|
|
case runtimeEventMsg:
|
|
a.status = msg.event.Message
|
|
a.event = msg.event
|
|
if msg.event.Kind == base.EventRunning {
|
|
a.connected = true
|
|
a.screen = screenDashboard
|
|
}
|
|
return a, a.waitForEventCmd()
|
|
case runtimeDoneMsg:
|
|
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
|
|
a.errText = msg.err.Error()
|
|
a.screen = screenError
|
|
return a, nil
|
|
}
|
|
if a.connected || a.quitting {
|
|
return a, tea.Quit
|
|
}
|
|
if msg.err != nil {
|
|
a.errText = msg.err.Error()
|
|
a.screen = screenError
|
|
return a, nil
|
|
}
|
|
return a, tea.Quit
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (a *App) updateLogin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
return a, tea.Quit
|
|
case "tab", "shift+tab", "up", "down":
|
|
if a.focus == 0 {
|
|
a.focus = 1
|
|
a.username.Blur()
|
|
a.password.Focus()
|
|
} else {
|
|
a.focus = 0
|
|
a.password.Blur()
|
|
a.username.Focus()
|
|
}
|
|
return a, nil
|
|
case "enter":
|
|
if a.focus == 0 {
|
|
a.focus = 1
|
|
a.username.Blur()
|
|
a.password.Focus()
|
|
return a, nil
|
|
}
|
|
return a, a.startRuntime()
|
|
}
|
|
|
|
if a.focus == 0 {
|
|
var cmd tea.Cmd
|
|
a.username, cmd = a.username.Update(msg)
|
|
return a, cmd
|
|
}
|
|
var cmd tea.Cmd
|
|
a.password, cmd = a.password.Update(msg)
|
|
return a, cmd
|
|
}
|
|
|
|
func (a *App) startRuntime() tea.Cmd {
|
|
username := strings.TrimSpace(a.username.Value())
|
|
passwordBytes := []byte(a.password.Value())
|
|
a.screen = screenConnecting
|
|
a.status = "unlocking credentials"
|
|
|
|
payload, err := vault.Unlock(embedded.Blob, username, passwordBytes)
|
|
vault.Zero(passwordBytes)
|
|
if err != nil {
|
|
if errors.Is(err, vault.ErrAuthFailed) {
|
|
a.errText = "authentication failed"
|
|
} else {
|
|
a.errText = err.Error()
|
|
}
|
|
a.screen = screenError
|
|
return nil
|
|
}
|
|
|
|
a.eventCh = make(chan base.Event, 16)
|
|
a.doneCh = make(chan error, 1)
|
|
a.started = true
|
|
|
|
go func() {
|
|
report := base.Reporter(func(event base.Event) {
|
|
a.eventCh <- event
|
|
})
|
|
var runErr error
|
|
if a.useProxy {
|
|
runErr = proxy.RunWithReporter(a.ctx, payload, report)
|
|
} else {
|
|
runErr = full.RunWithReporter(a.ctx, payload, report)
|
|
}
|
|
a.doneCh <- runErr
|
|
close(a.eventCh)
|
|
close(a.doneCh)
|
|
}()
|
|
|
|
return tea.Batch(a.spinner.Tick, a.waitForEventCmd(), a.waitForDoneCmd())
|
|
}
|
|
|
|
func (a *App) waitForEventCmd() tea.Cmd {
|
|
if !a.started || a.eventCh == nil {
|
|
return nil
|
|
}
|
|
return func() tea.Msg {
|
|
event, ok := <-a.eventCh
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return runtimeEventMsg{event: event}
|
|
}
|
|
}
|
|
|
|
func (a *App) waitForDoneCmd() tea.Cmd {
|
|
if !a.started || a.doneCh == nil {
|
|
return nil
|
|
}
|
|
return func() tea.Msg {
|
|
err, ok := <-a.doneCh
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return runtimeDoneMsg{err: err}
|
|
}
|
|
}
|
|
|
|
func (a *App) View() string {
|
|
switch a.screen {
|
|
case screenLogin:
|
|
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
|
|
titleStyle.Render("tsrun"),
|
|
subStyle.Render("Temporary Tailscale access"),
|
|
"",
|
|
a.username.View(),
|
|
a.password.View(),
|
|
"",
|
|
helpStyle.Render("Tab to switch, Enter to continue, Esc to quit"),
|
|
))
|
|
case screenConnecting:
|
|
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
|
|
titleStyle.Render("tsrun"),
|
|
"",
|
|
fmt.Sprintf("%s %s", a.spinner.View(), a.status),
|
|
"",
|
|
helpStyle.Render("q to cancel"),
|
|
))
|
|
case screenDashboard:
|
|
lines := []string{
|
|
fmt.Sprintf("Mode: %s", a.event.Mode),
|
|
fmt.Sprintf("Status: %s", a.event.Message),
|
|
}
|
|
if a.event.Hostname != "" {
|
|
lines = append(lines, fmt.Sprintf("Hostname: %s", a.event.Hostname))
|
|
}
|
|
if validAddr(a.event.IPv4) {
|
|
lines = append(lines, fmt.Sprintf("IPv4: %s", a.event.IPv4))
|
|
}
|
|
if validAddr(a.event.IPv6) {
|
|
lines = append(lines, fmt.Sprintf("IPv6: %s", a.event.IPv6))
|
|
}
|
|
if a.event.ProxyAddr != "" {
|
|
lines = append(lines,
|
|
fmt.Sprintf("Proxy: %s", a.event.ProxyAddr),
|
|
fmt.Sprintf("Proxy user: %s", a.event.ProxyUser),
|
|
fmt.Sprintf("Proxy password: %s", a.event.ProxyPass),
|
|
)
|
|
}
|
|
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
|
|
titleStyle.Render("tsrun"),
|
|
"",
|
|
strings.Join(lines, "\n"),
|
|
"",
|
|
helpStyle.Render("q to disconnect"),
|
|
))
|
|
case screenError:
|
|
return pageStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
|
|
titleStyle.Render("tsrun"),
|
|
"",
|
|
errorStyle.Render(a.errText),
|
|
"",
|
|
helpStyle.Render("Enter or q to exit"),
|
|
))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func validAddr(v string) bool {
|
|
if v == "" || v == "<nil>" {
|
|
return false
|
|
}
|
|
_, err := netip.ParseAddr(v)
|
|
return err == nil
|
|
}
|
|
|
|
var (
|
|
pageStyle = lipgloss.NewStyle().Padding(2, 4)
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
|
subStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
|
)
|