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