Files
magpie/internal/gui/web.go
T

188 lines
6.5 KiB
Go

package gui
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/update"
)
// `magpie web` serves the window's page to a browser, for a computer that
// can't show the app — no desktop, WSL, a server reached over SSH — with
// the gateway beside it as the app has. What is the desktop's (the panel,
// the tray, the Dock) does nothing there; a folder is named, not opened.
// webHost is the Windows a browser tab stands for.
type webHost struct{ quit func() }
func (webHost) HidePanel() {}
func (webHost) ShowMain(string) {}
func (h webHost) Quit() { h.quit() }
func (webHost) OpenURL(string) {} // the page opens links itself
func (webHost) Copy(string) bool { return false }
func (webHost) FitPanel(int, Glide) {}
func (webHost) TintPanel([4]uint8, int) bool { return false }
func (webHost) OpenFolder(path string) error {
return errors.New("in the browser magpie can't open folders: it is " + tilde(path))
}
// Web is a started `magpie web`: its address and the link that signs a
// browser in.
type Web struct {
Addr, Link string
key string
srv *http.Server
quit chan struct{}
}
// webReexec is set when the page's restart to update has put the new
// version in: Wait then runs it in this one's place.
var webReexec atomic.Bool
// webRunKey hands a run's own key to the version it restarts into, so the
// browser's cookie still opens the page (#111).
const webRunKey = "MAGPIE_WEB_RUNKEY"
// StartWeb serves the page on addr (host:port), as the given version of
// magpie, which keeps itself up to date as the app does. Every request needs the
// key the link carries, taken once into a cookie other sites' pages can't
// send with a POST (every change is one), so neither a page elsewhere nor
// anyone else on the network reaches the settings and keys behind it.
func StartWeb(addr, version string) (*Web, error) {
Version = version
key, fixed, err := webKey()
if err != nil {
return nil, err
}
var keep time.Duration
if fixed {
keep = webCookieAge
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
w := &Web{Addr: ln.Addr().String(), key: key, quit: make(chan struct{})}
host, port, _ := net.SplitHostPort(w.Addr)
if ip := net.ParseIP(host); ip == nil || ip.IsUnspecified() {
host = "127.0.0.1"
}
var once sync.Once
quit := func() { once.Do(func() { close(w.quit) }) }
w.Link = "http://" + net.JoinHostPort(host, port) + "/?k=" + url.QueryEscape(key)
w.srv = &http.Server{
Handler: webGuard("magpie_web_"+port, key, keep, Handler(webHost{quit}, startBackend())),
ReadHeaderTimeout: 30 * time.Second,
}
go w.srv.Serve(ln)
updates.start()
return w, nil
}
// Wait returns once the page's Quit is used. After a restart to update it
// runs the new version in this one's place, with the same arguments and
// key, so the page open in the browser finds it where it was.
func (w *Web) Wait() error {
<-w.quit
time.Sleep(200 * time.Millisecond) // the answer to the Quit gets out
w.srv.Close()
if !webReexec.Load() {
return nil
}
exe, err := update.Executable()
if err != nil {
return err
}
return update.Reexec(exe, os.Args[1:], append(os.Environ(), webRunKey+"="+w.key))
}
// webKeyMin is the shortest MAGPIE_WEB_KEY taken: it is all that stands
// between the network and the keys behind the page.
const webKeyMin = 16
// webCookieAge is how long a browser keeps a fixed key's cookie (the most
// Chrome allows); a run's own key lives in a cookie that goes with the
// browser session, as the key goes with the run.
const webCookieAge = 400 * 24 * time.Hour
// webKey is the key the link carries: a new one each run, unless
// MAGPIE_WEB_KEY names one — for a page kept running as a service, which
// browsers then stay signed in to across restarts. fixed says which.
func webKey() (key string, fixed bool, err error) {
if k := os.Getenv(webRunKey); k != "" {
os.Unsetenv(webRunKey) // the run's alone, not its children's
if len(k) == 32 && strings.Trim(k, "0123456789abcdef") == "" && os.Getenv("MAGPIE_WEB_KEY") == "" {
return k, false, nil
}
}
if k := os.Getenv("MAGPIE_WEB_KEY"); k != "" {
if len(k) < webKeyMin {
return "", false, fmt.Errorf("MAGPIE_WEB_KEY is %d characters; it needs at least %d", len(k), webKeyMin)
}
// a cookie can't hold a space, ; " \ or a comma: the browser would
// be sent one that isn't the key, and turned away
if strings.Trim(k, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") != "" {
return "", false, errors.New("MAGPIE_WEB_KEY takes letters, digits and - . _ ~ only (openssl rand -hex 16 makes one)")
}
return k, true, nil
}
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b), false, nil
}
// webGuard lets through requests with the key: in the link's k, which it
// trades for a cookie (kept for keep, or the browser session when 0) and
// a clean address, or in that cookie.
func webGuard(cookie, key string, keep time.Duration, next http.Handler) http.Handler {
same := func(v string) bool { return subtle.ConstantTimeCompare([]byte(v), []byte(key)) == 1 }
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if q := r.URL.Query(); q.Has("k") {
if !same(q.Get("k")) {
http.Error(rw, "this link's key is not this magpie web's: use the link it printed when it started", http.StatusUnauthorized)
return
}
http.SetCookie(rw, &http.Cookie{Name: cookie, Value: key, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(keep / time.Second)})
q.Del("k")
u := *r.URL
u.RawQuery = q.Encode()
http.Redirect(rw, r, u.RequestURI(), http.StatusSeeOther)
return
}
if c, err := r.Cookie(cookie); err != nil || !same(c.Value) {
http.Error(rw, "open the link magpie web printed when it started: it carries the key", http.StatusUnauthorized)
return
}
next.ServeHTTP(rw, r)
})
}
// LANAddrs are this computer's addresses on the local network, for the
// links to print when the page is served there.
func LANAddrs() []string {
var out []string
as, _ := net.InterfaceAddrs()
for _, a := range as {
n, ok := a.(*net.IPNet)
if !ok || n.IP.IsLoopback() || n.IP.To4() == nil || !n.IP.IsPrivate() {
continue
}
out = append(out, n.IP.String())
}
return out
}
// isWeb: the page is served to a browser by `magpie web`.
func isWeb(w Windows) bool { _, ok := w.(webHost); return ok }