260 lines
5.3 KiB
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):
|
|
}
|
|
}
|
|
}
|