@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,175 @@
1
+ // Package roots decides which directories the storage map covers and reads
2
+ // how full their volumes are.
3
+ //
4
+ // With no configuration it picks sensible roots for the platform: on Linux the
5
+ // root filesystem plus every drive mounted under /mnt, on Windows the system
6
+ // drive, the user's profile folder and every other fixed drive. Explicit
7
+ // entries replace that, and the word "auto" mixes the defaults back in.
8
+ package roots
9
+
10
+ import (
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+ )
17
+
18
+ // Root is one entry in the storage map. Label is the path as the user knows
19
+ // it; Path is where Kanshi actually reads it, which differs only inside a
20
+ // container, where the host's / is mounted at KANSHI_HOST_ROOT.
21
+ type Root struct {
22
+ Label string `json:"label"`
23
+ Path string `json:"path"`
24
+ }
25
+
26
+ // Sep is the separator labels are written with. The browser needs it to parse
27
+ // a pasted path and to draw breadcrumbs.
28
+ const Sep = string(filepath.Separator)
29
+
30
+ // How long a resolved list is reused. Vitals asks every tick; re-reading the
31
+ // mount table that often would be pointless work for something that changes
32
+ // when a drive is plugged in.
33
+ const cacheFor = 30 * time.Second
34
+
35
+ // Resolver turns the configured entries into roots, and caches the answer.
36
+ type Resolver struct {
37
+ spec []string
38
+ hostRoot string
39
+
40
+ mu sync.Mutex
41
+ cached []Root
42
+ at time.Time
43
+ }
44
+
45
+ func NewResolver(spec []string, hostRoot string) *Resolver {
46
+ if hostRoot != "" && !isDir(hostRoot) {
47
+ // The image sets KANSHI_HOST_ROOT=/hostfs; run without that mount,
48
+ // the container's own filesystem is all there is to show.
49
+ hostRoot = ""
50
+ }
51
+ return &Resolver{spec: spec, hostRoot: hostRoot}
52
+ }
53
+
54
+ // Roots returns the cached list, refreshing it once it is stale.
55
+ func (r *Resolver) Roots() []Root {
56
+ r.mu.Lock()
57
+ defer r.mu.Unlock()
58
+ if r.cached == nil || time.Since(r.at) > cacheFor {
59
+ r.cached, r.at = r.resolve(), time.Now()
60
+ }
61
+ return r.cached
62
+ }
63
+
64
+ // Refresh re-resolves now. The storage walk calls this so a drive mounted a
65
+ // moment ago is included.
66
+ func (r *Resolver) Refresh() []Root {
67
+ r.mu.Lock()
68
+ defer r.mu.Unlock()
69
+ r.cached, r.at = r.resolve(), time.Now()
70
+ return r.cached
71
+ }
72
+
73
+ // HostRoot is the prefix host paths are read under, or "" on bare metal.
74
+ func (r *Resolver) HostRoot() string { return r.hostRoot }
75
+
76
+ func (r *Resolver) resolve() []Root {
77
+ out := []Root{}
78
+ seen := map[string]bool{}
79
+ add := func(root Root) {
80
+ key := Key(root.Path)
81
+ if !seen[key] {
82
+ seen[key] = true
83
+ out = append(out, root)
84
+ }
85
+ }
86
+ for _, entry := range r.spec {
87
+ if strings.EqualFold(entry, "auto") {
88
+ for _, root := range auto(r.hostRoot) {
89
+ add(root)
90
+ }
91
+ continue
92
+ }
93
+ add(r.explicit(entry))
94
+ }
95
+ return out
96
+ }
97
+
98
+ // explicit parses "label=path" or a bare path. A bare path is labelled with
99
+ // itself, as seen from the host. Inside a container, a host path that does
100
+ // not exist there is looked up under the host root instead — so an old
101
+ // "/mnt/data" entry keeps working after its dedicated volume is gone.
102
+ func (r *Resolver) explicit(entry string) Root {
103
+ label, p, found := strings.Cut(entry, "=")
104
+ if !found || p == "" {
105
+ p, label = label, ""
106
+ }
107
+ if r.hostRoot != "" && !within(r.hostRoot, p) && !isDir(p) && isDir(r.hostRoot+p) {
108
+ p = r.hostRoot + p
109
+ }
110
+ if label == "" {
111
+ label = r.HostPath(p)
112
+ }
113
+ return Root{Label: label, Path: p}
114
+ }
115
+
116
+ // HostPath strips the host-root prefix, turning "/hostfs/mnt/data" into
117
+ // "/mnt/data".
118
+ func (r *Resolver) HostPath(p string) string {
119
+ if r.hostRoot == "" || !within(r.hostRoot, p) {
120
+ return p
121
+ }
122
+ rest := p[len(r.hostRoot):]
123
+ if rest == "" {
124
+ return "/"
125
+ }
126
+ return rest
127
+ }
128
+
129
+ // ContainerPath is the reverse of HostPath: where a host path is readable.
130
+ // Excludes are written as host paths, so they go through this.
131
+ func (r *Resolver) ContainerPath(p string) string {
132
+ if r.hostRoot == "" || within(r.hostRoot, p) {
133
+ return p
134
+ }
135
+ return r.hostRoot + p
136
+ }
137
+
138
+ // within reports whether p is dir itself or somewhere below it.
139
+ func within(dir, p string) bool {
140
+ d, q := Key(dir), Key(p)
141
+ if d == q {
142
+ return true
143
+ }
144
+ if !strings.HasSuffix(d, Sep) {
145
+ d += Sep
146
+ }
147
+ return strings.HasPrefix(q, d)
148
+ }
149
+
150
+ // Within reports whether child is parent itself or inside it, comparing the
151
+ // way the platform's filesystem does.
152
+ func Within(parent, child string) bool { return within(parent, child) }
153
+
154
+ // Key normalises a path for comparison: cleaned, and on Windows lower-cased,
155
+ // since NTFS treats C:\Users and c:\users as the same directory.
156
+ func Key(p string) string {
157
+ p = filepath.Clean(p)
158
+ if caseInsensitive {
159
+ p = strings.ToLower(p)
160
+ }
161
+ return p
162
+ }
163
+
164
+ func isDir(p string) bool {
165
+ info, err := os.Stat(p)
166
+ return err == nil && info.IsDir()
167
+ }
168
+
169
+ // Usage is how full the volume holding a root is.
170
+ type Usage struct {
171
+ Total, Used, Free uint64
172
+ // Volume identifies the filesystem, so two roots on the same one share a
173
+ // single meter rather than showing the same numbers twice.
174
+ Volume string
175
+ }
@@ -0,0 +1,132 @@
1
+ package roots
2
+
3
+ import (
4
+ "bufio"
5
+ "os"
6
+ "sort"
7
+ "strconv"
8
+ "strings"
9
+ "syscall"
10
+ )
11
+
12
+ const caseInsensitive = false
13
+
14
+ // auto is / plus every real filesystem mounted under /mnt.
15
+ func auto(hostRoot string) []Root {
16
+ base := hostRoot
17
+ if base == "" {
18
+ base = "/"
19
+ }
20
+ out := []Root{{Label: "/", Path: base}}
21
+ for _, point := range mountsUnder(hostRoot+"/mnt", "/proc/self/mountinfo") {
22
+ label := strings.TrimPrefix(point, hostRoot)
23
+ out = append(out, Root{Label: label, Path: point})
24
+ }
25
+ return out
26
+ }
27
+
28
+ // Kernel and virtual filesystems that can be mounted under /mnt but hold no
29
+ // data worth mapping.
30
+ var pseudoFS = map[string]bool{
31
+ "autofs": true, "binfmt_misc": true, "bpf": true, "cgroup": true, "cgroup2": true,
32
+ "configfs": true, "debugfs": true, "devpts": true, "devtmpfs": true, "efivarfs": true,
33
+ "fusectl": true, "hugetlbfs": true, "mqueue": true, "nsfs": true, "overlay": true,
34
+ "proc": true, "pstore": true, "ramfs": true, "rpc_pipefs": true, "securityfs": true,
35
+ "squashfs": true, "sysfs": true, "tmpfs": true, "tracefs": true, "nfsd": true,
36
+ "fuse.gvfsd-fuse": true, "fuse.portal": true, "fuse.lxcfs": true, "fuse.snapfuse": true,
37
+ }
38
+
39
+ // mountsUnder lists mount points at or below dir from a mountinfo file,
40
+ // sorted.
41
+ // Inside a container the host's mounts appear under the host root because the
42
+ // bind mount of / is recursive, so the same code finds them in both places.
43
+ func mountsUnder(dir, mountinfo string) []string {
44
+ f, err := os.Open(mountinfo)
45
+ if err != nil {
46
+ return nil
47
+ }
48
+ defer f.Close()
49
+
50
+ type mount struct{ source, fstype string }
51
+ byPoint := map[string]mount{}
52
+ sc := bufio.NewScanner(f)
53
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
54
+ for sc.Scan() {
55
+ // 36 35 98:0 /mnt1 /mnt/parent rw,noatime master:1 - ext3 /dev/root rw
56
+ pre, post, ok := strings.Cut(sc.Text(), " - ")
57
+ if !ok {
58
+ continue
59
+ }
60
+ fields := strings.Fields(pre)
61
+ tail := strings.Fields(post)
62
+ if len(fields) < 5 || len(tail) < 1 {
63
+ continue
64
+ }
65
+ point := unescape(fields[4])
66
+ if point != dir && !strings.HasPrefix(point, dir+"/") {
67
+ continue
68
+ }
69
+ // Later lines are mounted on top of earlier ones at the same point,
70
+ // so the last one is what a path lookup actually reaches.
71
+ byPoint[point] = mount{source: fields[2] + " " + unescape(fields[3]), fstype: tail[0]}
72
+ }
73
+
74
+ points := make([]string, 0, len(byPoint))
75
+ for p := range byPoint {
76
+ points = append(points, p)
77
+ }
78
+ sort.Strings(points)
79
+
80
+ // A bind mount of the same directory at two points would be walked, and
81
+ // counted, twice. Keep the first.
82
+ sources := map[string]bool{}
83
+ out := points[:0]
84
+ for _, p := range points {
85
+ m := byPoint[p]
86
+ if pseudoFS[m.fstype] || sources[m.source] {
87
+ continue
88
+ }
89
+ sources[m.source] = true
90
+ out = append(out, p)
91
+ }
92
+ return out
93
+ }
94
+
95
+ // unescape reverses mountinfo's octal escapes ("\040" is a space).
96
+ func unescape(s string) string {
97
+ if !strings.Contains(s, `\`) {
98
+ return s
99
+ }
100
+ var b strings.Builder
101
+ for i := 0; i < len(s); i++ {
102
+ if s[i] == '\\' && i+4 <= len(s) {
103
+ if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
104
+ b.WriteByte(byte(n))
105
+ i += 3
106
+ continue
107
+ }
108
+ }
109
+ b.WriteByte(s[i])
110
+ }
111
+ return b.String()
112
+ }
113
+
114
+ // DiskUsage reads a volume's size off statfs.
115
+ func DiskUsage(path string) (Usage, error) {
116
+ var st syscall.Statfs_t
117
+ if err := syscall.Statfs(path, &st); err != nil {
118
+ return Usage{}, err
119
+ }
120
+ bsize := uint64(st.Bsize)
121
+ total := st.Blocks * bsize
122
+ // Bavail excludes root-reserved blocks, so used+free won't equal total.
123
+ // "Used" is reported the way df does it.
124
+ u := Usage{Total: total, Used: total - st.Bfree*bsize, Free: st.Bavail * bsize}
125
+ var s syscall.Stat_t
126
+ if err := syscall.Stat(path, &s); err == nil {
127
+ u.Volume = strconv.FormatUint(uint64(s.Dev), 10)
128
+ } else {
129
+ u.Volume = path
130
+ }
131
+ return u, nil
132
+ }
@@ -0,0 +1,74 @@
1
+ package roots
2
+
3
+ import (
4
+ "os"
5
+ "strings"
6
+ "syscall"
7
+ "unsafe"
8
+ )
9
+
10
+ const caseInsensitive = true
11
+
12
+ var (
13
+ kernel32 = syscall.NewLazyDLL("kernel32.dll")
14
+ procGetLogicalDrives = kernel32.NewProc("GetLogicalDrives")
15
+ procGetDriveTypeW = kernel32.NewProc("GetDriveTypeW")
16
+ procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW")
17
+ procGetVolumePathNameW = kernel32.NewProc("GetVolumePathNameW")
18
+ )
19
+
20
+ const driveFixed = 3
21
+
22
+ // auto is the system drive, the user's profile folder, then every other fixed
23
+ // drive. The profile folder sits on the system drive; the walk notices that
24
+ // and covers both in one pass.
25
+ func auto(string) []Root {
26
+ system := strings.TrimRight(os.Getenv("SystemDrive"), `\`)
27
+ if system == "" {
28
+ system = "C:"
29
+ }
30
+ out := []Root{{Label: system + `\`, Path: system + `\`}}
31
+ if home, err := os.UserHomeDir(); err == nil && isDir(home) {
32
+ out = append(out, Root{Label: home, Path: home})
33
+ }
34
+
35
+ mask, _, _ := procGetLogicalDrives.Call()
36
+ for i := 0; i < 26; i++ {
37
+ if mask&(1<<i) == 0 {
38
+ continue
39
+ }
40
+ drive := string(rune('A'+i)) + `:\`
41
+ if strings.EqualFold(drive[:2], system) {
42
+ continue
43
+ }
44
+ // Fixed covers internal disks and most external USB drives. Network
45
+ // shares, optical drives and card readers are left out: walking
46
+ // those is slow, and they come and go.
47
+ p, _ := syscall.UTF16PtrFromString(drive)
48
+ if t, _, _ := procGetDriveTypeW.Call(uintptr(unsafe.Pointer(p))); t == driveFixed && isDir(drive) {
49
+ out = append(out, Root{Label: drive, Path: drive})
50
+ }
51
+ }
52
+ return out
53
+ }
54
+
55
+ // DiskUsage reads a volume's size with GetDiskFreeSpaceExW.
56
+ func DiskUsage(path string) (Usage, error) {
57
+ p, err := syscall.UTF16PtrFromString(path)
58
+ if err != nil {
59
+ return Usage{}, err
60
+ }
61
+ var avail, total, free uint64
62
+ r, _, e := procGetDiskFreeSpaceExW.Call(uintptr(unsafe.Pointer(p)),
63
+ uintptr(unsafe.Pointer(&avail)), uintptr(unsafe.Pointer(&total)), uintptr(unsafe.Pointer(&free)))
64
+ if r == 0 {
65
+ return Usage{}, e
66
+ }
67
+ u := Usage{Total: total, Used: total - free, Free: avail, Volume: strings.ToLower(path)}
68
+ // The profile folder and C:\ are the same volume; GetVolumePathName says so.
69
+ buf := make([]uint16, 300)
70
+ if r, _, _ := procGetVolumePathNameW.Call(uintptr(unsafe.Pointer(p)), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))); r != 0 {
71
+ u.Volume = strings.ToLower(syscall.UTF16ToString(buf))
72
+ }
73
+ return u, nil
74
+ }
@@ -0,0 +1,155 @@
1
+ package server
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net"
6
+ "net/http"
7
+ "net/netip"
8
+ "net/url"
9
+ "strings"
10
+
11
+ "github.com/rene-roid/kanshi/internal/access"
12
+ "github.com/rene-roid/kanshi/internal/config"
13
+ )
14
+
15
+ // accessState is what the dashboard's network panel shows.
16
+ type accessState struct {
17
+ Mode string `json:"mode"`
18
+ Source config.Source `json:"source"`
19
+ Editable bool `json:"editable"`
20
+ // Reason says why Editable is false, in words the panel can show as is.
21
+ Reason string `json:"reason,omitempty"`
22
+ Links []access.Link `json:"links"`
23
+ }
24
+
25
+ func (s *Server) accessState(r *http.Request) accessState {
26
+ s.accessMu.Lock()
27
+ mgr, src := s.access, s.accessSource
28
+ s.accessMu.Unlock()
29
+
30
+ st := accessState{Source: src, Links: []access.Link{}}
31
+ if mgr == nil {
32
+ return st
33
+ }
34
+ st.Mode, st.Links = mgr.Mode().String(), mgr.Links()
35
+ switch {
36
+ case src == config.SourceFlag:
37
+ st.Reason = "Set with --access when kanshi was started."
38
+ case src == config.SourceEnv:
39
+ st.Reason = "Set by KANSHI_ACCESS in kanshi's environment."
40
+ case !s.canSave():
41
+ st.Reason = "Kanshi cannot save this setting here. Set KANSHI_ACCESS where it is started — for Docker, in the .env file."
42
+ case !fromLocalhost(r):
43
+ st.Reason = "Open the dashboard on this computer, at localhost, to change this."
44
+ default:
45
+ st.Editable = true
46
+ }
47
+ return st
48
+ }
49
+
50
+ // canSave is checked once, the first time the network panel asks.
51
+ func (s *Server) canSave() bool {
52
+ s.saveOnce.Do(func() { s.saveOK = s.cfg.File != "" && config.Writable(s.cfg.File) })
53
+ return s.saveOK
54
+ }
55
+
56
+ func (s *Server) handleAccess(w http.ResponseWriter, r *http.Request) {
57
+ writeJSON(w, r, s.accessState(r))
58
+ }
59
+
60
+ // handleSetAccess changes who can reach the dashboard. Widening that is the
61
+ // one thing here that matters for security, so it is only accepted from the
62
+ // machine itself, from the dashboard's own page, and only when no flag or
63
+ // environment variable would override it on the next start.
64
+ func (s *Server) handleSetAccess(w http.ResponseWriter, r *http.Request) {
65
+ if !fromLocalhost(r) || !sameOrigin(r) {
66
+ http.Error(w, "the network setting can only be changed from this computer", http.StatusForbidden)
67
+ return
68
+ }
69
+ var body struct {
70
+ Mode string `json:"mode"`
71
+ }
72
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
73
+ http.Error(w, "bad request", http.StatusBadRequest)
74
+ return
75
+ }
76
+ mode, err := access.Parse(body.Mode)
77
+ if err != nil {
78
+ http.Error(w, err.Error(), http.StatusBadRequest)
79
+ return
80
+ }
81
+
82
+ s.accessMu.Lock()
83
+ mgr, src := s.access, s.accessSource
84
+ switch {
85
+ case mgr == nil:
86
+ s.accessMu.Unlock()
87
+ http.Error(w, "not listening yet", http.StatusServiceUnavailable)
88
+ return
89
+ case src == config.SourceFlag || src == config.SourceEnv || !s.canSave():
90
+ s.accessMu.Unlock()
91
+ http.Error(w, "the network setting is fixed by how kanshi was started", http.StatusConflict)
92
+ return
93
+ }
94
+ prev := mgr.Mode()
95
+ if err := mgr.Apply(mode); err != nil {
96
+ _ = mgr.Apply(prev)
97
+ s.accessMu.Unlock()
98
+ http.Error(w, err.Error(), http.StatusInternalServerError)
99
+ return
100
+ }
101
+ saveErr := config.SaveValue(s.cfg.File, "KANSHI_ACCESS", mode.String())
102
+ if saveErr == nil {
103
+ s.accessSource = config.SourceFile
104
+ }
105
+ s.accessMu.Unlock()
106
+
107
+ s.logf("access changed to %s from the dashboard", mode)
108
+ st := s.accessState(r)
109
+ if saveErr != nil {
110
+ s.logf("warning: could not save %s: %v", s.cfg.File, saveErr)
111
+ st.Reason = "Applied, but not saved: " + saveErr.Error()
112
+ }
113
+ writeJSON(w, r, st)
114
+ }
115
+
116
+ // fromLocalhost reports a request that arrived over loopback and was
117
+ // addressed to a loopback name. The second half matters: a hostile page can
118
+ // point its own domain at 127.0.0.1 (DNS rebinding), but then the Host header
119
+ // still carries that domain.
120
+ func fromLocalhost(r *http.Request) bool {
121
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
122
+ if err != nil {
123
+ return false
124
+ }
125
+ if ip, err := netip.ParseAddr(host); err != nil || !ip.Unmap().IsLoopback() {
126
+ return false
127
+ }
128
+ name := r.Host
129
+ if h, _, err := net.SplitHostPort(name); err == nil {
130
+ name = h
131
+ }
132
+ name = strings.Trim(name, "[]")
133
+ if strings.EqualFold(name, "localhost") {
134
+ return true
135
+ }
136
+ ip, err := netip.ParseAddr(name)
137
+ return err == nil && ip.Unmap().IsLoopback()
138
+ }
139
+
140
+ // sameOrigin reports a request made by the dashboard's own script. The custom
141
+ // header cannot be attached by a form or an <img> on another site, and a
142
+ // cross-origin fetch that tries is stopped by the browser's preflight, which
143
+ // this server never approves.
144
+ func sameOrigin(r *http.Request) bool {
145
+ if r.Header.Get("X-Kanshi") != "1" {
146
+ return false
147
+ }
148
+ if origin := r.Header.Get("Origin"); origin != "" {
149
+ u, err := url.Parse(origin)
150
+ if err != nil || !strings.EqualFold(u.Host, r.Host) {
151
+ return false
152
+ }
153
+ }
154
+ return true
155
+ }