@yuuki824/kanshi 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.env.example +34 -0
  2. package/Dockerfile +53 -0
  3. package/LICENSE +661 -0
  4. package/README.md +219 -0
  5. package/bin/kanshi.js +63 -0
  6. package/docker-compose.yml +84 -0
  7. package/go.mod +3 -0
  8. package/internal/access/access.go +275 -0
  9. package/internal/access/listeners.go +229 -0
  10. package/internal/config/config.go +256 -0
  11. package/internal/config/defaults_other.go +5 -0
  12. package/internal/config/defaults_windows.go +4 -0
  13. package/internal/config/envfile.go +147 -0
  14. package/internal/dockerstats/dial.go +32 -0
  15. package/internal/dockerstats/dial_other.go +13 -0
  16. package/internal/dockerstats/dial_windows.go +62 -0
  17. package/internal/dockerstats/dockerstats.go +626 -0
  18. package/internal/roots/roots.go +175 -0
  19. package/internal/roots/roots_linux.go +132 -0
  20. package/internal/roots/roots_windows.go +74 -0
  21. package/internal/server/access.go +155 -0
  22. package/internal/server/server.go +523 -0
  23. package/internal/server/static.go +165 -0
  24. package/internal/storage/cache.go +283 -0
  25. package/internal/storage/dir_linux.go +177 -0
  26. package/internal/storage/dir_windows.go +244 -0
  27. package/internal/storage/statat_fast.go +21 -0
  28. package/internal/storage/statat_other.go +11 -0
  29. package/internal/storage/storage.go +598 -0
  30. package/internal/storage/sysnum_amd64.go +7 -0
  31. package/internal/storage/sysnum_generic.go +7 -0
  32. package/internal/storage/walk.go +342 -0
  33. package/internal/vitals/vitals.go +246 -0
  34. package/internal/vitals/vitals_linux.go +548 -0
  35. package/internal/vitals/vitals_windows.go +286 -0
  36. package/main.go +129 -0
  37. package/main_other.go +11 -0
  38. package/main_windows.go +29 -0
  39. package/package.json +49 -0
  40. package/web/app.js +835 -0
  41. package/web/apple-touch-icon.png +0 -0
  42. package/web/icon.svg +6 -0
  43. package/web/index.html +151 -0
  44. package/web/style.css +356 -0
  45. package/web/treemap.js +217 -0
package/README.md ADDED
@@ -0,0 +1,219 @@
1
+ <div align="center">
2
+
3
+ # kanshi
4
+
5
+ **A one-page, mobile-first glance at a machine: CPU, memory, a storage map, and every Docker container.**
6
+
7
+ One small program for Linux or Windows. No database, no agents, no history, no alerting —
8
+ open it, see what's going on, close it. When nobody is looking it does nothing at all.
9
+
10
+ [![CI](https://github.com/rene-roid/kanshi/actions/workflows/ci.yml/badge.svg)](https://github.com/rene-roid/kanshi/actions/workflows/ci.yml)
11
+ [![Release](https://img.shields.io/github/v/release/rene-roid/kanshi?sort=semver)](https://github.com/rene-roid/kanshi/releases/latest)
12
+ [![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20Windows%20%7C%20Docker-0b7285)](#install)
13
+ [![Image](https://img.shields.io/badge/image-ghcr.io%2Frene--roid%2Fkanshi-2496ed?logo=docker&logoColor=white)](https://github.com/rene-roid/kanshi/pkgs/container/kanshi)
14
+ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
15
+
16
+ </div>
17
+
18
+ <table>
19
+ <tr>
20
+ <td width="60%">
21
+
22
+ **Vitals & storage** — live CPU/RAM meters and the storage map
23
+ <img src="docs/screenshots/dashboard-vitals.png" alt="Processor, memory and storage map cards" />
24
+
25
+ </td>
26
+ <td width="40%">
27
+
28
+ **Mobile** — the same cards, stacked for a phone
29
+ <img src="docs/screenshots/dashboard-mobile.png" alt="Dashboard on a mobile viewport" />
30
+
31
+ </td>
32
+ </tr>
33
+ </table>
34
+
35
+ ## Features
36
+
37
+ - 📊 **Processor** — overall and per-core utilisation, load average, temperature, network and disk throughput
38
+ - 🧠 **Memory & volumes** — RAM, swap, and how full each drive is
39
+ - 🗚 **Storage map** — a treemap of what is using your disk. Tap to drill in; a table below lists every folder and, on request, the largest files. Pin any folder by path
40
+ - ðŸģ **Containers** — CPU, memory, network and health for every Docker container, with each published port as a link
41
+ - ðŸ–Ĩ **Linux and Windows** — one executable per platform, or a 10 MB Docker image
42
+ - 🔒 **Private by default** — only this computer can open it until you say otherwise; then pick your LAN, your Tailscale network, or both
43
+ - ðŸ˜ī **Idles to zero** — with no browser open it stops reading stats, stops talking to Docker, and stops scanning disks
44
+ - ðŸŠķ **Tiny** — ~8 MB of memory, no dependencies, nothing to install alongside it
45
+
46
+ ## Install
47
+
48
+ Pick one. Every option ends with the dashboard at **<http://localhost:8100>**.
49
+
50
+ ### Windows
51
+
52
+ 1. Download `kanshi-windows-amd64.exe` from the [latest release](https://github.com/rene-roid/kanshi/releases/latest)
53
+ (`kanshi-windows-arm64.exe` on an ARM laptop).
54
+ 2. Double-click it. The dashboard opens in your browser.
55
+
56
+ Windows may say it *protected your PC* because the file is not code-signed: choose **More info → Run anyway**.
57
+ The storage map covers `C:\`, your user folder and every other fixed drive; run it as administrator to include
58
+ folders your account cannot open. Docker Desktop is picked up automatically if it is running.
59
+
60
+ ### Linux
61
+
62
+ ```sh
63
+ curl -fLo kanshi https://github.com/rene-roid/kanshi/releases/latest/download/kanshi-linux-amd64
64
+ chmod +x kanshi
65
+ ./kanshi
66
+ ```
67
+
68
+ Use `kanshi-linux-arm64` on a Raspberry Pi or other 64-bit ARM board. The storage map covers `/` and every
69
+ drive mounted under `/mnt`. To see container stats your user needs to be in the `docker` group.
70
+
71
+ To run it as a service, see [the installation guide](docs/installation.md#linux-as-a-service).
72
+
73
+ ### Docker Compose (recommended on a Docker host)
74
+
75
+ ```sh
76
+ curl -fLO https://raw.githubusercontent.com/rene-roid/kanshi/main/docker-compose.yml
77
+ docker compose up -d
78
+ ```
79
+
80
+ The container reads the host's `/proc`, its Docker socket and its filesystem (all read-only), so what you see is
81
+ the host, not the container. The image is Linux-only by nature; on Windows use the `.exe`.
82
+
83
+ ### npx
84
+
85
+ With Node.js 18+ and Docker installed, this runs the same Compose setup without a checkout:
86
+
87
+ ```sh
88
+ npx @yuuki824/kanshi # start
89
+ npx @yuuki824/kanshi update # pull the newest image and restart
90
+ npx @yuuki824/kanshi down # stop
91
+ ```
92
+
93
+ The [installation guide](docs/installation.md) has the details for each: services, permissions, updating,
94
+ uninstalling and troubleshooting.
95
+
96
+ ## Who can open it
97
+
98
+ Kanshi has no login, so who can reach it is decided entirely by where it listens. **This computer can always
99
+ open it at `localhost`.** On top of that, choose any of:
100
+
101
+ | Access | Who else can open it |
102
+ |---|---|
103
+ | `local` *(default)* | nobody — only this computer |
104
+ | `lan` | phones and computers on the same local network, wired or Wi-Fi |
105
+ | `tailscale` | your devices on your [Tailscale](https://tailscale.com) network, from anywhere |
106
+ | `all` | anyone who can reach any of this machine's addresses, public ones included |
107
+
108
+ Combine them with commas: `lan,tailscale`. There are three ways to set it:
109
+
110
+ - **From the dashboard** — open it on this computer, click **Network** at the bottom, tick what you want, **Save**.
111
+ The choice is kept in `kanshi.env` and applied immediately, no restart. (Opened from any other device, the
112
+ panel is read-only: only the machine itself can widen access.)
113
+ - **A flag** — `kanshi --access lan,tailscale`
114
+ - **The environment or a settings file** — `KANSHI_ACCESS=tailscale` in `.env` (Compose) or `kanshi.env`
115
+
116
+ Kanshi notices network changes on its own: if Tailscale connects after it starts, or the laptop joins a new
117
+ Wi-Fi network, it starts listening there within 30 seconds. At startup it prints every address it can be opened at.
118
+
119
+ > **Prefer `tailscale` over `all`.** `all` puts your container list and disk layout in front of anyone who can
120
+ > reach the port. Only use it behind a firewall, or a reverse proxy that asks for a login.
121
+
122
+ ## Settings
123
+
124
+ Every setting is an environment variable, and can also go in a **`kanshi.env`** file using the same
125
+ `KEY=VALUE` lines ([`.env.example`](.env.example) lists them all). Kanshi reads `kanshi.env` from next to the
126
+ executable, or from your config folder (`%APPDATA%\kanshi\` on Windows, `~/.config/kanshi/` on Linux).
127
+ Precedence is: flag, then environment, then file, then the default.
128
+
129
+ | Variable | Flag | Default | What it does |
130
+ |---|---|---|---|
131
+ | `KANSHI_ACCESS` | `--access` | `local` | Who can open the dashboard ([above](#who-can-open-it)) |
132
+ | `KANSHI_PORT` | `--port` | `8100` | Port to listen on |
133
+ | `KANSHI_CONFIG` | `--config` | *(see above)* | Path of the settings file |
134
+ | | `--open` | on when double-clicked on Windows | Open the dashboard in the browser at startup |
135
+ | `KANSHI_STORAGE_ROOTS` | | `auto` | Folders in the storage map: `auto`, `path`, or `label=path`, comma-separated. `auto,/srv` adds to the defaults |
136
+ | `KANSHI_STORAGE_EXCLUDE` | | *(none)* | Folders to skip entirely, e.g. `/var/lib/docker` |
137
+ | `KANSHI_STORAGE_INTERVAL` | | `21600` | Seconds a folder's size is trusted before opening the folder above it measures it again |
138
+ | `KANSHI_STORAGE_CPU` | | `25` | Share of one core, in %, measuring folders may use. `100` = unthrottled |
139
+ | `KANSHI_TREE_DEPTH` | | `4` | Folder levels remembered from each measurement; deeper folders are measured when you open them |
140
+ | `KANSHI_STORAGE_CACHE` | | `~/.cache/kanshi/storage.cache`, `%LocalAppData%\kanshi\storage.cache`; `/data/storage.cache` in the image | Where folder sizes are cached, so a restart does not measure them again |
141
+ | `KANSHI_POLL_INTERVAL` | | `5` | Seconds between live updates |
142
+ | `KANSHI_IDLE_TIMEOUT` | | `30` | Seconds with no browser before everything stops |
143
+ | `KANSHI_DOCKER_CONCURRENCY` | | `8` | Parallel container stat requests |
144
+ | `DOCKER_HOST` | | platform default | Docker endpoint: `unix://â€Ķ`, `npipe://â€Ķ` or `tcp://â€Ķ` |
145
+ | `KANSHI_HOST_ROOT` | | *(none; `/hostfs` in the image)* | Where the host's `/` is mounted when running in a container |
146
+ | `KANSHI_WEB_DIR` | | *(embedded)* | Serve the frontend from disk, for frontend development |
147
+
148
+ `kanshi --help` lists the flags; `kanshi --version` prints the version.
149
+
150
+ ### What the storage map covers
151
+
152
+ With `KANSHI_STORAGE_ROOTS=auto`:
153
+
154
+ - **Linux:** `/`, plus every real filesystem mounted at or under `/mnt` (`/mnt/data`, `/mnt/usb`, a NAS shareâ€Ķ).
155
+ Drives mounted later appear on their own.
156
+ - **Windows:** the system drive (`C:\`), your user folder, and every other fixed drive (`D:\`, `E:\`â€Ķ).
157
+ Your user folder is inside `C:\`, and both come out of a single pass over the drive.
158
+
159
+ Nothing is scanned up front. Opening a folder lists it on the spot, and each subfolder's size comes from a small
160
+ cache file; the ones it has never seen, or not for six hours, are measured in the background, at the lowest CPU
161
+ and disk priority and throttled to a quarter of one core. The first visit to `/` still measures most of the disk
162
+ once. After that, opening a folder is instant, restarts included, and only folders someone opens are measured
163
+ again. **Rescan** re-measures the folders on screen.
164
+
165
+ ## Updating
166
+
167
+ - **Windows / Linux binary:** download the new file from [Releases](https://github.com/rene-roid/kanshi/releases) and replace the old one.
168
+ - **Compose:** `docker compose pull && docker compose up -d`
169
+ - **npx:** `npx @yuuki824/kanshi update`
170
+
171
+ ## Building from source
172
+
173
+ Go 1.25 or newer is the only requirement: there are no third-party modules.
174
+
175
+ ```sh
176
+ go build . # the binary for this machine
177
+ go test ./... # the tests
178
+ GOOS=windows go build -o kanshi.exe . # cross-compile for Windows from anywhere
179
+ docker compose up -d --build # build the image from this checkout
180
+ ```
181
+
182
+ The frontend is plain HTML, CSS and JavaScript with no build step, embedded into the binary at compile time.
183
+ Set `KANSHI_WEB_DIR=./web` to serve it from disk while editing.
184
+
185
+ ## Releasing
186
+
187
+ Releases are made by the **Release** workflow, run by hand: *Actions → Release → Run workflow*, enter a version
188
+ such as `v0.2.0`. It runs the full test suite on Linux and Windows, builds the Windows and Linux executables
189
+ (x64 and ARM64), pushes a multi-arch image to `ghcr.io/rene-roid/kanshi`, and publishes a GitHub release with
190
+ the executables, checksums and install notes. After the very first release, set the package's visibility to
191
+ **Public** under the repository's *Packages* settings so `docker pull` works without logging in.
192
+
193
+ ## API
194
+
195
+ The page holds one Server-Sent Events connection, `/api/stream`, and the server pushes every update over it —
196
+ nothing polls. The stream is gzip-compressed for browsers that accept it: on a host with 30 containers each
197
+ update is ~16 KB of JSON but about 2 KB on the wire.
198
+
199
+ | Endpoint | What it returns |
200
+ |---|---|
201
+ | `GET /api/stream` | Live updates: vitals, containers and which folder is being measured |
202
+ | `GET /api/vitals` | CPU, memory, swap, network, disk and volume figures |
203
+ | `GET /api/containers` | Per-container stats |
204
+ | `GET /api/storage` | One summary line per storage root |
205
+ | `GET /api/storage/dir?root=0&path=home/you` | One folder, listed live; subfolders not measured yet come back `pending` and are queued |
206
+ | `POST /api/storage/rescan?root=0&path=home/you` | Re-measures that folder's subfolders; needs the `X-Kanshi: 1` header |
207
+ | `GET /api/access`, `POST /api/access` | The access mode; changing it only works from this computer |
208
+ | `GET /api/config` | Version, OS and intervals |
209
+ | `GET /healthz` | `{"ok":true}` |
210
+
211
+ ## How it works
212
+
213
+ [docs/design.md](docs/design.md) explains the choices behind it: why it stops when nobody is looking, how the disk
214
+ scan stays fast and polite on both platforms, why container stats use Docker's one-shot mode, and what the
215
+ memory number in `docker stats` actually means.
216
+
217
+ ## License
218
+
219
+ [AGPL-3.0](LICENSE) ÂĐ rene-roid
package/bin/kanshi.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { existsSync } = require('node:fs');
4
+ const { spawnSync } = require('node:child_process');
5
+ const { join } = require('node:path');
6
+
7
+ const packageRoot = join(__dirname, '..');
8
+ const composeFile = join(packageRoot, 'docker-compose.yml');
9
+ const callerDirectory = process.cwd();
10
+ const args = process.argv.slice(2);
11
+
12
+ if (args.includes('--help') || args.includes('-h')) {
13
+ console.log(`Usage: npx @yuuki824/kanshi [update | docker-compose arguments]
14
+
15
+ Starts Kanshi with Docker Compose, using the published image
16
+ (ghcr.io/rene-roid/kanshi). A .env file in the current directory is used when
17
+ present. Common commands:
18
+ npx @yuuki824/kanshi start it (pulls the image the first time)
19
+ npx @yuuki824/kanshi update pull the newest image and restart
20
+ npx @yuuki824/kanshi logs -f
21
+ npx @yuuki824/kanshi down
22
+ npx @yuuki824/kanshi up -d --build build from source instead of pulling
23
+
24
+ Docker and the Docker Compose v2 plugin are required.`);
25
+ process.exit(0);
26
+ }
27
+
28
+ const dockerCheck = spawnSync('docker', ['compose', 'version'], { stdio: 'ignore' });
29
+ if (dockerCheck.error || dockerCheck.status !== 0) {
30
+ console.error('Kanshi requires Docker with the Docker Compose v2 plugin.');
31
+ console.error('Install Docker first, then run this command again.');
32
+ process.exit(1);
33
+ }
34
+
35
+ const composeArgs = [
36
+ 'compose',
37
+ '--project-directory', packageRoot,
38
+ '--project-name', 'kanshi',
39
+ '--file', composeFile,
40
+ ];
41
+
42
+ const envFile = join(callerDirectory, '.env');
43
+ if (callerDirectory !== packageRoot && existsSync(envFile)) {
44
+ composeArgs.push('--env-file', envFile);
45
+ }
46
+
47
+ function compose(extra) {
48
+ const result = spawnSync('docker', composeArgs.concat(extra), { stdio: 'inherit' });
49
+ if (result.error) {
50
+ console.error(`Could not start Docker: ${result.error.message}`);
51
+ process.exit(1);
52
+ }
53
+ return result.status ?? 1;
54
+ }
55
+
56
+ if (args.length === 0) {
57
+ process.exit(compose(['up', '--detach']));
58
+ }
59
+ if (args.length === 1 && args[0] === 'update') {
60
+ const pulled = compose(['pull']);
61
+ process.exit(pulled === 0 ? compose(['up', '--detach']) : pulled);
62
+ }
63
+ process.exit(compose(args));
@@ -0,0 +1,84 @@
1
+ services:
2
+ kanshi:
3
+ # Pulls the published image. `docker compose up -d --build` builds it from
4
+ # this checkout instead.
5
+ image: ghcr.io/rene-roid/kanshi:${KANSHI_VERSION:-latest}
6
+ build: .
7
+ container_name: kanshi
8
+ restart: unless-stopped
9
+
10
+ # Host networking for two reasons: /proc/net/dev is namespaced, so it is the
11
+ # only way to report real host network throughput, and it lets Kanshi listen
12
+ # on the host's own LAN and Tailscale addresses.
13
+ network_mode: host
14
+
15
+ environment:
16
+ # Who can open the dashboard. localhost always works; add lan, tailscale
17
+ # (comma-separated), or all. Empty means local only.
18
+ KANSHI_ACCESS: "${KANSHI_ACCESS:-}"
19
+ # The older way to say the same thing, still honoured if set.
20
+ KANSHI_HOST: "${KANSHI_HOST:-}"
21
+ KANSHI_PORT: "${KANSHI_PORT:-8100}"
22
+
23
+ # Live poll cadence. Each tick is one /containers/json plus one /stats per
24
+ # running container, so keep this conservative.
25
+ KANSHI_POLL_INTERVAL: "${KANSHI_POLL_INTERVAL:-5}"
26
+ KANSHI_DOCKER_CONCURRENCY: "${KANSHI_DOCKER_CONCURRENCY:-8}"
27
+ # Polling stops entirely once no browser has been connected this long.
28
+ KANSHI_IDLE_TIMEOUT: "${KANSHI_IDLE_TIMEOUT:-30}"
29
+
30
+ # Storage map. "auto" is the host's / plus every drive mounted under /mnt.
31
+ KANSHI_STORAGE_ROOTS: "${KANSHI_STORAGE_ROOTS:-auto}"
32
+ # A cached folder size older than this many seconds is measured again
33
+ # the next time the folder around it is opened.
34
+ KANSHI_STORAGE_INTERVAL: "${KANSHI_STORAGE_INTERVAL:-21600}"
35
+ # Host paths to skip. /var/lib/docker is ~100k overlay2 files and
36
+ # dominates the walk time — set it here to trim the walk.
37
+ KANSHI_STORAGE_EXCLUDE: "${KANSHI_STORAGE_EXCLUDE:-}"
38
+ # Share of one core, in percent, the walk may average. 100 = unthrottled.
39
+ KANSHI_STORAGE_CPU: "${KANSHI_STORAGE_CPU:-25}"
40
+ KANSHI_TREE_DEPTH: "${KANSHI_TREE_DEPTH:-4}"
41
+
42
+ # Give the GC a budget below mem_limit so a walk on an unusually large
43
+ # filesystem degrades into more collection rather than an OOM kill.
44
+ GOMEMLIMIT: "80MiB"
45
+ # Two Ps is plenty for a 5s poll loop and one walk, and it keeps the Go
46
+ # scheduler from spreading across all four cores of a busy box.
47
+ GOMAXPROCS: "2"
48
+
49
+ volumes:
50
+ # Read-only on the socket is a mount flag, not an API restriction — it is
51
+ # the access mode above that keeps this out of reach.
52
+ - /var/run/docker.sock:/var/run/docker.sock:ro
53
+ # The whole host, read-only. rslave lets a drive mounted under /mnt after
54
+ # the container started show up here too, without a restart.
55
+ - /:/hostfs:ro,rslave
56
+ # The folder size cache, so a restart or an update does not walk the
57
+ # disk again.
58
+ - kanshi-data:/data
59
+
60
+ read_only: true
61
+ tmpfs:
62
+ - /tmp:size=16m
63
+ cap_drop: [ALL]
64
+ # Read/traverse bypass only — it does NOT grant write bypass (that would be
65
+ # DAC_OVERRIDE). Without it the walk cannot enter home directories (0750)
66
+ # and silently under-reports the root filesystem.
67
+ cap_add: [DAC_READ_SEARCH]
68
+ security_opt:
69
+ - no-new-privileges:true
70
+
71
+ # Stays out of the way of everything else on a small box. Steady state is
72
+ # ~0.03% of one core; the ceiling is headroom for the walk.
73
+ cpus: "1.0"
74
+ # Measured peak during a full walk of a 2 TB host is ~20 MiB of heap, and
75
+ # ~8 MiB at rest. The rest of this is reclaimable page cache the walk pulls
76
+ # in, which the kernel drops again the moment anything else wants it.
77
+ mem_limit: 96m
78
+
79
+ logging:
80
+ driver: json-file
81
+ options: { max-size: "10m", max-file: "3" }
82
+
83
+ volumes:
84
+ kanshi-data:
package/go.mod ADDED
@@ -0,0 +1,3 @@
1
+ module github.com/rene-roid/kanshi
2
+
3
+ go 1.25
@@ -0,0 +1,275 @@
1
+ // Package access decides which addresses the dashboard listens on.
2
+ //
3
+ // Kanshi has no login, so who can reach it is decided entirely by where it
4
+ // listens. Loopback is always on: whatever else is chosen, the machine itself
5
+ // can open http://localhost:<port>. On top of that the mode can add the
6
+ // machine's Tailscale addresses, its private LAN addresses (wired and Wi-Fi),
7
+ // explicit addresses, or every interface at once.
8
+ package access
9
+
10
+ import (
11
+ "fmt"
12
+ "net"
13
+ "net/netip"
14
+ "sort"
15
+ "strings"
16
+ )
17
+
18
+ // Mode is a parsed access setting. The zero value is "local".
19
+ type Mode struct {
20
+ LAN bool
21
+ Tailscale bool
22
+ All bool
23
+ IPs []netip.Addr
24
+ }
25
+
26
+ // Parse reads a comma-separated list such as "lan,tailscale". Explicit IP
27
+ // addresses may be mixed in. An empty string is "local".
28
+ func Parse(spec string) (Mode, error) {
29
+ var m Mode
30
+ for _, tok := range strings.Split(spec, ",") {
31
+ tok = strings.ToLower(strings.TrimSpace(tok))
32
+ switch tok {
33
+ case "", "local", "localhost", "loopback":
34
+ case "lan", "wlan", "wifi", "network":
35
+ m.LAN = true
36
+ case "tailscale", "ts", "tailnet":
37
+ m.Tailscale = true
38
+ case "all", "any", "everywhere", "*", "0.0.0.0", "::", "[::]":
39
+ m.All = true
40
+ default:
41
+ ip, err := netip.ParseAddr(strings.Trim(tok, "[]"))
42
+ if err != nil {
43
+ return Mode{}, fmt.Errorf("unknown access mode %q (want local, lan, tailscale, all or an IP address)", tok)
44
+ }
45
+ ip = ip.Unmap()
46
+ if ip.IsUnspecified() {
47
+ m.All = true
48
+ } else if !ip.IsLoopback() && !contains(m.IPs, ip) {
49
+ m.IPs = append(m.IPs, ip)
50
+ }
51
+ }
52
+ }
53
+ return m, nil
54
+ }
55
+
56
+ // String is the canonical spelling, which Parse reads back unchanged.
57
+ func (m Mode) String() string {
58
+ if m.All {
59
+ return "all"
60
+ }
61
+ var parts []string
62
+ if m.LAN {
63
+ parts = append(parts, "lan")
64
+ }
65
+ if m.Tailscale {
66
+ parts = append(parts, "tailscale")
67
+ }
68
+ for _, ip := range m.IPs {
69
+ parts = append(parts, ip.String())
70
+ }
71
+ if len(parts) == 0 {
72
+ return "local"
73
+ }
74
+ return strings.Join(parts, ",")
75
+ }
76
+
77
+ // dynamic reports whether the set of addresses depends on the machine's
78
+ // interfaces, and so has to be re-checked as they come and go.
79
+ func (m Mode) dynamic() bool {
80
+ return !m.All && (m.LAN || m.Tailscale || len(m.IPs) > 0)
81
+ }
82
+
83
+ // Kind labels an address for the startup banner and the dashboard footer.
84
+ type Kind string
85
+
86
+ const (
87
+ KindLocal Kind = "local"
88
+ KindLAN Kind = "lan"
89
+ KindTailscale Kind = "tailscale"
90
+ KindAll Kind = "all"
91
+ KindCustom Kind = "custom"
92
+ KindOther Kind = "other"
93
+ )
94
+
95
+ // Endpoint is one address to listen on.
96
+ type Endpoint struct {
97
+ Addr netip.Addr
98
+ Kind Kind
99
+ }
100
+
101
+ // Interface is the part of a network interface that classification needs.
102
+ type Interface struct {
103
+ Name string
104
+ Up bool
105
+ Loopback bool
106
+ Addrs []netip.Addr
107
+ }
108
+
109
+ var (
110
+ tailnet4 = netip.MustParsePrefix("100.64.0.0/10")
111
+ tailnet6 = netip.MustParsePrefix("fd7a:115c:a1e0::/48")
112
+ loop4 = netip.AddrFrom4([4]byte{127, 0, 0, 1})
113
+ loop6 = netip.IPv6Loopback()
114
+ )
115
+
116
+ // IsTailscale reports whether an address is in the ranges Tailscale assigns.
117
+ // It is decided by address rather than interface name, because the adapter is
118
+ // "tailscale0" on Linux and "Tailscale" on Windows.
119
+ func IsTailscale(a netip.Addr) bool {
120
+ return tailnet4.Contains(a) || tailnet6.Contains(a)
121
+ }
122
+
123
+ // isLAN is a private address that another machine on the local network could
124
+ // actually connect to: RFC 1918 for IPv4 and unique-local for IPv6.
125
+ // Link-local addresses are left out because they are unusable without a zone,
126
+ // and public addresses because they are not "the LAN".
127
+ func isLAN(a netip.Addr) bool {
128
+ return a.IsPrivate() && !IsTailscale(a)
129
+ }
130
+
131
+ // isVirtual matches interfaces that carry private addresses but are not the
132
+ // LAN: Docker and libvirt bridges, container veths, VPN tunnels, and the
133
+ // Hyper-V switches Windows creates for WSL and Docker Desktop. Listening there
134
+ // would only expose the dashboard to containers and VMs.
135
+ func isVirtual(name string) bool {
136
+ n := strings.ToLower(name)
137
+ for _, p := range []string{"docker", "veth", "virbr", "vnet", "cni", "flannel", "cali", "kube", "podman", "lxc", "lxd", "tailscale", "tun", "tap", "wg", "zt", "vethernet", "vmnet"} {
138
+ if strings.HasPrefix(n, p) {
139
+ return true
140
+ }
141
+ }
142
+ // Compose networks are "br-" plus 12 hex digits. A plain "br0" is more
143
+ // likely a real LAN bridge, so it is left alone.
144
+ if strings.HasPrefix(n, "br-") && len(n) == 15 {
145
+ return true
146
+ }
147
+ for _, s := range []string{"virtualbox", "vmware", "hyper-v", "wsl", "loopback pseudo"} {
148
+ if strings.Contains(n, s) {
149
+ return true
150
+ }
151
+ }
152
+ return false
153
+ }
154
+
155
+ // classify says what kind of address a is on the named interface, or false
156
+ // when it is neither loopback, LAN nor Tailscale.
157
+ func classify(iface Interface, a netip.Addr) (Kind, bool) {
158
+ switch {
159
+ case a.IsLoopback():
160
+ return KindLocal, true
161
+ case IsTailscale(a):
162
+ return KindTailscale, true
163
+ case isVirtual(iface.Name) || a.IsLinkLocalUnicast():
164
+ return "", false
165
+ case isLAN(a):
166
+ return KindLAN, true
167
+ }
168
+ return KindOther, true
169
+ }
170
+
171
+ // Endpoints lists the addresses a mode listens on, given the current
172
+ // interfaces.
173
+ func (m Mode) Endpoints(ifaces []Interface) []Endpoint {
174
+ if m.All {
175
+ // The wildcards already cover loopback. Binding 127.0.0.1 as well
176
+ // would collide with 0.0.0.0 on the same port.
177
+ return []Endpoint{
178
+ {netip.IPv4Unspecified(), KindAll},
179
+ {netip.IPv6Unspecified(), KindAll},
180
+ }
181
+ }
182
+ out := []Endpoint{{loop4, KindLocal}, {loop6, KindLocal}}
183
+ have := map[netip.Addr]bool{loop4: true, loop6: true}
184
+ add := func(a netip.Addr, k Kind) {
185
+ if !have[a] {
186
+ have[a] = true
187
+ out = append(out, Endpoint{a, k})
188
+ }
189
+ }
190
+ for _, iface := range ifaces {
191
+ if !iface.Up || iface.Loopback {
192
+ continue
193
+ }
194
+ for _, a := range iface.Addrs {
195
+ switch k, ok := classify(iface, a); {
196
+ case !ok:
197
+ case k == KindTailscale && m.Tailscale, k == KindLAN && m.LAN:
198
+ add(a, k)
199
+ }
200
+ }
201
+ }
202
+ for _, ip := range m.IPs {
203
+ add(ip, KindCustom)
204
+ }
205
+ return out
206
+ }
207
+
208
+ // Reachable lists the addresses a wildcard listener can be reached on, for
209
+ // display. Loopback comes first, then Tailscale, then LAN, then anything else.
210
+ func Reachable(ifaces []Interface) []Endpoint {
211
+ out := []Endpoint{{loop4, KindLocal}}
212
+ for _, iface := range ifaces {
213
+ if !iface.Up || iface.Loopback {
214
+ continue
215
+ }
216
+ for _, a := range iface.Addrs {
217
+ if k, ok := classify(iface, a); ok && k != KindLocal {
218
+ out = append(out, Endpoint{a, k})
219
+ }
220
+ }
221
+ }
222
+ rank := map[Kind]int{KindLocal: 0, KindTailscale: 1, KindLAN: 2, KindCustom: 3, KindOther: 4}
223
+ sort.SliceStable(out, func(i, j int) bool { return rank[out[i].Kind] < rank[out[j].Kind] })
224
+ return out
225
+ }
226
+
227
+ // SystemInterfaces reads the machine's interfaces. Virtual ones are dropped
228
+ // before their addresses are fetched: a Docker host can have dozens of veths,
229
+ // and each Addrs call is a separate netlink round trip.
230
+ func SystemInterfaces() []Interface {
231
+ ifaces, err := net.Interfaces()
232
+ if err != nil {
233
+ return nil
234
+ }
235
+ out := make([]Interface, 0, len(ifaces))
236
+ for _, ni := range ifaces {
237
+ iface := Interface{
238
+ Name: ni.Name,
239
+ Up: ni.Flags&net.FlagUp != 0,
240
+ Loopback: ni.Flags&net.FlagLoopback != 0,
241
+ }
242
+ if !iface.Up || iface.Loopback {
243
+ continue
244
+ }
245
+ // Tailscale's own adapter matches isVirtual, so it is kept by name.
246
+ lower := strings.ToLower(ni.Name)
247
+ if isVirtual(ni.Name) && !strings.Contains(lower, "tailscale") && !strings.HasPrefix(lower, "utun") {
248
+ continue
249
+ }
250
+ addrs, err := ni.Addrs()
251
+ if err != nil {
252
+ continue
253
+ }
254
+ for _, a := range addrs {
255
+ ipnet, ok := a.(*net.IPNet)
256
+ if !ok {
257
+ continue
258
+ }
259
+ if ip, ok := netip.AddrFromSlice(ipnet.IP); ok {
260
+ iface.Addrs = append(iface.Addrs, ip.Unmap())
261
+ }
262
+ }
263
+ out = append(out, iface)
264
+ }
265
+ return out
266
+ }
267
+
268
+ func contains(list []netip.Addr, a netip.Addr) bool {
269
+ for _, x := range list {
270
+ if x == a {
271
+ return true
272
+ }
273
+ }
274
+ return false
275
+ }