package main import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" ) type terminal struct{ r *bufio.Reader } func (t terminal) ask(label, current string) (string, error) { fmt.Printf("%s [%s]: ", label, current) s, e := t.r.ReadString('\n') if e != nil { return "", e } s = strings.TrimSpace(s) if s == "" { return current, nil } return s, nil } func (a *App) tui() error { t := terminal{bufio.NewReader(os.Stdin)} for { a.mu.Lock() s := a.settings a.mu.Unlock() fmt.Println("\nwswg |", a.engine.status().Line()) for i, p := range s.Profiles { mark := "" if p.ID == a.engine.activeID() { mark = " [connected]" } fmt.Printf("%d. %s%s\n", i+1, p.Name, mark) } fmt.Println("n. New config\ns. Settings\nx. Disconnect\nq. Quit") v, err := t.ask(">", "") if err != nil { return nil } switch v { case "q": return nil case "x": a.engine.stop() case "s": fmt.Printf("On boot: %t\n", s.OnBoot) v, err = t.ask("Toggle on boot? y/n", "n") if err != nil { return nil } if v == "y" { if err = a.setOptions(s.Autorun, !s.OnBoot); err != nil { fmt.Println(err) } } case "n": name, err := t.ask("Name", "New config") if err != nil { return nil } p := Profile{Name: name, DNS: true, Tunnel: Tunnel{Port: 443, HTTPS: true}} if err = editConfig(a.dir, &p); err == nil { err = a.save(p) } if err != nil { fmt.Println(err) } default: p, err := s.find(v) if err != nil { fmt.Println(err) continue } if err = a.profileMenu(t, p); err != nil { fmt.Println(err) } } } } func (a *App) profileMenu(t terminal, p Profile) error { for { fmt.Printf("\n%s\n1. WireGuard (DNS: %t)\n2. wstunnel (enabled: %t, host: %s, port: %d, HTTPS: %t)\n3. Use DNS from WireGuard config: %t\n4. Rename\n5. Connect\n6. Disconnect\n7. Delete\n0. Back\n", p.Name, p.DNS, p.Tunnel.Enabled, p.Tunnel.Host, p.Tunnel.Port, p.Tunnel.HTTPS, p.DNS) v, err := t.ask(">", "") if err != nil { return err } next := p switch v { case "0": return nil case "1": err = editConfig(a.dir, &next) case "2": err = tunnelMenu(t, &next) case "3": next.DNS = !next.DNS case "4": next.Name, err = t.ask("Name", p.Name) case "5": err = a.connect(p.ID) if err == nil { return a.watch(t) } case "6": a.engine.stop() continue case "7": v, err = t.ask("Delete this profile? y/n", "n") if err == nil && v == "y" { return a.remove(p.ID) } continue default: continue } if err == nil && v != "5" { err = a.save(next) if err == nil { p = next } } if err != nil { fmt.Println(err) } } } func tunnelMenu(t terminal, p *Profile) error { for { fmt.Printf("\n1. Enabled: %t\n2. IP/host: %s (empty = WireGuard endpoint)\n3. Port: %d\n4. HTTPS: %t\n0. Back\n", p.Tunnel.Enabled, p.Tunnel.Host, p.Tunnel.Port, p.Tunnel.HTTPS) v, e := t.ask(">", "") if e != nil { return e } switch v { case "0": return nil case "1": p.Tunnel.Enabled = !p.Tunnel.Enabled case "2": h, e := t.ask("Host, '-' to reset", p.Tunnel.Host) if e != nil { return e } if h == "-" { h = "" } p.Tunnel.Host = h case "3": v, e = t.ask("Port", strconv.Itoa(p.Tunnel.Port)) if e != nil { return e } n, e := strconv.Atoi(v) if e != nil || n < 1 || n > 65535 { fmt.Println("Port must be 1..65535") } else { p.Tunnel.Port = n } case "4": p.Tunnel.HTTPS = !p.Tunnel.HTTPS } } } func editConfig(dir string, p *Profile) error { f, err := os.CreateTemp(dir, "edit-*.conf") if err != nil { return err } path := f.Name() defer os.Remove(path) if err = f.Chmod(0600); err == nil { _, err = f.WriteString(p.WireGuard) } ce := f.Close() if err != nil { return err } if ce != nil { return ce } editor := os.Getenv("EDITOR") if editor == "" { editor = "nano" } args, err := editorArgs(editor) if err != nil { return err } c := exec.Command(args[0], append(args[1:], filepath.Clean(path))...) c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr if err = c.Run(); err != nil { return err } b, err := os.ReadFile(path) if err != nil { return err } p.WireGuard = string(b) return nil } // Parse quoted executable paths and arguments without executing a shell. func editorArgs(s string) ([]string, error) { var args []string var word strings.Builder var quote rune for _, r := range s { if quote != 0 { if r == quote { quote = 0 } else { word.WriteRune(r) } continue } if r == '\'' || r == '"' { quote = r } else if r == ' ' || r == '\t' { if word.Len() > 0 { args = append(args, word.String()) word.Reset() } } else { word.WriteRune(r) } } if quote != 0 { return nil, fmt.Errorf("unclosed quote in EDITOR") } if word.Len() > 0 { args = append(args, word.String()) } if len(args) == 0 { return nil, fmt.Errorf("empty EDITOR") } return args, nil } func (a *App) watch(t terminal) error { fmt.Println("Connected. Enter returns to menu, Ctrl+C quits.") done := make(chan struct{}) go func() { defer close(done); _, _ = t.r.ReadString('\n') }() for { fmt.Printf("\r\033[K%s", a.engine.status().Line()) select { case <-done: fmt.Println() return nil case <-time.After(time.Second): } } }