wswg/tui.go

260 lines
5.3 KiB
Go

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):
}
}
}