@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
@@ -0,0 +1,229 @@
1
+ package access
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net"
8
+ "net/http"
9
+ "net/netip"
10
+ "sort"
11
+ "strconv"
12
+ "sync"
13
+ "time"
14
+ )
15
+
16
+ // How often a mode that depends on interfaces is re-checked. Tailscale coming
17
+ // up after Kanshi, a laptop joining Wi-Fi, a DHCP lease changing: all of them
18
+ // are picked up within this long, without a restart.
19
+ const rescanEvery = 30 * time.Second
20
+
21
+ // Manager keeps one listener per address the current mode wants, all feeding
22
+ // the same http.Server.
23
+ type Manager struct {
24
+ port int
25
+ server *http.Server
26
+ logf func(format string, args ...any)
27
+ // Interfaces is swapped out by tests.
28
+ Interfaces func() []Interface
29
+
30
+ mu sync.Mutex
31
+ mode Mode
32
+ active map[netip.Addr]*listener
33
+ failures map[netip.Addr]string // last bind error per address, so it is logged once
34
+ }
35
+
36
+ type listener struct {
37
+ ep Endpoint
38
+ ln net.Listener
39
+ }
40
+
41
+ func NewManager(port int, server *http.Server, logf func(string, ...any)) *Manager {
42
+ return &Manager{
43
+ port: port,
44
+ server: server,
45
+ logf: logf,
46
+ Interfaces: SystemInterfaces,
47
+ active: make(map[netip.Addr]*listener),
48
+ failures: make(map[netip.Addr]string),
49
+ }
50
+ }
51
+
52
+ // ErrLoopback means the one listener that must always work could not be
53
+ // opened — almost always because something else already has the port.
54
+ var ErrLoopback = errors.New("cannot listen on localhost")
55
+
56
+ // Apply switches to a new mode, opening and closing listeners as needed. It
57
+ // only fails when 127.0.0.1 cannot be bound; any other address that will not
58
+ // bind is logged and retried on the next interface check.
59
+ func (m *Manager) Apply(mode Mode) error {
60
+ m.mu.Lock()
61
+ defer m.mu.Unlock()
62
+ m.mode = mode
63
+ return m.reconcile()
64
+ }
65
+
66
+ func (m *Manager) Mode() Mode {
67
+ m.mu.Lock()
68
+ defer m.mu.Unlock()
69
+ return m.mode
70
+ }
71
+
72
+ func (m *Manager) reconcile() error {
73
+ var ifaces []Interface
74
+ if m.mode.dynamic() {
75
+ ifaces = m.Interfaces()
76
+ }
77
+ want := m.mode.Endpoints(ifaces)
78
+ wanted := make(map[netip.Addr]Endpoint, len(want))
79
+ for _, ep := range want {
80
+ wanted[ep.Addr] = ep
81
+ }
82
+
83
+ // Close first: switching to "all" has to release 127.0.0.1 before
84
+ // 0.0.0.0 can take the same port, and the reverse going back.
85
+ for addr, l := range m.active {
86
+ if _, ok := wanted[addr]; !ok {
87
+ l.ln.Close()
88
+ delete(m.active, addr)
89
+ if l.ep.Kind != KindLocal {
90
+ m.logf("stopped listening on %s", URL(addr, m.port))
91
+ }
92
+ }
93
+ }
94
+ for addr := range m.failures {
95
+ if _, ok := wanted[addr]; !ok {
96
+ delete(m.failures, addr)
97
+ }
98
+ }
99
+
100
+ var loopErr error
101
+ for _, ep := range want {
102
+ if _, ok := m.active[ep.Addr]; ok {
103
+ continue
104
+ }
105
+ ln, err := listen(ep.Addr, m.port)
106
+ if err != nil {
107
+ if ep.Addr == loop4 || (ep.Kind == KindAll && ep.Addr.Is4()) {
108
+ loopErr = fmt.Errorf("%w on port %d: %v", ErrLoopback, m.port, err)
109
+ continue
110
+ }
111
+ // IPv6 loopback and "::" are simply absent on hosts with IPv6
112
+ // turned off. That is not worth a warning, or a retry.
113
+ if ep.Addr == loop6 || ep.Addr == netip.IPv6Unspecified() {
114
+ continue
115
+ }
116
+ if m.failures[ep.Addr] != err.Error() {
117
+ m.logf("warning: cannot listen on %s yet (%v); will keep trying", URL(ep.Addr, m.port), err)
118
+ }
119
+ m.failures[ep.Addr] = err.Error()
120
+ continue
121
+ }
122
+ if _, failed := m.failures[ep.Addr]; failed {
123
+ m.logf("now listening on %s", URL(ep.Addr, m.port))
124
+ delete(m.failures, ep.Addr)
125
+ }
126
+ m.active[ep.Addr] = &listener{ep: ep, ln: ln}
127
+ go func() {
128
+ // Serve returns once the listener is closed, either here or by
129
+ // Shutdown. Neither is an error worth reporting.
130
+ _ = m.server.Serve(ln)
131
+ }()
132
+ }
133
+ return loopErr
134
+ }
135
+
136
+ func listen(addr netip.Addr, port int) (net.Listener, error) {
137
+ network := "tcp4"
138
+ if addr.Is6() {
139
+ // tcp6 sets IPV6_V6ONLY, so "::" does not also try to take 0.0.0.0.
140
+ network = "tcp6"
141
+ }
142
+ return net.Listen(network, net.JoinHostPort(addr.String(), strconv.Itoa(port)))
143
+ }
144
+
145
+ // Watch re-applies the current mode as interfaces come and go, and retries
146
+ // addresses that failed to bind. It returns when ctx is cancelled.
147
+ func (m *Manager) Watch(ctx context.Context) {
148
+ t := time.NewTicker(rescanEvery)
149
+ defer t.Stop()
150
+ for {
151
+ select {
152
+ case <-ctx.Done():
153
+ return
154
+ case <-t.C:
155
+ }
156
+ m.mu.Lock()
157
+ if m.mode.dynamic() || len(m.failures) > 0 {
158
+ _ = m.reconcile()
159
+ }
160
+ m.mu.Unlock()
161
+ }
162
+ }
163
+
164
+ // Close stops every listener.
165
+ func (m *Manager) Close() {
166
+ m.mu.Lock()
167
+ defer m.mu.Unlock()
168
+ for addr, l := range m.active {
169
+ l.ln.Close()
170
+ delete(m.active, addr)
171
+ }
172
+ }
173
+
174
+ // Link is one address the dashboard can be opened at.
175
+ type Link struct {
176
+ Kind Kind `json:"kind"`
177
+ URL string `json:"url"`
178
+ }
179
+
180
+ // Links lists where the dashboard is reachable right now. Loopback collapses
181
+ // into one localhost link, and a wildcard listener expands into the machine's
182
+ // actual addresses, since "0.0.0.0" is not something you can open.
183
+ func (m *Manager) Links() []Link {
184
+ m.mu.Lock()
185
+ var eps []Endpoint
186
+ wildcard := false
187
+ for _, l := range m.active {
188
+ if l.ep.Kind == KindAll {
189
+ wildcard = true
190
+ continue
191
+ }
192
+ eps = append(eps, l.ep)
193
+ }
194
+ m.mu.Unlock()
195
+
196
+ if wildcard {
197
+ eps = Reachable(m.Interfaces())
198
+ }
199
+ rank := map[Kind]int{KindLocal: 0, KindTailscale: 1, KindLAN: 2, KindCustom: 3, KindOther: 4}
200
+ sort.SliceStable(eps, func(i, j int) bool {
201
+ a, b := eps[i], eps[j]
202
+ if rank[a.Kind] != rank[b.Kind] {
203
+ return rank[a.Kind] < rank[b.Kind]
204
+ }
205
+ if a.Addr.Is4() != b.Addr.Is4() {
206
+ return a.Addr.Is4() // IPv4 first: shorter, and what people type
207
+ }
208
+ return a.Addr.Less(b.Addr)
209
+ })
210
+
211
+ out := []Link{}
212
+ local := false
213
+ for _, ep := range eps {
214
+ if ep.Kind == KindLocal {
215
+ if !local {
216
+ out = append(out, Link{KindLocal, "http://localhost:" + strconv.Itoa(m.port)})
217
+ local = true
218
+ }
219
+ continue
220
+ }
221
+ out = append(out, Link{ep.Kind, URL(ep.Addr, m.port)})
222
+ }
223
+ return out
224
+ }
225
+
226
+ // URL formats an address the way a browser wants it.
227
+ func URL(addr netip.Addr, port int) string {
228
+ return "http://" + net.JoinHostPort(addr.String(), strconv.Itoa(port))
229
+ }
@@ -0,0 +1,256 @@
1
+ // Package config holds runtime configuration.
2
+ //
3
+ // Every setting is an environment variable. The same KEY=VALUE pairs can also
4
+ // live in an optional kanshi.env file, which is what makes a double-clicked
5
+ // Windows executable configurable at all. A handful of settings also have a
6
+ // command-line flag. Precedence is flag, then environment, then file, then the
7
+ // built-in default.
8
+ //
9
+ // Defaults are deliberately conservative: the box this was written for has 4
10
+ // cores and ~28 other containers, so Kanshi should be invisible in `docker stats`.
11
+ package config
12
+
13
+ import (
14
+ "os"
15
+ "path/filepath"
16
+ "strconv"
17
+ "strings"
18
+ "time"
19
+ )
20
+
21
+ // Source says where a setting's value came from. The dashboard only lets you
22
+ // change the access mode when it is not pinned by a flag or the environment,
23
+ // because a change there would be silently overridden on the next start.
24
+ type Source string
25
+
26
+ const (
27
+ SourceDefault Source = "default"
28
+ SourceFile Source = "file"
29
+ SourceEnv Source = "env"
30
+ SourceFlag Source = "flag"
31
+ )
32
+
33
+ // Config is read once at startup. Only the access mode changes afterwards, and
34
+ // that is owned by the server, not this struct.
35
+ type Config struct {
36
+ // How often the live poller samples vitals + container stats.
37
+ PollInterval time.Duration
38
+ // Stop polling entirely once no browser has been connected for this long.
39
+ // Nobody is looking, so there is no reason to keep waking the Docker daemon.
40
+ IdleTimeout time.Duration
41
+
42
+ // Max concurrent /stats requests against the Docker daemon per tick.
43
+ DockerConcurrency int
44
+ // A DOCKER_HOST-style address: unix://, npipe:// or tcp://.
45
+ DockerHost string
46
+
47
+ // Storage map. Entries are "auto", "label=path" or just "path".
48
+ StorageRoots []string
49
+ // A cached folder size older than this is walked again the next time
50
+ // its parent is opened.
51
+ StorageInterval time.Duration
52
+ // Paths to skip entirely. Their bytes vanish from the totals, so only
53
+ // exclude things you truly don't want counted.
54
+ StorageExclude []string
55
+ // Rescan skips folders sized more recently than this.
56
+ StorageMinRescan time.Duration
57
+ // Share of one core, in percent, the walk may average. It sleeps between
58
+ // directories to stay under it. 0 or 100+ means unthrottled.
59
+ StorageCPU float64
60
+
61
+ // How many levels below a walked folder are cached from the same walk.
62
+ // Anything deeper is walked when it is opened.
63
+ TreeDepth int
64
+ // The file folder sizes are cached in. Empty keeps them in memory only,
65
+ // to be walked again after a restart.
66
+ StorageCache string
67
+
68
+ // Where the host's root filesystem is mounted when Kanshi runs in a
69
+ // container. Empty on a bare-metal install.
70
+ HostRoot string
71
+
72
+ // Who can reach the dashboard: "local", "lan", "tailscale", "all", or a
73
+ // comma-separated mix, plus explicit addresses. Parsed by package access.
74
+ Access string
75
+ AccessSource Source
76
+ Port int
77
+
78
+ // The kanshi.env that was loaded, or where one would be written if the
79
+ // access mode is changed from the dashboard. Empty when there is nowhere
80
+ // sensible to write, such as a read-only container.
81
+ File string
82
+ FileLoaded bool
83
+
84
+ // Serve web assets from this directory instead of the ones baked into the
85
+ // binary. Only useful when iterating on the frontend.
86
+ WebDir string
87
+ }
88
+
89
+ // Flags are the command-line overrides. A zero value means "not given".
90
+ type Flags struct {
91
+ Access string
92
+ Port int
93
+ File string
94
+ }
95
+
96
+ // Load reads flags, environment and kanshi.env. Unparseable values fall back
97
+ // to the default rather than refusing to start — a typo in one knob should not
98
+ // take the dashboard down.
99
+ func Load(flags Flags) Config {
100
+ path, loaded := findFile(flags.File)
101
+ var file map[string]string
102
+ if loaded {
103
+ file, _ = ReadFile(path)
104
+ }
105
+ l := lookup{file: file}
106
+
107
+ access, accessSrc := l.access(flags.Access)
108
+ port := l.int("KANSHI_PORT", 8100)
109
+ if flags.Port > 0 {
110
+ port = flags.Port
111
+ }
112
+
113
+ return Config{
114
+ PollInterval: l.seconds("KANSHI_POLL_INTERVAL", 5*time.Second),
115
+ IdleTimeout: l.seconds("KANSHI_IDLE_TIMEOUT", 30*time.Second),
116
+ DockerConcurrency: l.int("KANSHI_DOCKER_CONCURRENCY", 8),
117
+ DockerHost: l.dockerHost(),
118
+ StorageRoots: l.list("KANSHI_STORAGE_ROOTS", "auto"),
119
+ StorageInterval: l.seconds("KANSHI_STORAGE_INTERVAL", 6*time.Hour),
120
+ StorageExclude: l.list("KANSHI_STORAGE_EXCLUDE", ""),
121
+ StorageMinRescan: l.seconds("KANSHI_STORAGE_MIN_RESCAN", 30*time.Second),
122
+ StorageCPU: l.float("KANSHI_STORAGE_CPU", 25),
123
+ TreeDepth: l.int("KANSHI_TREE_DEPTH", 4),
124
+ StorageCache: l.string("KANSHI_STORAGE_CACHE", defaultStorageCache()),
125
+ HostRoot: strings.TrimRight(l.string("KANSHI_HOST_ROOT", ""), `/\`),
126
+ Access: access,
127
+ AccessSource: accessSrc,
128
+ Port: port,
129
+ File: path,
130
+ FileLoaded: loaded,
131
+ WebDir: l.string("KANSHI_WEB_DIR", ""),
132
+ }
133
+ }
134
+
135
+ // defaultStorageCache is in the user's cache folder: ~/.cache/kanshi on Linux,
136
+ // %LocalAppData%\kanshi on Windows. A scratch container has no home, so there
137
+ // it is set explicitly.
138
+ func defaultStorageCache() string {
139
+ dir, err := os.UserCacheDir()
140
+ if err != nil {
141
+ return ""
142
+ }
143
+ return filepath.Join(dir, "kanshi", "storage.cache")
144
+ }
145
+
146
+ // lookup resolves one setting against the environment, then the file.
147
+ type lookup struct {
148
+ file map[string]string
149
+ }
150
+
151
+ func (l lookup) get(name string) (string, Source) {
152
+ if v := strings.TrimSpace(os.Getenv(name)); v != "" {
153
+ return v, SourceEnv
154
+ }
155
+ if v := strings.TrimSpace(l.file[name]); v != "" {
156
+ return v, SourceFile
157
+ }
158
+ return "", SourceDefault
159
+ }
160
+
161
+ // access resolves the access mode. KANSHI_HOST is the setting this replaced;
162
+ // it is still honoured, one level below KANSHI_ACCESS from the same source, so
163
+ // an existing .env keeps meaning what it meant.
164
+ func (l lookup) access(flag string) (string, Source) {
165
+ if flag != "" {
166
+ return flag, SourceFlag
167
+ }
168
+ if v := strings.TrimSpace(os.Getenv("KANSHI_ACCESS")); v != "" {
169
+ return v, SourceEnv
170
+ }
171
+ if v := strings.TrimSpace(os.Getenv("KANSHI_HOST")); v != "" {
172
+ return hostToAccess(v), SourceEnv
173
+ }
174
+ if v := strings.TrimSpace(l.file["KANSHI_ACCESS"]); v != "" {
175
+ return v, SourceFile
176
+ }
177
+ if v := strings.TrimSpace(l.file["KANSHI_HOST"]); v != "" {
178
+ return hostToAccess(v), SourceFile
179
+ }
180
+ return "local", SourceDefault
181
+ }
182
+
183
+ func hostToAccess(host string) string {
184
+ switch host {
185
+ case "0.0.0.0", "::", "[::]", "*":
186
+ return "all"
187
+ case "127.0.0.1", "localhost", "::1", "[::1]":
188
+ return "local"
189
+ }
190
+ return host
191
+ }
192
+
193
+ // dockerHost honours DOCKER_HOST like the docker CLI does, then the older
194
+ // KANSHI_DOCKER_SOCKET (a bare socket path), then the platform default.
195
+ func (l lookup) dockerHost() string {
196
+ if v, _ := l.get("DOCKER_HOST"); v != "" {
197
+ return v
198
+ }
199
+ if v, _ := l.get("KANSHI_DOCKER_SOCKET"); v != "" {
200
+ if strings.Contains(v, "://") {
201
+ return v
202
+ }
203
+ return "unix://" + v
204
+ }
205
+ return defaultDockerHost
206
+ }
207
+
208
+ func (l lookup) string(name, def string) string {
209
+ if v, _ := l.get(name); v != "" {
210
+ return v
211
+ }
212
+ return def
213
+ }
214
+
215
+ func (l lookup) int(name string, def int) int {
216
+ v, _ := l.get(name)
217
+ n, err := strconv.Atoi(v)
218
+ if err != nil {
219
+ return def
220
+ }
221
+ return n
222
+ }
223
+
224
+ func (l lookup) float(name string, def float64) float64 {
225
+ v, _ := l.get(name)
226
+ n, err := strconv.ParseFloat(v, 64)
227
+ if err != nil {
228
+ return def
229
+ }
230
+ return n
231
+ }
232
+
233
+ // seconds accepts a bare number of seconds, matching the Compose file's plain
234
+ // integers.
235
+ func (l lookup) seconds(name string, def time.Duration) time.Duration {
236
+ v, _ := l.get(name)
237
+ n, err := strconv.ParseFloat(v, 64)
238
+ if err != nil || n <= 0 {
239
+ return def
240
+ }
241
+ return time.Duration(n * float64(time.Second))
242
+ }
243
+
244
+ func (l lookup) list(name, def string) []string {
245
+ raw, _ := l.get(name)
246
+ if raw == "" {
247
+ raw = def
248
+ }
249
+ var out []string
250
+ for _, part := range strings.Split(raw, ",") {
251
+ if p := strings.TrimSpace(part); p != "" {
252
+ out = append(out, p)
253
+ }
254
+ }
255
+ return out
256
+ }
@@ -0,0 +1,5 @@
1
+ //go:build !windows
2
+
3
+ package config
4
+
5
+ const defaultDockerHost = "unix:///var/run/docker.sock"
@@ -0,0 +1,4 @@
1
+ package config
2
+
3
+ // Docker Desktop's engine pipe.
4
+ const defaultDockerHost = "npipe:////./pipe/docker_engine"
@@ -0,0 +1,147 @@
1
+ package config
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ )
10
+
11
+ // FileName is the optional settings file. It uses the same KEY=VALUE format
12
+ // as a Compose .env file, so the two are interchangeable.
13
+ const FileName = "kanshi.env"
14
+
15
+ // findFile picks the settings file: an explicit path (flag, then
16
+ // KANSHI_CONFIG), else kanshi.env next to the executable, else one in the
17
+ // user's config directory. When none exists it still returns where one should
18
+ // be created, so the dashboard has somewhere to save an access change.
19
+ func findFile(explicit string) (path string, exists bool) {
20
+ if explicit == "" {
21
+ explicit = strings.TrimSpace(os.Getenv("KANSHI_CONFIG"))
22
+ }
23
+ if explicit != "" {
24
+ return explicit, isFile(explicit)
25
+ }
26
+ if exe, err := os.Executable(); err == nil {
27
+ if p := filepath.Join(filepath.Dir(exe), FileName); isFile(p) {
28
+ return p, true
29
+ }
30
+ }
31
+ dir, err := os.UserConfigDir()
32
+ if err != nil {
33
+ // A scratch container has no HOME. There is nowhere to persist to,
34
+ // and the access mode comes from the environment there anyway.
35
+ return "", false
36
+ }
37
+ p := filepath.Join(dir, "kanshi", FileName)
38
+ return p, isFile(p)
39
+ }
40
+
41
+ func isFile(p string) bool {
42
+ info, err := os.Stat(p)
43
+ return err == nil && info.Mode().IsRegular()
44
+ }
45
+
46
+ // ReadFile parses a KEY=VALUE file. Blank lines and # comments are skipped, an
47
+ // "export " prefix is tolerated, and a value may be wrapped in single or double
48
+ // quotes.
49
+ func ReadFile(path string) (map[string]string, error) {
50
+ raw, err := os.ReadFile(path)
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ out := make(map[string]string)
55
+ sc := bufio.NewScanner(bytes.NewReader(raw))
56
+ for sc.Scan() {
57
+ key, value, ok := parseLine(sc.Text())
58
+ if ok {
59
+ out[key] = value
60
+ }
61
+ }
62
+ return out, sc.Err()
63
+ }
64
+
65
+ func parseLine(line string) (key, value string, ok bool) {
66
+ line = strings.TrimSpace(line)
67
+ if line == "" || line[0] == '#' {
68
+ return "", "", false
69
+ }
70
+ line = strings.TrimPrefix(line, "export ")
71
+ key, value, ok = strings.Cut(line, "=")
72
+ if !ok {
73
+ return "", "", false
74
+ }
75
+ key = strings.TrimSpace(key)
76
+ value = strings.TrimSpace(value)
77
+ if n := len(value); n >= 2 && (value[0] == '"' || value[0] == '\'') && value[n-1] == value[0] {
78
+ value = value[1 : n-1]
79
+ } else if i := strings.Index(value, " #"); i >= 0 {
80
+ value = strings.TrimSpace(value[:i]) // trailing comment on an unquoted value
81
+ }
82
+ return key, value, key != ""
83
+ }
84
+
85
+ // Writable reports whether SaveValue could write path: its directory can be
86
+ // created and written to. In a container with a read-only root filesystem it
87
+ // cannot, and the dashboard says so instead of offering to save.
88
+ func Writable(path string) bool {
89
+ dir := filepath.Dir(path)
90
+ if err := os.MkdirAll(dir, 0o755); err != nil {
91
+ return false
92
+ }
93
+ f, err := os.CreateTemp(dir, ".kanshi-*.tmp")
94
+ if err != nil {
95
+ return false
96
+ }
97
+ f.Close()
98
+ os.Remove(f.Name())
99
+ return true
100
+ }
101
+
102
+ // SaveValue sets one key in the file, keeping every other line — comments
103
+ // included — exactly as it was. The file and its directory are created if
104
+ // needed, and the write goes through a rename so a crash cannot leave half a
105
+ // file behind.
106
+ func SaveValue(path, key, value string) error {
107
+ raw, err := os.ReadFile(path)
108
+ if err != nil && !os.IsNotExist(err) {
109
+ return err
110
+ }
111
+ var lines []string
112
+ if len(raw) > 0 {
113
+ lines = strings.Split(strings.TrimRight(string(raw), "\r\n"), "\n")
114
+ }
115
+ entry := key + "=" + value
116
+ replaced := false
117
+ for i, line := range lines {
118
+ if k, _, ok := parseLine(strings.TrimRight(line, "\r")); ok && k == key {
119
+ if !replaced {
120
+ lines[i] = entry
121
+ replaced = true
122
+ } else {
123
+ lines[i] = "# " + line // a later duplicate would win; neutralise it
124
+ }
125
+ }
126
+ }
127
+ if !replaced {
128
+ lines = append(lines, entry)
129
+ }
130
+
131
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
132
+ return err
133
+ }
134
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".kanshi-*.env")
135
+ if err != nil {
136
+ return err
137
+ }
138
+ defer os.Remove(tmp.Name())
139
+ if _, err := tmp.WriteString(strings.Join(lines, "\n") + "\n"); err != nil {
140
+ tmp.Close()
141
+ return err
142
+ }
143
+ if err := tmp.Close(); err != nil {
144
+ return err
145
+ }
146
+ return os.Rename(tmp.Name(), path)
147
+ }
@@ -0,0 +1,32 @@
1
+ package dockerstats
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net"
7
+ "strings"
8
+ "time"
9
+ )
10
+
11
+ // dialer connects to the daemon named by a DOCKER_HOST-style address:
12
+ // unix:///var/run/docker.sock, npipe:////./pipe/docker_engine or
13
+ // tcp://host:2375. TLS endpoints are not supported; the daemon Kanshi watches
14
+ // is the local one.
15
+ func dialer(host string) (func(ctx context.Context) (net.Conn, error), error) {
16
+ scheme, addr, ok := strings.Cut(host, "://")
17
+ if !ok {
18
+ return nil, fmt.Errorf("unsupported DOCKER_HOST %q", host)
19
+ }
20
+ d := &net.Dialer{Timeout: 5 * time.Second}
21
+ switch scheme {
22
+ case "unix":
23
+ return func(ctx context.Context) (net.Conn, error) { return d.DialContext(ctx, "unix", addr) }, nil
24
+ case "tcp":
25
+ return func(ctx context.Context) (net.Conn, error) { return d.DialContext(ctx, "tcp", addr) }, nil
26
+ case "npipe":
27
+ // npipe:////./pipe/docker_engine → \\.\pipe\docker_engine
28
+ pipe := strings.ReplaceAll(addr, "/", `\`)
29
+ return func(ctx context.Context) (net.Conn, error) { return dialPipe(ctx, pipe) }, nil
30
+ }
31
+ return nil, fmt.Errorf("unsupported DOCKER_HOST scheme %q", scheme)
32
+ }
@@ -0,0 +1,13 @@
1
+ //go:build !windows
2
+
3
+ package dockerstats
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ "net"
9
+ )
10
+
11
+ func dialPipe(context.Context, string) (net.Conn, error) {
12
+ return nil, &net.OpError{Op: "dial", Net: "npipe", Err: errors.New("named pipes only exist on Windows")}
13
+ }