From 1266840d0713ff8e17659394dd4834fcc665fd11 Mon Sep 17 00:00:00 2001 From: jthy10 <43766287+jthy10@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:27:12 -0400 Subject: [PATCH 1/2] Console: LAN access setting and -lan flag Opening the console to other devices took -addr, a password from the environment and -tls-self-signed. Now there's a switch under Settings ("Use on other devices") and a -lan flag; -lan=false turns it off. The choice is stored in the database and kept across restarts. Turning it on moves only the web console, not the scanner: it rebinds on every interface at the same port, over HTTPS with the self-signed certificate. If the new address can't be bound, it goes back to the old one. Your own password is still required first. -addr overrides the setting and can't be combined with -lan. The address for other devices is shown under Settings, as "Web app" in the sidebar's Network card, on the Capabilities page and in the terminal. --- README.md | 19 ++- docs/security.md | 2 +- internal/app/app.go | 151 ++++++++---------- internal/app/app_test.go | 145 +++++++++++++++++ internal/app/console.go | 278 +++++++++++++++++++++++++++++++++ internal/config/config.go | 15 ++ internal/config/config_test.go | 22 +++ internal/web/server.go | 24 +++ ui/static/css/app.css | 9 ++ ui/static/js/main.js | 7 +- ui/static/js/views/settings.js | 60 ++++++- ui/static/js/views/status.js | 1 + 12 files changed, 645 insertions(+), 88 deletions(-) create mode 100644 internal/app/console.go diff --git a/README.md b/README.md index 08bcfcd..6855dd9 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ staying root. The Capabilities page shows which features are on and why. | Flag | Default | | |---|---|---| | `-addr` | `127.0.0.1:8787` | `host:port`, `:port` or `port` (the last two bind to loopback) | +| `-lan` | off | Open the console to other devices on this network and remember it (`-lan=false` turns it off) | | `-addr-fallback` | off | Use a free port if the chosen one is taken | | `-allow-host` | | Extra names the console answers to off loopback, comma-separated | | `-user` | `admin` | Console username | @@ -92,7 +93,23 @@ staying root. The Capabilities page shows which features are on and why. The password can also come from `LOCALLANVIEW_PASSWORD`. There's no `-password` flag on purpose: it would show up in `ps` and shell history. -### Opening the console to your LAN +### Using it from your phone or other devices + +LAN access is off by default. To turn it on, set your own password first +(Settings, or `-set-password`), then either: + +- tick **Open LocalLanView to devices on this network** under Settings, or +- start it with `-lan`. + +The console restarts on every network interface, on the same port, over +HTTPS with a self-signed certificate (browsers warn about it once). The +address to use, such as `https://192.168.1.50:8787`, is shown under +Settings, in the sidebar's Network card and in the terminal. The choice is +remembered across restarts; untick the box or run with `-lan=false` to go +back to this machine only. If other devices can't connect, allow the port +through this computer's firewall. + +For full control, `-addr` still works and overrides the setting: ```sh LOCALLANVIEW_PASSWORD='a long passphrase' ./LocalLanView -addr 0.0.0.0:8787 -tls-self-signed diff --git a/docs/security.md b/docs/security.md index 9d1fc01..0f08c56 100644 --- a/docs/security.md +++ b/docs/security.md @@ -7,7 +7,7 @@ runs, and when it's around. Treat its database and console accordingly. | Threat | Mitigation | |---|---| -| Someone else on the LAN opens the console | Binds to `127.0.0.1` by default. Binding anywhere else requires a password you set yourself (12+ chars, checked for strength), and prints a warning unless TLS is on. | +| Someone else on the LAN opens the console | Binds to `127.0.0.1` by default. Binding anywhere else, with `-addr` or the LAN access setting, requires a password you set yourself (12+ chars, checked for strength). LAN access always uses HTTPS; `-addr` prints a warning unless TLS is on. | | Weak or default credentials | No default password. First run generates a random one, prints it once, and stores only an argon2id hash. | | Password leaking via `ps` or shell history | No `-password` flag. Use `-password-file`, `LOCALLANVIEW_PASSWORD` or the prompt. | | Brute-force login | Per-client lockout after 10 failures in 15 minutes (IPv6 clients by /64), a global lockout after 50 that doesn't apply to logins from the machine itself, plus growing delays. Attempts count as soon as they start, and only two password hashes run at once. | diff --git a/internal/app/app.go b/internal/app/app.go index fb6975c..9f15210 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -149,48 +149,33 @@ func Run(ctx context.Context, cfg *config.Config, opt Options) error { // 6. Exposure rules. var warnings []string - if !cfg.Addr.IsLoopback() { - if cr.Creds.Generated { - return fmt.Errorf("refusing to listen on %s: the console would be reachable from the network with the auto-generated password.\n"+ - "Set your own strong password first with -set-password, -password-file or %s", cfg.Addr, EnvPassword) - } - if !cfg.TLS() { - msg := "The console is reachable from the network over plain HTTP; logins and the device list can be sniffed. Use -tls-self-signed or -tls-cert/-tls-key." - warnings = append(warnings, msg) - fmt.Fprintln(opt.Stderr, "WARNING: "+msg) - } + if cfg.LANSet && cfg.LAN && cr.Creds.Generated { + return fmt.Errorf("refusing to open the console to the network with the auto-generated password.\n"+ + "Set your own strong password first with -set-password, -password-file or %s", EnvPassword) } - - // 7. TLS. - var tlsCfg *tls.Config - switch { - case cfg.TLSCert != "": - c, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey) - if err != nil { - return fmt.Errorf("loading TLS certificate: %w", err) - } - tlsCfg = &tls.Config{Certificates: []tls.Certificate{c}, MinVersion: tls.VersionTLS12} - case cfg.TLSSelfSigned: - ips := []net.IP{nw.IP.AsSlice()} - if !cfg.Addr.IP.IsUnspecified() { - ips = append(ips, cfg.Addr.IP.AsSlice()) - } - c, path, err := selfSignedCert(dataDir, ips) - if err != nil { - return fmt.Errorf("creating self-signed certificate: %w", err) - } - fmt.Fprintf(out, "Using self-signed certificate %s (your browser will warn about it once).\n", path) - tlsCfg = &tls.Config{Certificates: []tls.Certificate{c}, MinVersion: tls.VersionTLS12} + lan, err := lanWanted(ctx, st, cfg) + if err != nil { + return err + } + const heldBackWarning = "LAN access is on, but the console still uses the generated password, so it's only reachable from this machine. Set your own password, then turn LAN access on again under Settings." + lanHeldBack := lan && cr.Creds.Generated + if lanHeldBack { + lan = false + fmt.Fprintln(opt.Stderr, "WARNING: "+heldBackWarning) + } + if !cfg.Addr.IsLoopback() && cr.Creds.Generated { + return fmt.Errorf("refusing to listen on %s: the console would be reachable from the network with the auto-generated password.\n"+ + "Set your own strong password first with -set-password, -password-file or %s", cfg.Addr, EnvPassword) } - // 8. Bind the console. - ln, err := listen(cfg.Addr, cfg.AddrFallback) - if err != nil { + // 7. Bind the console, with TLS when asked for or when it's on the LAN. + cons := &console{cfg: cfg, nw: nw, dataDir: dataDir, out: out, stderr: opt.Stderr, log: log, errs: make(chan error, 2)} + if err := cons.bind(lan, 0, cfg.AddrFallback); err != nil { return err } - defer ln.Close() + defer cons.stop(context.Background()) - // 9. Drop root now that the sockets are open; they stay usable. + // 8. Drop root now that the sockets are open; they stay usable. if dropping { handBack(dataDir, createdDirs, defaultDir, target.UID, target.GID) if err := privilege.DropTo(target); err != nil { @@ -204,7 +189,7 @@ func Run(ctx context.Context, cfg *config.Config, opt Options) error { fmt.Fprintln(opt.Stderr, "WARNING: "+msg) } - // 10. Inventory, alerts, scanning. + // 9. Inventory, alerts, scanning. hub := alert.NewHub() var webhook *alert.Webhook if cfg.Webhook != "" { @@ -288,22 +273,27 @@ func Run(ctx context.Context, cfg *config.Config, opt Options) error { hub.Publish(alert.Message{Type: "scan", Data: ss.snapshot()}) } - // 11. Web console. - consoleAddr := ln.Addr().String() + // 10. Web console. srv := &web.Server{ - Store: st, - Sessions: auth.NewSessions(tlsCfg != nil), - Limiter: auth.NewLoginLimiter(), - Hub: hub, - Static: ui.FS(), - Log: log, - ScanNow: engine.ScanNow, - LoopbackOnly: cfg.Addr.IsLoopback(), - AllowedHosts: consoleHosts(cfg.AllowHosts, tlsCfg), + Store: st, + Limiter: auth.NewLoginLimiter(), + Hub: hub, + Static: ui.FS(), + Log: log, + ScanNow: engine.ScanNow, } generated := cr.Creds.Generated var genMu sync.Mutex srv.OnPasswordChange = func(g bool) { genMu.Lock(); generated = g; genMu.Unlock() } + srv.SetLAN = func(ctx context.Context, on bool) (any, error) { + genMu.Lock() + g := generated + genMu.Unlock() + if on && g { + return nil, ErrLANNeedsPassword + } + return cons.setLAN(ctx, st, on) + } srv.Status = func(ctx context.Context) any { ds, _ := st.Devices(ctx) unread, _ := st.UnacknowledgedCount(ctx) @@ -316,29 +306,38 @@ func Run(ctx context.Context, cfg *config.Config, opt Options) error { genMu.Lock() g := generated genMu.Unlock() + ci := cons.info() + ws := append([]string(nil), warnings...) + if !ci.Loopback && !ci.TLS { + ws = append(ws, plainHTTPWarning) + } + if lanHeldBack && g { + ws = append(ws, heldBackWarning) + } return map[string]any{ "version": opt.Version, "network": map[string]string{"interface": nw.Iface.Name, "ip": nw.IP.String(), "subnet": nw.Prefix.String(), "mac": nw.Iface.HardwareAddr.String(), "gateway": gateway}, "privilege": priv, "features": engine.Features(), "scan": ss.snapshot(), - "console": map[string]any{"addr": consoleAddr, "tls": tlsCfg != nil, "loopback": cfg.Addr.IsLoopback()}, + "console": ci, "dbPath": st.Path(), "devices": len(ds), "online": online, "unread": unread, "generatedPassword": g, - "warnings": warnings, + "warnings": ws, "liveWindowSeconds": liveWindow.Seconds(), } } - httpSrv := &http.Server{ - Handler: srv.Handler(), - ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 30 * time.Second, - IdleTimeout: 2 * time.Minute, - TLSConfig: tlsCfg, - ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), + // Each listener gets its own copy of the server, with fresh sessions: + // moving to or from the LAN changes the address and the scheme anyway. + cons.handler = func(loopback bool, tlsCfg *tls.Config) http.Handler { + s := *srv + s.Sessions = auth.NewSessions(tlsCfg != nil) + s.LoopbackOnly = loopback + s.AllowedHosts = consoleHosts(cfg.AllowHosts, tlsCfg) + return s.Handler() } ctx, cancel := context.WithCancel(ctx) @@ -357,41 +356,26 @@ func Run(ctx context.Context, cfg *config.Config, opt Options) error { }() } - serveErr := make(chan error, 1) - go func() { - var err error - if tlsCfg != nil { - err = httpSrv.ServeTLS(ln, "", "") - } else { - err = httpSrv.Serve(ln) - } - if !errors.Is(err, http.ErrServerClosed) { - serveErr <- err - } - close(serveErr) - }() - - url := consoleURL(ln.Addr().(*net.TCPAddr), tlsCfg != nil) - printBanner(out, url, cfg, cr, priv, nw, engine.Features(), dataDir) + cons.serve() + ci := cons.info() + printBanner(out, ci, cr, priv, nw, engine.Features(), dataDir) if opt.Ready != nil { - opt.Ready(url) + opt.Ready(ci.URL) } if !cfg.NoOpenBrowser { - openBrowser(url) + openBrowser(ci.URL) } go engine.Run(ctx) select { case <-ctx.Done(): - case err := <-serveErr: - if err != nil { - return fmt.Errorf("web console stopped: %w", err) - } + case err := <-cons.errs: + return fmt.Errorf("web console stopped: %w", err) } shutCtx, done := context.WithTimeout(context.Background(), 5*time.Second) defer done() - httpSrv.Shutdown(shutCtx) + cons.stop(shutCtx) return nil } @@ -433,9 +417,12 @@ func consoleURL(a *net.TCPAddr, tls bool) string { return scheme + "://" + net.JoinHostPort(host.String(), fmt.Sprint(a.Port)) + "/" } -func printBanner(w io.Writer, url string, cfg *config.Config, cr *credResult, priv privilege.Status, nw *netinfo.Network, feats []scan.Feature, dataDir string) { +func printBanner(w io.Writer, ci consoleInfo, cr *credResult, priv privilege.Status, nw *netinfo.Network, feats []scan.Feature, dataDir string) { fmt.Fprintf(w, "\nLocalLanView is running\n\n") - fmt.Fprintf(w, " Dashboard: %s\n", url) + fmt.Fprintf(w, " Dashboard: %s\n", ci.URL) + if ci.LANURL != "" { + fmt.Fprintf(w, " On your network: %s (open it on any device on this network)\n", ci.LANURL) + } fmt.Fprintf(w, " Scanning: %s on %s (this machine is %s)\n", nw.Prefix, nw.Iface.Name, nw.IP) mode := "standard" if priv.Elevated { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d991ef1..32caa87 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -3,10 +3,13 @@ package app import ( "bytes" "context" + "crypto/tls" + "encoding/json" "errors" "io" "net" "net/http" + "net/http/cookiejar" "net/netip" "os" "path/filepath" @@ -186,3 +189,145 @@ func TestSelfSignedTLS(t *testing.T) { t.Fatalf("url %s", url) } } + +// lanConfig is testConfig on a free port, which -lan keeps; -addr can't be +// combined with -lan. +func lanConfig(t *testing.T, args ...string) *config.Config { + cfg := testConfig(t, args...) + cfg.Addr.Port = freePort(t) + return cfg +} + +func TestLANFlag(t *testing.T) { + err := run(lanConfig(t, "-lan"), fakeChecker(false)) + if err == nil || !strings.Contains(err.Error(), "auto-generated password") { + t.Fatalf("want refusal with generated password, got %v", err) + } + + t.Setenv(EnvPassword, "Env-Supplied-Passw0rd") + dir := t.TempDir() + out := &syncBuf{} + cfg := lanConfig(t, "-lan", "-data-dir", dir) + url := start(t, cfg, out) + if !strings.HasPrefix(url, "https://127.0.0.1:") || !strings.Contains(out.String(), "On your network: https://") { + t.Fatalf("url %s, output:\n%s", url, out) + } + + // The choice is remembered without the flag, and -lan=false forgets it. + for _, tc := range []struct { + args []string + https bool + }{{nil, true}, {[]string{"-lan=false"}, false}, {nil, false}} { + url := "" + cfg := lanConfig(t, append([]string{"-data-dir", dir}, tc.args...)...) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + Run(ctx, cfg, Options{Stdout: io.Discard, Stderr: io.Discard, Checker: fakeChecker(false), Network: loopbackNet(), NoSignal: true, + Ready: func(u string) { url = u; cancel() }}) + cancel() + if strings.HasPrefix(url, "https://") != tc.https { + t.Fatalf("%v: url %q", tc.args, url) + } + } +} + +func TestLANToggle(t *testing.T) { + t.Setenv(EnvPassword, "Env-Supplied-Passw0rd") + out := &syncBuf{} + url := start(t, lanConfig(t), out) + if !strings.HasPrefix(url, "http://127.0.0.1:") { + t.Fatalf("LAN access should be off by default: %s", url) + } + jar, _ := cookiejar.New(nil) + c := &http.Client{Jar: jar, Timeout: 5 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + csrf := login(t, c, url) + + resp := post(t, c, url+"api/console", csrf, `{"lan":true}`) + var info struct { + URL string `json:"url"` + LANURL string `json:"lanUrl"` + LAN bool `json:"lan"` + } + json.NewDecoder(resp.Body).Decode(&info) + resp.Body.Close() + if resp.StatusCode != 200 || !info.LAN || !strings.HasPrefix(info.URL, "https://127.0.0.1:") || info.LANURL == "" { + t.Fatalf("turning LAN on: %d %+v", resp.StatusCode, info) + } + + // The console comes back over HTTPS on the same port, with new sessions. + deadline := time.Now().Add(5 * time.Second) + for { + resp, err := c.Get(info.URL + "api/session") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + break + } + } + if time.Now().After(deadline) { + t.Fatalf("console didn't come back on %s: %v", info.URL, err) + } + time.Sleep(100 * time.Millisecond) + } + if !strings.Contains(out.String(), "On other devices: https://") { + t.Fatalf("terminal wasn't told the new address:\n%s", out) + } + + csrf = login(t, c, info.URL) + resp = post(t, c, info.URL+"api/console", csrf, `{"lan":false}`) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("turning LAN off: %d", resp.StatusCode) + } +} + +func TestLANToggleNeedsOwnPassword(t *testing.T) { + out := &syncBuf{} + url := start(t, lanConfig(t), out) + pw := "" + for _, l := range strings.Split(out.String(), "\n") { + if strings.HasPrefix(l, " ") && strings.TrimSpace(l) != "" { + pw = strings.TrimSpace(l) + } + } + jar, _ := cookiejar.New(nil) + c := &http.Client{Jar: jar, Timeout: 5 * time.Second} + csrf := loginAs(t, c, url, pw) + resp := post(t, c, url+"api/console", csrf, `{"lan":true}`) + resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("want 409 with the generated password, got %d", resp.StatusCode) + } +} + +func login(t *testing.T, c *http.Client, url string) string { + return loginAs(t, c, url, "Env-Supplied-Passw0rd") +} + +func loginAs(t *testing.T, c *http.Client, url, pw string) string { + t.Helper() + b, _ := json.Marshal(map[string]string{"user": "admin", "password": pw}) + resp, err := c.Post(url+"api/login", "application/json", bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("login: %d", resp.StatusCode) + } + var out struct{ CSRF string } + json.NewDecoder(resp.Body).Decode(&out) + return out.CSRF +} + +func post(t *testing.T, c *http.Client, url, csrf, body string) *http.Response { + t.Helper() + req, _ := http.NewRequest("POST", url, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-CSRF-Token", csrf) + resp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} diff --git a/internal/app/console.go b/internal/app/console.go new file mode 100644 index 0000000..c9579f0 --- /dev/null +++ b/internal/app/console.go @@ -0,0 +1,278 @@ +package app + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/netip" + "sync" + "time" + + "github.com/jthy10/LocalLanView/internal/config" + "github.com/jthy10/LocalLanView/internal/netinfo" + "github.com/jthy10/LocalLanView/internal/store" +) + +// settingLAN remembers whether the console is open to the LAN. +const settingLAN = "lan_access" + +// ErrLANNeedsPassword is returned when LAN access is asked for while the +// console still uses the generated first-run password. +var ErrLANNeedsPassword = errors.New("set your own console password before opening the console to the network") + +// console owns the web console's listener, so it can move between this +// machine and the LAN without restarting scanning. +type console struct { + cfg *config.Config + nw *netinfo.Network + dataDir string + out io.Writer + stderr io.Writer + log *slog.Logger + // handler builds the HTTP handler for a listener; it's called again on + // every move, so sessions start fresh along with the new address. + handler func(loopback bool, tlsCfg *tls.Config) http.Handler + // errs receives an error if serving stops unexpectedly. + errs chan error + + mu sync.Mutex + lan bool + ln net.Listener + srv *http.Server + tlsCfg *tls.Config + moving bool + closed bool +} + +// lanAddr is where the console listens with LAN access on: every +// interface, on the port it would use otherwise. +func lanAddr(a config.Addr) config.Addr { + return config.Addr{IP: netip.IPv4Unspecified(), Port: a.Port} +} + +// bind opens the console's listener. Serving starts with serve, so the +// port can be taken before privileges are dropped. +func (c *console) bind(lan bool, port int, fallback bool) error { + addr := c.cfg.Addr + if lan { + addr = lanAddr(addr) + } + if port != 0 { + addr.Port = port + } + tlsCfg, err := c.tlsConfig(lan, addr) + if err != nil { + return err + } + ln, err := listen(addr, fallback) + if err != nil { + return err + } + if !addr.IsLoopback() && tlsCfg == nil { + fmt.Fprintln(c.stderr, "WARNING: "+plainHTTPWarning) + } + c.lan, c.ln, c.tlsCfg = lan, ln, tlsCfg + return nil +} + +// serve serves the console on the bound listener in the background. +func (c *console) serve() { + ln, tlsCfg := c.ln, c.tlsCfg + srv := &http.Server{ + Handler: c.handler(ln.Addr().(*net.TCPAddr).IP.IsLoopback(), tlsCfg), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + TLSConfig: tlsCfg, + ErrorLog: slog.NewLogLogger(c.log.Handler(), slog.LevelWarn), + } + c.srv = srv + go func() { + var err error + if tlsCfg != nil { + err = srv.ServeTLS(ln, "", "") + } else { + err = srv.Serve(ln) + } + if !errors.Is(err, http.ErrServerClosed) { + c.errs <- err + } + }() +} + +const plainHTTPWarning = "The console is reachable from the network over plain HTTP; logins and the device list can be sniffed. Use -tls-self-signed or -tls-cert/-tls-key." + +// tlsConfig serves HTTPS when asked to on the command line, and always +// when the console is open to the LAN, so the password never crosses the +// network in the clear. +func (c *console) tlsConfig(lan bool, addr config.Addr) (*tls.Config, error) { + switch { + case c.cfg.TLSCert != "": + cert, err := tls.LoadX509KeyPair(c.cfg.TLSCert, c.cfg.TLSKey) + if err != nil { + return nil, fmt.Errorf("loading TLS certificate: %w", err) + } + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil + case c.cfg.TLSSelfSigned || lan: + ips := []net.IP{c.nw.IP.AsSlice()} + if !addr.IP.IsUnspecified() { + ips = append(ips, addr.IP.AsSlice()) + } + cert, path, err := selfSignedCert(c.dataDir, ips) + if err != nil { + return nil, fmt.Errorf("creating self-signed certificate: %w", err) + } + fmt.Fprintf(c.out, "Using self-signed certificate %s (browsers will warn about it once).\n", path) + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil + } + return nil, nil +} + +// setLAN moves the console to or from the LAN. The move happens shortly +// after it returns, so the request that asked for it can still be +// answered; the reply carries the addresses the console will have. +func (c *console) setLAN(ctx context.Context, st *store.Store, on bool) (consoleInfo, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cfg.AddrSet { + return consoleInfo{}, errors.New("the console address was set with -addr on the command line, so it can't be changed here") + } + if c.moving { + return consoleInfo{}, errors.New("the console is already moving; try again in a moment") + } + if c.lan == on { + return c.infoLocked(), nil + } + if err := st.SetSetting(ctx, settingLAN, boolSetting(on)); err != nil { + return consoleInfo{}, err + } + c.moving = true + port := c.ln.Addr().(*net.TCPAddr).Port + secure := on || c.cfg.TLS() + next := consoleInfo{TLS: secure, Loopback: !on, LAN: on, URL: consoleURL(&net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}, secure)} + if on { + next.LANURL = lanURL(c.nw, port, secure) + } + go func() { + time.Sleep(500 * time.Millisecond) // let the reply reach the browser + c.move(st, on) + }() + return next, nil +} + +// move restarts the console on the new address, going back to the old +// one if that fails. +func (c *console) move(st *store.Store, on bool) { + c.mu.Lock() + defer c.mu.Unlock() + defer func() { c.moving = false }() + if c.closed { + return + } + port := c.ln.Addr().(*net.TCPAddr).Port + c.srv.Close() + if err := c.bind(on, port, false); err != nil { + c.log.Error("couldn't move the console", "lan", on, "err", err) + fmt.Fprintf(c.stderr, "Couldn't move the console: %v\n", err) + st.SetSetting(context.Background(), settingLAN, boolSetting(!on)) + if err := c.bind(!on, port, false); err != nil { + c.errs <- fmt.Errorf("restarting the console: %w", err) + return + } + } + c.serve() + c.printAddresses(c.infoLocked()) +} + +func (c *console) stop(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + if c.srv != nil { + c.srv.Shutdown(ctx) + } else { + c.ln.Close() + } +} + +// consoleInfo is what the dashboard and the terminal are told about the +// console. +type consoleInfo struct { + Addr string `json:"addr"` + TLS bool `json:"tls"` + Loopback bool `json:"loopback"` + LAN bool `json:"lan"` + URL string `json:"url"` // from this machine + LANURL string `json:"lanUrl,omitempty"` // from other devices + Locked string `json:"locked,omitempty"` // why LAN access can't be toggled here +} + +func (c *console) info() consoleInfo { + c.mu.Lock() + defer c.mu.Unlock() + return c.infoLocked() +} + +func (c *console) infoLocked() consoleInfo { + a := c.ln.Addr().(*net.TCPAddr) + secure := c.tlsCfg != nil + i := consoleInfo{ + Addr: a.String(), + TLS: secure, + Loopback: a.IP.IsLoopback(), + LAN: !a.IP.IsLoopback(), + URL: consoleURL(a, secure), + } + switch { + case a.IP.IsUnspecified(): + i.LANURL = lanURL(c.nw, a.Port, secure) + case !i.Loopback: + i.LANURL = i.URL // -addr named one of this machine's addresses + } + if c.cfg.AddrSet { + i.Locked = "Set with -addr on the command line." + } + return i +} + +func lanURL(nw *netinfo.Network, port int, secure bool) string { + return consoleURL(&net.TCPAddr{IP: nw.IP.AsSlice(), Port: port}, secure) +} + +// printAddresses tells the terminal where the console can be reached. +func (c *console) printAddresses(i consoleInfo) { + if i.LANURL == "" { + fmt.Fprintf(c.out, "\nThe console is now only reachable from this machine: %s\n\n", i.URL) + return + } + fmt.Fprintf(c.out, "\nThe console is open to your network.\n On this machine: %s\n On other devices: %s\n\n", i.URL, i.LANURL) +} + +// lanWanted works out whether to start with LAN access: -lan sets and +// remembers the choice, otherwise the stored one applies. -addr overrides +// both. +func lanWanted(ctx context.Context, st *store.Store, cfg *config.Config) (bool, error) { + if cfg.AddrSet { + return false, nil + } + if cfg.LANSet { + return cfg.LAN, st.SetSetting(ctx, settingLAN, boolSetting(cfg.LAN)) + } + v, err := st.Setting(ctx, settingLAN) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + return v == "1", err +} + +func boolSetting(b bool) string { + if b { + return "1" + } + return "0" +} diff --git a/internal/config/config.go b/internal/config/config.go index 8bff664..414c3f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,7 +13,10 @@ import ( // Config holds everything parsed from the command line. type Config struct { Addr Addr + AddrSet bool // -addr was given, which overrides the LAN setting AddrFallback bool + LAN bool // with LANSet: turn LAN access on or off and remember it + LANSet bool AllowHosts []string User string PasswordFile string @@ -61,6 +64,7 @@ func Parse(args []string, output io.Writer) (*Config, error) { fs.SetOutput(output) addr := fs.String("addr", "127.0.0.1:8787", "address the web console binds to: host:port, :port or port") + fs.BoolVar(&c.LAN, "lan", false, "open the console to other devices on this network and remember the choice (-lan=false turns it off); needs your own password and serves HTTPS") fs.BoolVar(&c.AddrFallback, "addr-fallback", false, "if the port is in use, pick a free port instead of exiting") allowHost := fs.String("allow-host", "", "extra host names the console answers to when not on loopback, comma-separated (e.g. nas.lan)") fs.StringVar(&c.User, "user", "admin", "console username") @@ -94,6 +98,14 @@ func Parse(args []string, output io.Writer) (*Config, error) { // Print the version even if other flags have bad values. return c, nil } + fs.Visit(func(f *flag.Flag) { + switch f.Name { + case "addr": + c.AddrSet = true + case "lan": + c.LANSet = true + } + }) if fs.NArg() > 0 { return nil, fmt.Errorf("unexpected argument %q (all options are flags, see -h)", fs.Arg(0)) } @@ -134,6 +146,9 @@ func Parse(args []string, output io.Writer) (*Config, error) { if (c.TLSCert == "") != (c.TLSKey == "") { return nil, errors.New("-tls-cert and -tls-key must be used together") } + if c.AddrSet && c.LANSet { + return nil, errors.New("use either -addr or -lan, not both; for LAN access on another port use -addr 0.0.0.0:PORT") + } if c.TLSCert != "" && c.TLSSelfSigned { return nil, errors.New("use either -tls-cert/-tls-key or -tls-self-signed, not both") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5df218a..0272529 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -80,3 +80,25 @@ func TestParseAllowHost(t *testing.T) { t.Fatalf("got %q", c.AllowHosts) } } + +func TestParseLAN(t *testing.T) { + c, err := Parse(nil, io.Discard) + if err != nil { + t.Fatal(err) + } + if c.LAN || c.LANSet || c.AddrSet { + t.Fatalf("defaults: %+v", c) + } + if c, _ = Parse([]string{"-lan"}, io.Discard); !c.LAN || !c.LANSet { + t.Fatalf("-lan: %+v", c) + } + if c, _ = Parse([]string{"-lan=false"}, io.Discard); c.LAN || !c.LANSet { + t.Fatalf("-lan=false: %+v", c) + } + if c, _ = Parse([]string{"-addr", "9000"}, io.Discard); !c.AddrSet { + t.Fatalf("-addr: %+v", c) + } + if _, err := Parse([]string{"-lan", "-addr", "9000"}, io.Discard); err == nil || !strings.Contains(err.Error(), "-addr or -lan") { + t.Fatalf("-lan with -addr: %v", err) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index b3ac53f..6b31d17 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -38,6 +38,9 @@ type Server struct { // AllowedHosts are extra host names the console answers to when it // isn't loopback-only (the machine's name, certificate names, -allow-host). AllowedHosts []string + // SetLAN opens the console to the LAN or closes it again. It returns + // the console's new addresses; the move itself happens just after. + SetLAN func(ctx context.Context, on bool) (any, error) // OnPasswordChange is called after a successful password change. OnPasswordChange func(generated bool) // StreamPing is how often the event stream pings and rechecks the @@ -72,6 +75,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/events/ack", s.authed(s.handleAck)) mux.Handle("POST /api/scan", s.authed(s.handleScan)) mux.Handle("POST /api/password", s.authed(s.handlePassword)) + mux.Handle("POST /api/console", s.authed(s.handleConsole)) mux.Handle("GET /api/export", s.authed(s.handleExport)) mux.Handle("GET /api/stream", s.authed(s.handleStream)) mux.Handle("/api/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -275,6 +279,26 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request, _ *auth.Sess writeJSON(w, http.StatusAccepted, map[string]string{"status": "scan requested"}) } +func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request, _ *auth.Session) { + var req struct { + LAN *bool `json:"lan"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.LAN == nil { + writeError(w, http.StatusBadRequest, "bad request") + return + } + if s.SetLAN == nil { + writeError(w, http.StatusNotImplemented, "not available") + return + } + info, err := s.SetLAN(r.Context(), *req.LAN) + if err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + writeJSON(w, http.StatusOK, info) +} + func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request, sess *auth.Session) { var req struct { Current string `json:"current"` diff --git a/ui/static/css/app.css b/ui/static/css/app.css index 15e2e93..dc0e1c3 100644 --- a/ui/static/css/app.css +++ b/ui/static/css/app.css @@ -607,6 +607,15 @@ td.wrap { white-space: normal; color: var(--muted); } .setting .about h2 { margin-bottom: 4px; font-size: 14px; } .setting .about p { color: var(--muted); } .setting form { max-width: 420px; } +.check { display: flex; align-items: center; gap: 8px; margin: 0; cursor: pointer; } +.check input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); } +.check:has(input:disabled) { cursor: default; color: var(--muted); } +.lan-url { + display: flex; gap: 10px; align-items: flex-start; margin-top: 4px; padding: 12px; + background: var(--bg-sunken); border: 1px solid var(--line); border-radius: var(--r); +} +.lan-url > svg { flex: none; width: 18px; height: 18px; color: var(--accent); margin-top: 1px; } +.lan-url a { overflow-wrap: anywhere; } /* ---------- login ---------- */ diff --git a/ui/static/js/main.js b/ui/static/js/main.js index f2af23f..c3c3ca4 100644 --- a/ui/static/js/main.js +++ b/ui/static/js/main.js @@ -115,11 +115,14 @@ function renderNetcard() { if (!s) return; $("#brand-ver").textContent = s.version; const n = s.network; - const row = (k, v) => [h("dt", {}, k), h("dd", { title: v }, v || "—")]; + const lanUrl = s.console && s.console.lanUrl; + const row = (k, v, title) => [h("dt", {}, k), h("dd", { title: title || v }, v || "—")]; $("#netcard").replaceChildren( h("div", { class: "netcard-head" }, icon("network"), "Network", h("span", { class: "badge plain mono", title: "Interface" }, n.interface)), - h("dl", {}, row("Subnet", n.subnet), row("This host", n.ip), row("Gateway", n.gateway)), + h("dl", {}, row("Subnet", n.subnet), row("This host", n.ip), row("Gateway", n.gateway), + lanUrl ? row("Web app", h("a", { href: lanUrl }, lanUrl.replace(/^https?:\/\/|\/$/g, "")), + "Open this on any device connected to your network") : null), ); } diff --git a/ui/static/js/views/settings.js b/ui/static/js/views/settings.js index 0c33cc0..f09dce9 100644 --- a/ui/static/js/views/settings.js +++ b/ui/static/js/views/settings.js @@ -1,8 +1,9 @@ import { state, h, icon, api, toast } from "../lib.js"; -import { setCrumbs, updateBanner } from "../main.js"; +import { setCrumbs, updateBanner, loadStatus } from "../main.js"; -export function render(view) { +export async function render(view) { setCrumbs([{ text: "Settings" }]); + try { await loadStatus(); } catch (_) { return; } const cur = h("input", { type: "password", id: "pw-current", autocomplete: "current-password", required: true }); const nw = h("input", { type: "password", id: "pw-new", autocomplete: "new-password", required: true, minlength: "12", "aria-describedby": "pw-hint" }); @@ -20,6 +21,7 @@ export function render(view) { updateBanner(); form.reset(); toast("Password changed. Other sessions were signed out."); + render(view); // LAN access needs a password of your own } catch (ex) { err.textContent = ex.message; } }, }, @@ -39,6 +41,8 @@ export function render(view) { h("div", { class: "sub" }, "Signed in as " + (state.user || "admin")))), h("div", { class: "settings" }, section("Console password", "Changing it signs out every other session. The password is stored as an argon2id hash.", form), + section("Use on other devices", "Lets phones, tablets and other computers on this network open LocalLanView. Off by default. Turning it on serves the console over HTTPS on every network interface.", + lanSetting()), section("Export inventory", "Download every known device with its addresses, vendor, type and labels. Generated locally.", h("div", { class: "inline" }, h("a", { class: "btn", href: "/api/export?format=csv", download: true }, icon("download"), "CSV"), @@ -46,3 +50,55 @@ export function render(view) { ), ); } + +const isLocalPage = () => ["localhost", "127.0.0.1", "[::1]"].includes(location.hostname); + +function lanSetting() { + const c = (state.status && state.status.console) || {}; + const box = h("input", { type: "checkbox", id: "lan-on", checked: !!c.lan, "aria-describedby": "lan-hint" }); + const err = h("div", { class: "error", role: "alert" }); + let hint = ""; + if (c.locked) { + box.disabled = true; + hint = c.locked; + } else if (state.generatedPassword && !c.lan) { + box.disabled = true; + hint = "Set your own password above first."; + } + box.addEventListener("change", async () => { + err.textContent = ""; + box.disabled = true; + const on = box.checked; + let r; + try { + r = await api("POST", "/api/console", { lan: on }); + } catch (ex) { + err.textContent = ex.message; + box.checked = !on; + box.disabled = false; + return; + } + if (!on && !isLocalPage()) { + // This device loses access, so there's nowhere to send it. + body.replaceChildren(h("p", {}, "Access from other devices is off. Open ", + h("span", { class: "mono" }, r.url), " on the computer running LocalLanView.")); + return; + } + // The console restarts on its new address; follow it there and sign in again. + toast("Restarting the console at " + r.url + "…"); + setTimeout(() => { location.href = r.url + "#/settings"; }, 1500); + }); + + const body = h("div", {}, + h("label", { class: "check", for: "lan-on" }, box, "Open LocalLanView to devices on this network"), + h("div", { class: "hint", id: "lan-hint" }, hint), + err, + c.lanUrl ? h("div", { class: "lan-url" }, + icon("phone"), + h("div", {}, + h("div", {}, "Go to ", h("a", { class: "mono", href: c.lanUrl }, c.lanUrl), " on any device connected to your network."), + h("div", { class: "hint" }, "Your browser will warn once about the self-signed certificate; that's expected. " + + "If the page doesn't load, allow TCP port " + new URL(c.lanUrl).port + " through this computer's firewall."))) : null, + ); + return body; +} diff --git a/ui/static/js/views/status.js b/ui/static/js/views/status.js index f008d00..05293ac 100644 --- a/ui/static/js/views/status.js +++ b/ui/static/js/views/status.js @@ -64,6 +64,7 @@ export async function render(view) { ["Listening on", mono(s.console.addr)], ["HTTPS", s.console.tls ? h("span", { class: "badge up" }, icon("lock"), "On") : h("span", { class: "badge" }, "Off")], ["Exposure", s.console.loopback ? "This machine only" : h("span", { class: "badge warn" }, "Reachable from the LAN")], + ...(s.console.lanUrl ? [["Other devices", h("a", { class: "mono", href: s.console.lanUrl }, s.console.lanUrl)]] : []), ["Database", mono(s.dbPath)], ["Version", mono(s.version)], ]))), From e567f05a914cfc5d70a096676d59c753c48f7b7b Mon Sep 17 00:00:00 2001 From: jthy10 <43766287+jthy10@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:29:34 -0400 Subject: [PATCH 2/2] Console: link to 127.0.0.1 when listening on every interface Go reports a 0.0.0.0 listener as [::], so the dashboard URL came out as https://[::1]:8787/ with -addr 0.0.0.0 or LAN access on. --- internal/app/app.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 9f15210..0af897f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -404,11 +404,9 @@ func consoleHosts(extra []string, tlsCfg *tls.Config) []string { func consoleURL(a *net.TCPAddr, tls bool) string { host := a.IP if host.IsUnspecified() { - if host.To4() != nil { - host = net.IPv4(127, 0, 0, 1) - } else { - host = net.IPv6loopback - } + // Go reports a 0.0.0.0 listener as [::], and a [::] one takes IPv4 + // too, so 127.0.0.1 reaches either. + host = net.IPv4(127, 0, 0, 1) } scheme := "http" if tls {