@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.
- package/.env.example +34 -0
- package/Dockerfile +53 -0
- package/LICENSE +661 -0
- package/README.md +219 -0
- package/bin/kanshi.js +63 -0
- package/docker-compose.yml +84 -0
- package/go.mod +3 -0
- package/internal/access/access.go +275 -0
- package/internal/access/listeners.go +229 -0
- package/internal/config/config.go +256 -0
- package/internal/config/defaults_other.go +5 -0
- package/internal/config/defaults_windows.go +4 -0
- package/internal/config/envfile.go +147 -0
- package/internal/dockerstats/dial.go +32 -0
- package/internal/dockerstats/dial_other.go +13 -0
- package/internal/dockerstats/dial_windows.go +62 -0
- package/internal/dockerstats/dockerstats.go +626 -0
- package/internal/roots/roots.go +175 -0
- package/internal/roots/roots_linux.go +132 -0
- package/internal/roots/roots_windows.go +74 -0
- package/internal/server/access.go +155 -0
- package/internal/server/server.go +523 -0
- package/internal/server/static.go +165 -0
- package/internal/storage/cache.go +283 -0
- package/internal/storage/dir_linux.go +177 -0
- package/internal/storage/dir_windows.go +244 -0
- package/internal/storage/statat_fast.go +21 -0
- package/internal/storage/statat_other.go +11 -0
- package/internal/storage/storage.go +598 -0
- package/internal/storage/sysnum_amd64.go +7 -0
- package/internal/storage/sysnum_generic.go +7 -0
- package/internal/storage/walk.go +342 -0
- package/internal/vitals/vitals.go +246 -0
- package/internal/vitals/vitals_linux.go +548 -0
- package/internal/vitals/vitals_windows.go +286 -0
- package/main.go +129 -0
- package/main_other.go +11 -0
- package/main_windows.go +29 -0
- package/package.json +49 -0
- package/web/app.js +835 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon.svg +6 -0
- package/web/index.html +151 -0
- package/web/style.css +356 -0
- 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
|
+
[](https://github.com/rene-roid/kanshi/actions/workflows/ci.yml)
|
|
11
|
+
[](https://github.com/rene-roid/kanshi/releases/latest)
|
|
12
|
+
[](#install)
|
|
13
|
+
[](https://github.com/rene-roid/kanshi/pkgs/container/kanshi)
|
|
14
|
+
[](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,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
|
+
}
|