wswg/main.go

286 lines
6.6 KiB
Go

package main
import (
"errors"
"flag"
"fmt"
"hash/fnv"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"sync"
"time"
)
var version = "dev"
type App struct {
mu sync.Mutex
dir string
settings Settings
engine Engine
noTunnel bool
}
func main() {
if e := run(); e != nil {
fmt.Fprintln(os.Stderr, "wswg:", e)
os.Exit(1)
}
}
func run() error {
enableTerminal()
var auto, daemon, tui, gui, ver, noTunnel bool
var config, dir string
flag.BoolVar(&auto, "a", false, "connect last profile")
flag.BoolVar(&auto, "auto", false, "connect last profile")
flag.BoolVar(&daemon, "d", false, "run in background, requires -a or -c")
flag.BoolVar(&daemon, "daemon", false, "run in background")
flag.BoolVar(&tui, "t", false, "force terminal UI")
flag.BoolVar(&tui, "tui", false, "force terminal UI")
flag.BoolVar(&gui, "g", false, "force browser GUI")
flag.BoolVar(&gui, "gui", false, "force browser GUI")
flag.StringVar(&config, "c", "", "profile number or name")
flag.StringVar(&config, "config", "", "profile number or name")
flag.StringVar(&dir, "data-dir", "", "settings directory")
flag.BoolVar(&ver, "version", false, "show version")
flag.BoolVar(&noTunnel, "no-wstunnel", false, "disable wstunnel for this -a / -c connection only")
flag.Parse()
if ver {
fmt.Println("wswg", version, runtime.GOOS, runtime.GOARCH)
return nil
}
if tui && gui {
return errors.New("-t and -g cannot be combined")
}
if flag.NArg() > 0 {
return errors.New("unexpected positional arguments")
}
if dir == "" {
base, e := os.UserConfigDir()
if e != nil {
return e
}
dir = filepath.Join(base, "wswg")
}
var e error
dir, e = filepath.Abs(dir)
if e != nil {
return e
}
if e = os.MkdirAll(dir, 0700); e != nil {
return e
}
if e = os.Chmod(dir, 0700); e != nil {
return e
}
s, e := loadSettings(dir)
if e != nil {
return e
}
if noTunnel && !auto && config == "" {
return errors.New("--no-wstunnel requires -a or -c")
}
selected := config
if selected == "" && auto {
selected = s.Last
if selected == "" {
return errors.New("no previous profile; choose -c first")
}
}
if daemon {
if selected == "" {
return errors.New("-d requires -a or -c")
}
if _, e = s.find(selected); e != nil {
return e
}
exe, e := os.Executable()
if e != nil {
return e
}
log, e := os.OpenFile(filepath.Join(dir, "daemon.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if e != nil {
return e
}
defer log.Close()
args := []string{"--data-dir", dir, "-c", selected}
if noTunnel {
args = append(args, "--no-wstunnel")
}
c := exec.Command(exe, args...)
ready := filepath.Join(dir, ".ready-"+newID())
defer os.Remove(ready)
c.Env = append(os.Environ(), "WSWG_DAEMON=1", "WSWG_READY="+ready)
c.Stdout = log
c.Stderr = log
background(c)
if e = c.Start(); e != nil {
return e
}
wait := make(chan error, 1)
go func() { wait <- c.Wait() }()
deadline := time.NewTimer(30 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case err := <-wait:
return fmt.Errorf("daemon exited: %v; see %s", err, log.Name())
case <-deadline.C:
_ = c.Process.Kill()
<-wait
return fmt.Errorf("daemon startup timed out; see %s", log.Name())
case <-ticker.C:
if _, err := os.Stat(ready); err == nil {
fmt.Println("Daemon connected, PID", c.Process.Pid, "log:", log.Name())
return nil
}
}
}
}
h := fnv.New32a()
h.Write([]byte(dir))
lock, e := net.Listen("tcp4", fmt.Sprintf("127.0.0.1:%d", 30000+h.Sum32()%20000))
if e != nil {
return errors.New("another wswg instance is running for this data directory, or its lock port is busy")
}
defer lock.Close()
a := &App{dir: dir, settings: s, noTunnel: noTunnel}
a.engine.dir = dir
defer a.engine.stop()
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, terminationSignal())
defer signal.Stop(signals)
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-signals:
a.engine.stop()
os.Exit(0)
case <-done:
}
}()
if selected != "" {
p, e := s.find(selected)
if e != nil {
return e
}
if e = a.connect(p.ID); e != nil {
return e
}
if os.Getenv("WSWG_DAEMON") == "1" {
if ready := os.Getenv("WSWG_READY"); ready != "" {
if e = os.WriteFile(ready, []byte("ready"), 0600); e != nil {
return e
}
}
for {
time.Sleep(time.Second)
if s := a.engine.status(); !s.Connected {
return fmt.Errorf("daemon disconnected: %s", s.Error)
}
}
}
for {
if s := a.engine.status(); !s.Connected {
return fmt.Errorf("disconnected: %s", s.Error)
}
fmt.Printf("\r\033[K%s", a.engine.status().Line())
time.Sleep(time.Second)
}
}
if !tui && !gui {
gui = runtime.GOOS == "windows" || os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}
if gui {
if s.Autorun && s.Last != "" {
if e = a.connect(s.Last); e != nil {
fmt.Fprintln(os.Stderr, e)
}
}
return a.gui()
}
return a.tui()
}
func (a *App) connect(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
p, e := a.settings.find(id)
if e != nil {
return e
}
if a.noTunnel {
p.Tunnel.Enabled = false
}
if e = a.engine.start(p); e != nil {
return e
}
old := a.settings.Last
a.settings.Last = p.ID
if e = saveSettings(a.dir, a.settings); e != nil {
a.settings.Last = old
a.engine.stop()
}
return e
}
func (a *App) save(p Profile) error {
if e := validateProfile(p); e != nil {
return e
}
a.mu.Lock()
defer a.mu.Unlock()
if a.engine.activeID() == p.ID && p.ID != "" {
return errors.New("disconnect before editing this profile")
}
for _, x := range a.settings.Profiles {
if x.Name == p.Name && x.ID != p.ID {
return errors.New("name already exists")
}
}
old := append([]Profile(nil), a.settings.Profiles...)
found := false
for i, x := range a.settings.Profiles {
if x.ID == p.ID && p.ID != "" {
a.settings.Profiles[i] = p
found = true
}
}
if !found {
p.ID = newID()
a.settings.Profiles = append(a.settings.Profiles, p)
}
if e := saveSettings(a.dir, a.settings); e != nil {
a.settings.Profiles = old
return e
}
return nil
}
func (a *App) remove(id string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.engine.activeID() == id {
return errors.New("disconnect before deleting")
}
old := a.settings
next := a.settings
next.Profiles = []Profile{}
for _, p := range old.Profiles {
if p.ID != id {
next.Profiles = append(next.Profiles, p)
}
}
if next.Last == id {
next.Last = ""
}
if e := saveSettings(a.dir, next); e != nil {
return e
}
a.settings = next
return nil
}