commit ddd6ecccdad25e2520853dc49ec05ae1a093c4aa Author: kokopi Date: Fri Apr 10 01:46:57 2026 +0900 init diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..316e2da --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module tailscale-vpn + +go 1.26.1 + +require ( + charm.land/bubbles/v2 v2.1.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/lipgloss/v2 v2.0.2 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a9177c --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/pages/home.go b/internal/pages/home.go new file mode 100644 index 0000000..26726c1 --- /dev/null +++ b/internal/pages/home.go @@ -0,0 +1,36 @@ +package pages + +type HomePageMsg struct{} + +type SettingsPageMsg struct{} + +type SelectServerPageMsg struct{} + +type VPNStatusMsg struct { + Connected bool + Err error +} + +type VPNToggleMsg struct { + Connected bool + Err error +} + +type MenuItem struct { + Label string + Key string +} + +func HomeMenuItems(connected, hasServers bool) []MenuItem { + var items []MenuItem + if hasServers { + if connected { + items = append(items, MenuItem{Label: "Turn Off", Key: "off"}) + } else { + items = append(items, MenuItem{Label: "Turn On", Key: "on"}) + } + items = append(items, MenuItem{Label: "Select Server", Key: "select-server"}) + } + items = append(items, MenuItem{Label: "Settings", Key: "settings"}) + return items +} diff --git a/internal/pages/select_server.go b/internal/pages/select_server.go new file mode 100644 index 0000000..6c77d9b --- /dev/null +++ b/internal/pages/select_server.go @@ -0,0 +1,10 @@ +package pages + +import "tailscale-vpn/internal/services" + +type ServerSelectedMsg struct { + Server services.Server + Err error +} + +type ServerSelectionCanceledMsg struct{} diff --git a/internal/pages/settings.go b/internal/pages/settings.go new file mode 100644 index 0000000..6622b8e --- /dev/null +++ b/internal/pages/settings.go @@ -0,0 +1,21 @@ +package pages + +import "tailscale-vpn/internal/services" + +type ServerListLoadedMsg struct { + Servers []services.Server + Err error +} + +type ServerEditMsg struct { + Mode string + Server services.Server +} + +type ServerSaveMsg struct { + Err error +} + +type ServerDeleteMsg struct { + Err error +} diff --git a/internal/services/config.go b/internal/services/config.go new file mode 100644 index 0000000..3d66b7b --- /dev/null +++ b/internal/services/config.go @@ -0,0 +1,130 @@ +package services + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" +) + +type Server struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` +} + +type Config struct { + Servers []Server `json:"servers"` + SelectedServerID string `json:"selectedServerId"` +} + +type ConfigService struct { + path string + config Config +} + +func NewConfigService() (*ConfigService, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("get config dir: %w", err) + } + + dir := filepath.Join(configDir, "tailscale-vpn") + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create config dir: %w", err) + } + + path := filepath.Join(dir, "settings.json") + + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(path, []byte(`{"servers":[]}`), 0o600); err != nil { + return nil, fmt.Errorf("create config file: %w", err) + } + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + return &ConfigService{path: path, config: cfg}, nil +} + +func (s *ConfigService) GetServers() []Server { + return s.config.Servers +} + +func (s *ConfigService) GetServer(id string) (Server, bool) { + for _, srv := range s.config.Servers { + if srv.ID == id { + return srv, true + } + } + return Server{}, false +} + +func (s *ConfigService) AddServer(name, host string) error { + srv := Server{ + ID: host, + Name: name, + Host: host, + } + s.config.Servers = append(s.config.Servers, srv) + return s.Save() +} + +func (s *ConfigService) RemoveServer(id string) error { + s.config.Servers = slices.DeleteFunc(s.config.Servers, func(srv Server) bool { + return srv.ID == id + }) + return s.Save() +} + +func (s *ConfigService) GetSelectedServer() (Server, bool) { + for _, srv := range s.config.Servers { + if srv.ID == s.config.SelectedServerID { + return srv, true + } + } + return Server{}, false +} + +func (s *ConfigService) SetSelectedServer(id string) error { + for _, srv := range s.config.Servers { + if srv.ID == id { + s.config.SelectedServerID = id + return s.Save() + } + } + return fmt.Errorf("server not found") +} + +func (s *ConfigService) UpdateServer(id, name, host string) error { + for i, srv := range s.config.Servers { + if srv.ID == id { + s.config.Servers[i].Name = name + s.config.Servers[i].Host = host + s.config.Servers[i].ID = host + return s.Save() + } + } + return fmt.Errorf("server not found") +} + +func (s *ConfigService) Save() error { + data, err := json.MarshalIndent(s.config, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + if err := os.WriteFile(s.path, data, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} diff --git a/internal/services/services_store.go b/internal/services/services_store.go new file mode 100644 index 0000000..a0e1b65 --- /dev/null +++ b/internal/services/services_store.go @@ -0,0 +1,11 @@ +package services + +type ServicesStore struct { + VPN *VPNService + Config *ConfigService +} + +func NewServicesStore() (*ServicesStore, error) { + cfg, _ := NewConfigService() + return &ServicesStore{VPN: NewVPNService(cfg), Config: cfg}, nil +} diff --git a/internal/services/vpn_service.go b/internal/services/vpn_service.go new file mode 100644 index 0000000..cd96332 --- /dev/null +++ b/internal/services/vpn_service.go @@ -0,0 +1,66 @@ +package services + +import ( + "bytes" + "context" + "log/slog" + "os/exec" + "strings" +) + + +type VPNService struct{ + Config *ConfigService +} + +func NewVPNService(cfg *ConfigService) *VPNService { + return &VPNService{ + Config: cfg, + } +} + +func (v *VPNService) CheckStatus(ctx context.Context) (bool, error) { + cmd := exec.CommandContext(ctx, "tailscale", "exit-node", "list") + var out bytes.Buffer + cmd.Stdout = &out + + err := cmd.Run() + if err != nil { + slog.ErrorContext(ctx, "failed to check VPN status", "error", err) + return false, err + } + + output := out.String() + return strings.Contains(output, "selected"), nil +} + +func (v *VPNService) TurnOn(ctx context.Context) error { + selectedServer, _ := v.Config.GetSelectedServer() + cmd := exec.CommandContext(ctx, "sudo", "tailscale", "set", "--exit-node="+selectedServer.Host) + if err := cmd.Run(); err != nil { + slog.ErrorContext(ctx, "failed to turn on VPN", "error", err) + return err + } + return nil +} + +func (v *VPNService) TurnOff(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "sudo", "tailscale", "set", "--exit-node=") + if err := cmd.Run(); err != nil { + slog.ErrorContext(ctx, "failed to turn off VPN", "error", err) + return err + } + return nil +} + +func (v *VPNService) ExitNodeList(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "tailscale", "exit-node", "list") + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + slog.ErrorContext(ctx, "failed to get exit node list", "error", err) + return "", err + } + return out.String(), nil +} diff --git a/internal/styles/styles.go b/internal/styles/styles.go new file mode 100644 index 0000000..502ad04 --- /dev/null +++ b/internal/styles/styles.go @@ -0,0 +1,159 @@ +package styles + +import lipgloss "charm.land/lipgloss/v2" + +var ( + // Card / box + CardStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Width(52) + + CardInnerStyle = lipgloss.NewStyle(). + Padding(1, 3) + + CardTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true). + MarginBottom(1) + + CardSubtitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + MarginBottom(1) + + // VPN Status + VPNStatusConnectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true). + MarginBottom(1) + + VPNStatusDisconnectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginBottom(1) + + VPNStatusLoadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Italic(true). + MarginBottom(1) + + VPNErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("203")). + MarginBottom(1) + + // Buttons + buttonBase = lipgloss.NewStyle(). + Padding(0, 2). + MarginTop(1). + Width(44) + + ButtonActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")). + Background(lipgloss.Color("75")). + Bold(true). + Padding(0, 2). + MarginTop(1). + Width(44) + + ButtonInactiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Padding(0, 2). + MarginTop(1). + Width(44) + + ButtonLoadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("238")). + Padding(0, 2). + MarginTop(1). + Width(44) + + // Footer + FooterStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("237")). + Padding(0, 1). + Width(50) + + FooterKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) + FooterSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("237")) + FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) + + // Settings Page + ServerListStyle = lipgloss.NewStyle(). + PaddingTop(1) + + ServerItemActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true) + + ServerItemInactiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) + + ServerSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true). + MarginBottom(1) + + ServerNotSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Italic(true). + MarginBottom(1) + + EmptyListStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Italic(true). + PaddingTop(1) + + FormTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true). + MarginBottom(1) + + FormLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Width(6) + + FormInputActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")). + Background(lipgloss.Color("236")). + Width(30) + + FormInputInactiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Width(30) + + FormButtonActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("232")). + Background(lipgloss.Color("75")). + Bold(true). + Padding(0, 2). + Width(14) + + FormButtonInactiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Padding(0, 2). + Width(14) + + ConfirmTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("203")). + Bold(true). + MarginBottom(1) + + ConfirmStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + SelectListStyle = lipgloss.NewStyle(). + PaddingTop(1) + + SelectItemActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true) + + SelectItemInactiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) + + SelectedMarkerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")). + Bold(true) +) diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..f954d40 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,100 @@ +package tui + +import ( + "context" + "fmt" + "tailscale-vpn/internal/pages" + "tailscale-vpn/internal/services" + + tea "charm.land/bubbletea/v2" +) + +func checkVPNStatusCmd(store *services.ServicesStore) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + connected, err := store.VPN.CheckStatus(ctx) + return pages.VPNStatusMsg{ + Connected: connected, + Err: err, + } + } +} + +func turnOnVPNCmd(store *services.ServicesStore) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := store.VPN.TurnOn(ctx); err != nil { + return pages.VPNToggleMsg{ + Connected: false, + Err: err, + } + } + connected, _ := store.VPN.CheckStatus(ctx) + return pages.VPNToggleMsg{ + Connected: connected, + Err: nil, + } + } +} + +func turnOffVPNCmd(store *services.ServicesStore) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := store.VPN.TurnOff(ctx); err != nil { + return pages.VPNToggleMsg{ + Connected: true, + Err: err, + } + } + connected, _ := store.VPN.CheckStatus(ctx) + return pages.VPNToggleMsg{ + Connected: connected, + Err: nil, + } + } +} + +func loadServersCmd(store *services.ServicesStore) tea.Cmd { + return func() tea.Msg { + servers := store.Config.GetServers() + return pages.ServerListLoadedMsg{ + Servers: servers, + Err: nil, + } + } +} + +func addServerCmd(store *services.ServicesStore, name, host string) tea.Cmd { + return func() tea.Msg { + err := store.Config.AddServer(name, host) + return pages.ServerSaveMsg{Err: err} + } +} + +func updateServerCmd(store *services.ServicesStore, id, name, host string) tea.Cmd { + return func() tea.Msg { + err := store.Config.UpdateServer(id, name, host) + return pages.ServerSaveMsg{Err: err} + } +} + +func deleteServerCmd(store *services.ServicesStore, id string) tea.Cmd { + return func() tea.Msg { + err := store.Config.RemoveServer(id) + return pages.ServerDeleteMsg{Err: err} + } +} + +func selectServerCmd(store *services.ServicesStore, serverID string) tea.Cmd { + return func() tea.Msg { + err := store.Config.SetSelectedServer(serverID) + if err != nil { + return pages.ServerSelectedMsg{Err: err} + } + server, ok := store.Config.GetSelectedServer() + if !ok { + return pages.ServerSelectedMsg{Err: fmt.Errorf("failed to get selected server")} + } + return pages.ServerSelectedMsg{Server: server, Err: nil} + } +} diff --git a/internal/tui/init.go b/internal/tui/init.go new file mode 100644 index 0000000..155f1d4 --- /dev/null +++ b/internal/tui/init.go @@ -0,0 +1,15 @@ +package tui + +import ( + "tailscale-vpn/internal/pages" + + tea "charm.land/bubbletea/v2" +) + +func (m TUIInterface) Init() tea.Cmd { + return tea.Batch( + func() tea.Msg { return pages.HomePageMsg{} }, + checkVPNStatusCmd(m.Services), + loadServersCmd(m.Services), + ) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..7b5e84c --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,56 @@ +package tui + +import ( + "tailscale-vpn/internal/pages" + "tailscale-vpn/internal/services" +) + +type page int + +const ( + pageHome page = iota + pageSettings + pageSelectServer +) + +type TUIInterface struct { + Services *services.ServicesStore + Page page + MenuItems []pages.MenuItem + Selected int + Quitting bool + WindowWidth int + WindowHeight int + VPNConnected bool + VPNLoading bool + VPNToggleLoading bool + VPNError error + HasServers bool + SelectedServer services.Server + HasSelectedServer bool + SettingsServers []services.Server + SettingsSelected int + SettingsEditMode string + SettingsEditingServer services.Server + SettingsFormName string + SettingsFormHost string + SettingsFormField int + SettingsConfirmDelete bool + SelectServerServers []services.Server + SelectServerSelected int +} + +func NewTUIInterface(store *services.ServicesStore) TUIInterface { + servers := store.Config.GetServers() + selectedServer, hasSelected := store.Config.GetSelectedServer() + return TUIInterface{ + Services: store, + Page: pageHome, + MenuItems: pages.HomeMenuItems(false, len(servers) > 0), + VPNConnected: false, + VPNLoading: true, + HasServers: len(servers) > 0, + SelectedServer: selectedServer, + HasSelectedServer: hasSelected, + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..79d3d6c --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,344 @@ +package tui + +import ( + "errors" + "tailscale-vpn/internal/pages" + "tailscale-vpn/internal/services" + + tea "charm.land/bubbletea/v2" +) + +func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case pages.HomePageMsg: + m.Page = pageHome + m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers) + return m, nil + + case pages.SettingsPageMsg: + m.Page = pageSettings + m.SettingsEditMode = "" + m.SettingsConfirmDelete = false + m.SettingsSelected = 0 + return m, loadServersCmd(m.Services) + + case pages.SelectServerPageMsg: + m.Page = pageSelectServer + m.SelectServerServers = m.SettingsServers + m.SelectServerSelected = 0 + for i, srv := range m.SelectServerServers { + if m.HasSelectedServer && srv.ID == m.SelectedServer.ID { + m.SelectServerSelected = i + break + } + } + return m, nil + + case pages.ServerSelectedMsg: + m.SelectedServer = msg.Server + m.HasSelectedServer = msg.Server.ID != "" + if msg.Err != nil { + m.VPNError = msg.Err + } + return m, func() tea.Msg { return pages.HomePageMsg{} } + + case pages.ServerSelectionCanceledMsg: + return m, func() tea.Msg { return pages.HomePageMsg{} } + + case pages.ServerListLoadedMsg: + m.SettingsServers = msg.Servers + m.HasServers = len(msg.Servers) > 0 + if msg.Err != nil { + m.VPNError = msg.Err + } + return m, nil + + case pages.ServerEditMsg: + m.SettingsEditMode = msg.Mode + m.SettingsConfirmDelete = false + if msg.Mode == "edit" { + m.SettingsEditingServer = msg.Server + m.SettingsFormName = msg.Server.Name + m.SettingsFormHost = msg.Server.Host + } else { + m.SettingsEditingServer = services.Server{} + m.SettingsFormName = "" + m.SettingsFormHost = "" + } + m.SettingsFormField = 0 + return m, nil + + case pages.ServerSaveMsg: + m.SettingsEditMode = "" + if msg.Err != nil { + m.VPNError = msg.Err + } + return m, loadServersCmd(m.Services) + + case pages.ServerDeleteMsg: + m.SettingsConfirmDelete = false + if msg.Err != nil { + m.VPNError = msg.Err + } else { + if m.SettingsSelected >= len(m.SettingsServers) { + m.SettingsSelected = max(len(m.SettingsServers)-1, 0) + } + } + return m, loadServersCmd(m.Services) + + case tea.WindowSizeMsg: + m.WindowWidth = msg.Width + m.WindowHeight = msg.Height + return m, nil + + case tea.KeyPressMsg: + if m.Page == pageHome { + return m.updateHomePage(msg) + } + if m.Page == pageSettings { + return m.updateSettingsPage(msg) + } + if m.Page == pageSelectServer { + return m.updateSelectServerPage(msg) + } + + case tea.PasteMsg: + if m.Page == pageSettings && m.SettingsEditMode != "" { + if m.SettingsFormField == 0 { + m.SettingsFormName += msg.Content + } else if m.SettingsFormField == 1 { + m.SettingsFormHost += msg.Content + } + return m, nil + } + + case pages.VPNStatusMsg: + m.VPNConnected = msg.Connected + m.VPNLoading = false + if msg.Err != nil { + m.VPNError = msg.Err + } else { + m.VPNError = nil + } + m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers) + return m, nil + + case pages.VPNToggleMsg: + m.VPNConnected = msg.Connected + m.VPNToggleLoading = false + if msg.Err != nil { + m.VPNError = msg.Err + } else { + m.VPNError = nil + } + m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers) + return m, nil + } + + return m, nil +} + +func (m TUIInterface) updateHomePage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.VPNLoading || m.VPNToggleLoading { + if msg.String() == "ctrl+c" { + m.Quitting = true + return m, tea.Quit + } + return m, nil + } + + switch msg.String() { + case "up", "k": + if m.Selected > 0 { + m.Selected-- + } + return m, nil + + case "down", "j": + if m.Selected < len(m.MenuItems)-1 { + m.Selected++ + } + return m, nil + + case "enter": + if len(m.MenuItems) > 0 { + switch m.MenuItems[m.Selected].Key { + case "select-server": + return m, func() tea.Msg { return pages.SelectServerPageMsg{} } + case "settings": + return m, func() tea.Msg { return pages.SettingsPageMsg{} } + case "on": + m.VPNToggleLoading = true + m.VPNError = nil + return m, turnOnVPNCmd(m.Services) + case "off": + m.VPNToggleLoading = true + m.VPNError = nil + return m, turnOffVPNCmd(m.Services) + } + } + return m, nil + + case "r": + m.VPNLoading = true + m.VPNError = nil + return m, checkVPNStatusCmd(m.Services) + + case "ctrl+c", "esc": + m.Quitting = true + return m, tea.Quit + } + + return m, nil +} + +func (m TUIInterface) updateSelectServerPage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.SelectServerSelected > 0 { + m.SelectServerSelected-- + } + return m, nil + case "down", "j": + if m.SelectServerSelected < len(m.SelectServerServers)-1 { + m.SelectServerSelected++ + } + return m, nil + case "enter": + if m.SelectServerSelected >= 0 && m.SelectServerSelected < len(m.SelectServerServers) { + return m, selectServerCmd(m.Services, m.SelectServerServers[m.SelectServerSelected].ID) + } + return m, nil + case "esc", "ctrl+c": + return m, func() tea.Msg { return pages.ServerSelectionCanceledMsg{} } + } + return m, nil +} + +func (m TUIInterface) updateSettingsPage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.SettingsEditMode != "" { + switch msg.String() { + case "esc": + m.SettingsEditMode = "" + return m, nil + case "enter": + if m.SettingsFormField == 2 { + if m.SettingsFormName == "" || m.SettingsFormHost == "" { + m.VPNError = errors.New("name and host are required") + return m, nil + } + if m.SettingsEditMode == "add" { + return m, addServerCmd(m.Services, m.SettingsFormName, m.SettingsFormHost) + } + return m, updateServerCmd(m.Services, m.SettingsEditingServer.ID, m.SettingsFormName, m.SettingsFormHost) + } else if m.SettingsFormField == 3 { + m.SettingsEditMode = "" + return m, nil + } else { + m.SettingsFormField = (m.SettingsFormField + 1) % 4 + return m, nil + } + case "up", "k": + m.SettingsFormField = (m.SettingsFormField - 1 + 4) % 4 + return m, nil + case "down", "j": + m.SettingsFormField = (m.SettingsFormField + 1) % 4 + return m, nil + case "tab": + m.SettingsFormField = (m.SettingsFormField + 1) % 4 + return m, nil + case "backtab": + m.SettingsFormField = (m.SettingsFormField - 1 + 4) % 4 + return m, nil + case "backspace": + if m.SettingsFormField == 0 && len(m.SettingsFormName) > 0 { + m.SettingsFormName = m.SettingsFormName[:len(m.SettingsFormName)-1] + } else if m.SettingsFormField == 1 && len(m.SettingsFormHost) > 0 { + m.SettingsFormHost = m.SettingsFormHost[:len(m.SettingsFormHost)-1] + } + return m, nil + case "ctrl+c": + m.Quitting = true + return m, tea.Quit + default: + if m.SettingsFormField == 0 || m.SettingsFormField == 1 { + if msg.Text != "" { + if m.SettingsFormField == 0 { + m.SettingsFormName += msg.Text + } else { + m.SettingsFormHost += msg.Text + } + } + } + return m, nil + } + } + + switch msg.String() { + + case "esc": + if m.SettingsConfirmDelete { + m.SettingsConfirmDelete = false + return m, nil + } + return m, func() tea.Msg { return pages.HomePageMsg{} } + + case "up", "k": + if m.SettingsConfirmDelete { + return m, nil + } + if m.SettingsSelected > 0 { + m.SettingsSelected-- + } + return m, nil + + case "down", "j": + if m.SettingsConfirmDelete { + return m, nil + } + if m.SettingsSelected < len(m.SettingsServers)-1 { + m.SettingsSelected++ + } + return m, nil + + case "a": + if m.SettingsConfirmDelete { + return m, nil + } + return m, func() tea.Msg { return pages.ServerEditMsg{Mode: "add"} } + + case "enter": + if m.SettingsConfirmDelete { + if m.SettingsSelected >= 0 && m.SettingsSelected < len(m.SettingsServers) { + return m, deleteServerCmd(m.Services, m.SettingsServers[m.SettingsSelected].ID) + } + m.SettingsConfirmDelete = false + return m, nil + } + if m.SettingsSelected >= 0 && m.SettingsSelected < len(m.SettingsServers) { + return m, func() tea.Msg { + return pages.ServerEditMsg{ + Mode: "edit", + Server: m.SettingsServers[m.SettingsSelected], + } + } + } + return m, nil + + case "d": + if m.SettingsConfirmDelete { + return m, nil + } + if len(m.SettingsServers) > 0 { + m.SettingsConfirmDelete = true + } + return m, nil + + case "ctrl+c": + m.Quitting = true + return m, tea.Quit + } + + return m, nil +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..d518d77 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,324 @@ +package tui + +import ( + "tailscale-vpn/internal/styles" + + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" +) + +func footerHint(key, desc string) string { + return styles.FooterKeyStyle.Render(key) + + " " + + styles.FooterDescStyle.Render(desc) +} + +func footerSep() string { + return styles.FooterSepStyle.Render(" · ") +} + +func (m TUIInterface) subtitle() string { + switch m.Page { + case pageHome: + return "Secure vpn" + case pageSettings: + return "Settings" + case pageSelectServer: + return "Select Server" + default: + return "Secure vpn" + } +} + +func (m TUIInterface) View() tea.View { + if m.Quitting { + return tea.NewView("") + } + + w := m.WindowWidth + h := m.WindowHeight + if w == 0 { + w = 80 + } + if h == 0 { + h = 24 + } + + var body string + switch m.Page { + case pageHome: + body = m.viewHomePage() + case pageSettings: + body = m.viewSettingsPage() + case pageSelectServer: + body = m.viewSelectServerPage() + default: + body = m.viewHomePage() + } + + header := lipgloss.JoinVertical(lipgloss.Left, + styles.CardTitleStyle.Render("✦ tailscale vpn"), + styles.CardSubtitleStyle.Render(m.subtitle()), + ) + + topContent := styles.CardInnerStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, header, body), + ) + + var footerStr string + switch m.Page { + case pageHome: + footerStr = footerHint("↑↓", "navigate") + + footerSep() + + footerHint("enter", "select") + + footerSep() + + footerHint("r", "refresh") + + footerSep() + + footerHint("esc", "quit") + case pageSettings: + if m.SettingsEditMode == "" { + if m.SettingsConfirmDelete { + footerStr = footerHint("enter", "confirm") + + footerSep() + + footerHint("esc", "cancel") + } else { + footerStr = footerHint("↑↓", "navigate") + + footerSep() + + footerHint("enter", "select") + + footerSep() + + footerHint("a", "add") + + footerSep() + + footerHint("d", "delete") + + footerSep() + + footerHint("esc", "back") + } + } else { + footerStr = footerHint("↑↓", "navigate") + + footerSep() + + footerHint("enter", "select") + + footerSep() + + footerHint("esc", "cancel") + } + case pageSelectServer: + footerStr = footerHint("↑↓", "navigate") + + footerSep() + + footerHint("enter", "select") + + footerSep() + + footerHint("esc", "cancel") + default: + footerStr = footerHint("↑↓", "navigate") + + footerSep() + + footerHint("enter", "select") + + footerSep() + + footerHint("esc", "quit") + } + footer := styles.FooterStyle.Render(footerStr) + + card := styles.CardStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, topContent, footer), + ) + + cardHeight := lipgloss.Height(card) + topPad := max((h-cardHeight)/2, 0) + + centeredCard := lipgloss.NewStyle(). + Width(w). + Align(lipgloss.Center). + PaddingTop(topPad). + Render(card) + + v := tea.NewView(centeredCard) + v.AltScreen = true + return v +} + +func (m TUIInterface) viewHomePage() string { + var status string + if m.VPNLoading { + status = styles.VPNStatusLoadingStyle.Render("Checking...") + } else if m.VPNConnected { + status = styles.VPNStatusConnectedStyle.Render("● VPN Connected") + } else { + status = styles.VPNStatusDisconnectedStyle.Render("○ VPN Disconnected") + } + + var serverStatus string + if m.HasSelectedServer { + serverStatus = styles.ServerSelectedStyle.Render("Server: " + m.SelectedServer.Name) + } else if m.HasServers { + serverStatus = styles.ServerNotSelectedStyle.Render("No server selected") + } + + var menu string + if !m.VPNLoading && !m.VPNToggleLoading { + menu = m.renderMenu() + } else { + menu = styles.ButtonLoadingStyle.Render("...") + } + + var errorStr string + if m.VPNError != nil { + errorStr = styles.VPNErrorStyle.Render("✗ " + m.VPNError.Error()) + } + + content := lipgloss.JoinVertical(lipgloss.Left, status, serverStatus, menu, errorStr) + return content +} + +func (m TUIInterface) renderMenu() string { + if len(m.MenuItems) == 0 { + return "" + } + + var rows []string + for i, item := range m.MenuItems { + if i == m.Selected { + prefix := "▸ " + rows = append(rows, styles.ButtonActiveStyle.Render(prefix+item.Label)) + } else { + prefix := " " + rows = append(rows, styles.ButtonInactiveStyle.Render(prefix+item.Label)) + } + } + + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func (m TUIInterface) viewSettingsPage() string { + if m.SettingsEditMode != "" { + return m.renderSettingsForm() + } + + if m.SettingsConfirmDelete { + return m.renderSettingsDeleteConfirm() + } + + return m.renderSettingsList() +} + +func (m TUIInterface) renderSettingsList() string { + if len(m.SettingsServers) == 0 { + return styles.EmptyListStyle.Render("No servers configured") + } + + var rows []string + for i, srv := range m.SettingsServers { + if i == m.SettingsSelected { + name := styles.ServerItemActiveStyle.Render(srv.Name) + host := styles.ServerItemActiveStyle.Render(srv.Host) + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, "▸ ", name, styles.ServerItemInactiveStyle.Render(" · "), host)) + } else { + name := styles.ServerItemInactiveStyle.Render(srv.Name) + host := styles.ServerItemInactiveStyle.Render(srv.Host) + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, " ", name, styles.ServerItemInactiveStyle.Render(" · "), host)) + } + } + + return styles.ServerListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +} + +func (m TUIInterface) renderSettingsForm() string { + title := styles.FormTitleStyle.Render( + func() string { + if m.SettingsEditMode == "add" { + return "Add Server" + } + return "Edit Server" + }(), + ) + + nameLabel := styles.FormLabelStyle.Render("Name:") + nameInput := func() string { + if m.SettingsFormField == 0 { + return styles.FormInputActiveStyle.Render(m.SettingsFormName + "_") + } + return styles.FormInputInactiveStyle.Render(m.SettingsFormName) + }() + + hostLabel := styles.FormLabelStyle.Render("Host:") + hostInput := func() string { + if m.SettingsFormField == 1 { + return styles.FormInputActiveStyle.Render(m.SettingsFormHost + "_") + } + return styles.FormInputInactiveStyle.Render(m.SettingsFormHost) + }() + + saveBtn := func() string { + if m.SettingsFormField == 2 { + return styles.FormButtonActiveStyle.Render("Save") + } + return styles.FormButtonInactiveStyle.Render("Save") + }() + + cancelBtn := func() string { + if m.SettingsFormField == 3 { + return styles.FormButtonActiveStyle.Render("Cancel") + } + return styles.FormButtonInactiveStyle.Render("Cancel") + }() + + buttons := lipgloss.JoinHorizontal(lipgloss.Left, saveBtn, " ", cancelBtn) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + lipgloss.JoinHorizontal(lipgloss.Top, nameLabel, " ", nameInput), + lipgloss.JoinHorizontal(lipgloss.Top, hostLabel, " ", hostInput), + "", + buttons, + ) + + return styles.CardInnerStyle.Render(content) +} + +func (m TUIInterface) renderSettingsDeleteConfirm() string { + if m.SettingsSelected < 0 || m.SettingsSelected >= len(m.SettingsServers) { + return "" + } + + srv := m.SettingsServers[m.SettingsSelected] + title := styles.ConfirmTitleStyle.Render("Delete Server?") + message := styles.ConfirmStyle.Render("Delete \"" + srv.Name + "\"?") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + message, + ) + + return styles.CardInnerStyle.Render(content) +} + +func (m TUIInterface) viewSelectServerPage() string { + return m.renderSelectServerList() +} + +func (m TUIInterface) renderSelectServerList() string { + if len(m.SelectServerServers) == 0 { + return styles.EmptyListStyle.Render("No servers available") + } + + var rows []string + for i, srv := range m.SelectServerServers { + isSelected := m.HasSelectedServer && srv.ID == m.SelectedServer.ID + if i == m.SelectServerSelected { + prefix := "▸ " + row := prefix + srv.Name + if isSelected { + row += " " + styles.SelectedMarkerStyle.Render("✓") + } + rows = append(rows, styles.SelectItemActiveStyle.Render(row)) + } else { + prefix := " " + row := prefix + srv.Name + if isSelected { + row += " " + styles.SelectedMarkerStyle.Render("✓") + } + rows = append(rows, styles.SelectItemInactiveStyle.Render(row)) + } + } + + return styles.SelectListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..86cdae6 --- /dev/null +++ b/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + + "tailscale-vpn/internal/services" + "tailscale-vpn/internal/tui" + + tea "charm.land/bubbletea/v2" +) + +func authenticateSudo() error { + cmd := exec.Command("sudo", "-v") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func main() { + // Authenticate sudo before starting TUI + fmt.Println("Authenticating sudo...") + if err := authenticateSudo(); err != nil { + fmt.Fprintln(os.Stderr, "Failed to authenticate sudo:", err) + fmt.Fprintln(os.Stderr, "Sudo is required to manage the Tailscale exit node.") + os.Exit(1) + } + + srvStore, _ := services.NewServicesStore() + m := tui.NewTUIInterface(srvStore) + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +}