wswg/gui.go

147 lines
3.7 KiB
Go

package main
import (
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"runtime"
"strings"
"time"
)
//go:embed web/index.html
var assets embed.FS
func (a *App) gui() error {
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return err
}
token := newID() + newID()
origin := "http://" + ln.Addr().String()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'; connect-src 'self'")
if r.Host != ln.Addr().String() {
http.Error(w, "invalid host", 403)
return
}
if r.URL.Path == "/" {
if r.Method != "GET" {
http.Error(w, "method", 405)
return
}
b, _ := assets.ReadFile("web/index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(b)
return
}
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+token)) != 1 {
http.Error(w, "unauthorized", 401)
return
}
if o := r.Header.Get("Origin"); o != "" && o != origin {
http.Error(w, "origin", 403)
return
}
w.Header().Set("Content-Type", "application/json")
var result any = map[string]bool{"ok": true}
var err error
if r.Method == "GET" && r.URL.Path == "/api/state" {
a.mu.Lock()
s := a.settings
a.mu.Unlock()
result = struct {
Settings Settings `json:"settings"`
Status Status `json:"status"`
}{s, a.engine.status()}
} else if r.Method == "POST" {
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
decode := func(v any) error {
if e := dec.Decode(v); e != nil {
return e
}
if e := dec.Decode(new(any)); e != io.EOF {
return errors.New("unexpected trailing JSON")
}
return nil
}
switch r.URL.Path {
case "/api/save":
var p Profile
if err = decode(&p); err == nil {
err = a.save(p)
}
case "/api/delete", "/api/connect":
var p struct {
ID string `json:"id"`
}
if err = decode(&p); err == nil {
if r.URL.Path == "/api/delete" {
err = a.remove(p.ID)
} else {
err = a.connect(p.ID)
}
}
case "/api/disconnect":
a.engine.stop()
if s := a.engine.status(); s.Error != "" {
err = errors.New(s.Error)
}
case "/api/options":
var o struct {
Autorun bool `json:"autorun"`
OnBoot bool `json:"onBoot"`
}
if err = decode(&o); err == nil {
err = a.setOptions(o.Autorun, o.OnBoot)
}
default:
http.NotFound(w, r)
return
}
} else {
http.NotFound(w, r)
return
}
if err != nil {
w.WriteHeader(400)
result = map[string]string{"error": err.Error()}
}
_ = json.NewEncoder(w).Encode(result)
})
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 45 * time.Second, IdleTimeout: 60 * time.Second}
defer server.Close()
url := origin + "/#" + token
fmt.Println("GUI:", url, "\nKeep this process running. Ctrl+C stops the VPN.")
var c *exec.Cmd
switch runtime.GOOS {
case "windows":
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "darwin":
c = exec.Command("open", url)
default:
if strings.Contains(runtime.GOOS, "android") {
c = exec.Command("termux-open-url", url)
} else {
c = exec.Command("xdg-open", url)
}
}
if err = c.Start(); err != nil {
fmt.Println("Open the GUI URL in your browser:", err)
} else {
go c.Wait()
}
return server.Serve(ln)
}