@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
|
@@ -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,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
|
+
}
|