@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,523 @@
|
|
|
1
|
+
// Package server wires the poller, the storage scanner and the HTTP surface
|
|
2
|
+
// together.
|
|
3
|
+
package server
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"compress/gzip"
|
|
7
|
+
"context"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io"
|
|
11
|
+
"io/fs"
|
|
12
|
+
"net/http"
|
|
13
|
+
"runtime"
|
|
14
|
+
"strconv"
|
|
15
|
+
"strings"
|
|
16
|
+
"sync"
|
|
17
|
+
"time"
|
|
18
|
+
|
|
19
|
+
"github.com/rene-roid/kanshi/internal/access"
|
|
20
|
+
"github.com/rene-roid/kanshi/internal/config"
|
|
21
|
+
"github.com/rene-roid/kanshi/internal/dockerstats"
|
|
22
|
+
"github.com/rene-roid/kanshi/internal/roots"
|
|
23
|
+
"github.com/rene-roid/kanshi/internal/storage"
|
|
24
|
+
"github.com/rene-roid/kanshi/internal/vitals"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// Frame is one push to the browser. Vitals and Docker are explicit nulls
|
|
28
|
+
// rather than omitted, because app.js tests each for truthiness. Storage is
|
|
29
|
+
// only the walk queue's status: the page refetches the folder it shows when
|
|
30
|
+
// a walk finishes, so nothing polls for it.
|
|
31
|
+
type Frame struct {
|
|
32
|
+
Vitals *vitals.Sample `json:"vitals"`
|
|
33
|
+
Docker *dockerstats.Result `json:"docker"`
|
|
34
|
+
Storage *storage.Status `json:"storage"`
|
|
35
|
+
Error string `json:"error,omitempty"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Server holds the shared state the poller writes and the handlers read.
|
|
39
|
+
type Server struct {
|
|
40
|
+
cfg config.Config
|
|
41
|
+
version string
|
|
42
|
+
logf func(format string, args ...any)
|
|
43
|
+
roots *roots.Resolver
|
|
44
|
+
vitals *vitals.Reader
|
|
45
|
+
docker *dockerstats.Client
|
|
46
|
+
storage *storage.Scanner
|
|
47
|
+
assets *assets
|
|
48
|
+
|
|
49
|
+
// base outlives any single request. Work that mutates shared state, such
|
|
50
|
+
// as a sample, is started under it rather than the request context, so a
|
|
51
|
+
// browser navigating away cannot abort it for everyone else.
|
|
52
|
+
base context.Context
|
|
53
|
+
|
|
54
|
+
mu sync.RWMutex
|
|
55
|
+
latest Frame
|
|
56
|
+
payload []byte // latest, already marshalled, for newly connected streams
|
|
57
|
+
subs map[chan []byte]struct{}
|
|
58
|
+
lastSee time.Time
|
|
59
|
+
|
|
60
|
+
// wake releases the poller from its idle sleep. Buffered by one so a
|
|
61
|
+
// signal is never lost and no sender ever blocks.
|
|
62
|
+
wake chan struct{}
|
|
63
|
+
|
|
64
|
+
// access is set once Run starts listening. accessSource is where the
|
|
65
|
+
// current mode came from, which decides whether the page may change it.
|
|
66
|
+
access *access.Manager
|
|
67
|
+
accessMu sync.Mutex
|
|
68
|
+
accessSource config.Source
|
|
69
|
+
saveOnce sync.Once
|
|
70
|
+
saveOK bool
|
|
71
|
+
|
|
72
|
+
// OnReady, when set, runs once the dashboard is listening.
|
|
73
|
+
OnReady func()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func New(cfg config.Config, web fs.FS, version string, logf func(string, ...any)) (*Server, error) {
|
|
77
|
+
a, err := loadAssets(web, cfg.WebDir != "")
|
|
78
|
+
if err != nil {
|
|
79
|
+
return nil, err
|
|
80
|
+
}
|
|
81
|
+
r := roots.NewResolver(cfg.StorageRoots, cfg.HostRoot)
|
|
82
|
+
return &Server{
|
|
83
|
+
base: context.Background(),
|
|
84
|
+
cfg: cfg,
|
|
85
|
+
version: version,
|
|
86
|
+
logf: logf,
|
|
87
|
+
roots: r,
|
|
88
|
+
vitals: vitals.New(r),
|
|
89
|
+
docker: dockerstats.New(cfg),
|
|
90
|
+
storage: storage.New(cfg, r, logf),
|
|
91
|
+
assets: a,
|
|
92
|
+
subs: make(map[chan []byte]struct{}),
|
|
93
|
+
wake: make(chan struct{}, 1),
|
|
94
|
+
accessSource: cfg.AccessSource,
|
|
95
|
+
}, nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ── polling ────────────────────────────────────────────────────────────── */
|
|
99
|
+
|
|
100
|
+
// sampleOnce takes one reading of both halves at the same moment. The vitals
|
|
101
|
+
// read is pure /proc work, so it runs alongside the Docker round trips rather
|
|
102
|
+
// than adding its latency to them.
|
|
103
|
+
func (s *Server) sampleOnce(ctx context.Context) Frame {
|
|
104
|
+
var host vitals.Sample
|
|
105
|
+
var containers dockerstats.Result
|
|
106
|
+
|
|
107
|
+
var wg sync.WaitGroup
|
|
108
|
+
wg.Add(1)
|
|
109
|
+
go func() {
|
|
110
|
+
defer wg.Done()
|
|
111
|
+
host = s.vitals.Sample()
|
|
112
|
+
}()
|
|
113
|
+
containers = s.docker.Sample(ctx)
|
|
114
|
+
wg.Wait()
|
|
115
|
+
|
|
116
|
+
scan := s.storage.Status()
|
|
117
|
+
frame := Frame{Vitals: &host, Docker: &containers, Storage: &scan}
|
|
118
|
+
payload, err := json.Marshal(frame)
|
|
119
|
+
if err != nil {
|
|
120
|
+
payload, _ = json.Marshal(Frame{Error: err.Error()})
|
|
121
|
+
}
|
|
122
|
+
s.mu.Lock()
|
|
123
|
+
s.latest, s.payload = frame, payload
|
|
124
|
+
s.mu.Unlock()
|
|
125
|
+
return frame
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// seed fills the delta baselines so the first frame shows real numbers.
|
|
129
|
+
//
|
|
130
|
+
// Both CPU readings are differences against a previous sample, so a cold poller
|
|
131
|
+
// would otherwise publish a screen of zeros. One throwaway pass plus a short
|
|
132
|
+
// gap costs ~1s and makes the first frame the user sees correct.
|
|
133
|
+
func (s *Server) seed(ctx context.Context) {
|
|
134
|
+
s.vitals.Prime()
|
|
135
|
+
s.docker.Sample(ctx)
|
|
136
|
+
select {
|
|
137
|
+
case <-ctx.Done():
|
|
138
|
+
return
|
|
139
|
+
case <-time.After(time.Second):
|
|
140
|
+
}
|
|
141
|
+
s.vitals.Prime()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Poll is the live loop. Once nobody has been connected for IdleTimeout it
|
|
145
|
+
// stops touching /proc and the Docker daemon entirely, and sleeps until a
|
|
146
|
+
// browser comes back.
|
|
147
|
+
func (s *Server) Poll(ctx context.Context) {
|
|
148
|
+
timer := time.NewTimer(time.Hour)
|
|
149
|
+
timer.Stop()
|
|
150
|
+
defer timer.Stop()
|
|
151
|
+
|
|
152
|
+
s.seed(ctx)
|
|
153
|
+
for ctx.Err() == nil {
|
|
154
|
+
if s.idle() {
|
|
155
|
+
// Nobody is watching. The timer is only a safety net: waking
|
|
156
|
+
// here does nothing but check again, and the baselines are only
|
|
157
|
+
// re-seeded once a browser has actually come back.
|
|
158
|
+
timer.Reset(time.Minute)
|
|
159
|
+
select {
|
|
160
|
+
case <-ctx.Done():
|
|
161
|
+
return
|
|
162
|
+
case <-timer.C:
|
|
163
|
+
case <-s.wake:
|
|
164
|
+
}
|
|
165
|
+
timer.Stop()
|
|
166
|
+
if !s.idle() {
|
|
167
|
+
s.seed(ctx)
|
|
168
|
+
}
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
s.sampleOnce(ctx)
|
|
173
|
+
if ctx.Err() != nil {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
s.mu.RLock()
|
|
177
|
+
payload := s.payload
|
|
178
|
+
s.mu.RUnlock()
|
|
179
|
+
s.broadcast(payload)
|
|
180
|
+
|
|
181
|
+
timer.Reset(s.cfg.PollInterval)
|
|
182
|
+
select {
|
|
183
|
+
case <-ctx.Done():
|
|
184
|
+
return
|
|
185
|
+
case <-timer.C:
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func (s *Server) idle() bool {
|
|
191
|
+
s.mu.RLock()
|
|
192
|
+
defer s.mu.RUnlock()
|
|
193
|
+
return len(s.subs) == 0 && time.Since(s.lastSee) > s.cfg.IdleTimeout
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// touch records browser activity and wakes an idle poller.
|
|
197
|
+
func (s *Server) touch() {
|
|
198
|
+
s.mu.Lock()
|
|
199
|
+
s.lastSee = time.Now()
|
|
200
|
+
s.mu.Unlock()
|
|
201
|
+
signal(s.wake)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func signal(ch chan struct{}) {
|
|
205
|
+
select {
|
|
206
|
+
case ch <- struct{}{}:
|
|
207
|
+
default: // already pending
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func (s *Server) broadcast(payload []byte) {
|
|
212
|
+
s.mu.RLock()
|
|
213
|
+
defer s.mu.RUnlock()
|
|
214
|
+
for ch := range s.subs {
|
|
215
|
+
select {
|
|
216
|
+
case ch <- payload:
|
|
217
|
+
default:
|
|
218
|
+
// The subscriber is behind. Drop its stale frame and hand it the
|
|
219
|
+
// fresh one — a slow phone must never stall the poller.
|
|
220
|
+
select {
|
|
221
|
+
case <-ch:
|
|
222
|
+
default:
|
|
223
|
+
}
|
|
224
|
+
select {
|
|
225
|
+
case ch <- payload:
|
|
226
|
+
default:
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ── handlers ───────────────────────────────────────────────────────────── */
|
|
233
|
+
|
|
234
|
+
func (s *Server) Handler() http.Handler {
|
|
235
|
+
mux := http.NewServeMux()
|
|
236
|
+
mux.HandleFunc("GET /api/vitals", s.handleVitals)
|
|
237
|
+
mux.HandleFunc("GET /api/containers", s.handleContainers)
|
|
238
|
+
mux.HandleFunc("GET /api/storage", s.handleStorage)
|
|
239
|
+
mux.HandleFunc("POST /api/storage/rescan", s.handleRescan)
|
|
240
|
+
mux.HandleFunc("GET /api/storage/dir", s.handleStorageDir)
|
|
241
|
+
mux.HandleFunc("GET /api/config", s.handleConfig)
|
|
242
|
+
mux.HandleFunc("GET /api/access", s.handleAccess)
|
|
243
|
+
mux.HandleFunc("POST /api/access", s.handleSetAccess)
|
|
244
|
+
mux.HandleFunc("GET /api/stream", s.handleStream)
|
|
245
|
+
mux.HandleFunc("GET /healthz", s.handleHealth)
|
|
246
|
+
mux.HandleFunc("GET /static/", s.assets.serveStatic)
|
|
247
|
+
mux.HandleFunc("GET /{$}", s.assets.serveIndex)
|
|
248
|
+
return mux
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Responses larger than this are gzipped for clients that accept it. Below
|
|
252
|
+
// it the header overhead eats the saving.
|
|
253
|
+
const gzipMin = 1024
|
|
254
|
+
|
|
255
|
+
var gzipPool = sync.Pool{New: func() any {
|
|
256
|
+
w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed)
|
|
257
|
+
return w
|
|
258
|
+
}}
|
|
259
|
+
|
|
260
|
+
func acceptsGzip(r *http.Request) bool {
|
|
261
|
+
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func writeJSON(w http.ResponseWriter, r *http.Request, v any) {
|
|
265
|
+
body, err := json.Marshal(v)
|
|
266
|
+
if err != nil {
|
|
267
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
h := w.Header()
|
|
271
|
+
h.Set("Content-Type", "application/json")
|
|
272
|
+
h.Set("Cache-Control", "no-cache")
|
|
273
|
+
h.Set("Vary", "Accept-Encoding")
|
|
274
|
+
if len(body) < gzipMin || !acceptsGzip(r) {
|
|
275
|
+
_, _ = w.Write(body)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
h.Set("Content-Encoding", "gzip")
|
|
279
|
+
gz := gzipPool.Get().(*gzip.Writer)
|
|
280
|
+
gz.Reset(w)
|
|
281
|
+
_, _ = gz.Write(body)
|
|
282
|
+
_ = gz.Close()
|
|
283
|
+
gzipPool.Put(gz)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func (s *Server) handleVitals(w http.ResponseWriter, r *http.Request) {
|
|
287
|
+
s.touch()
|
|
288
|
+
s.mu.RLock()
|
|
289
|
+
latest := s.latest
|
|
290
|
+
s.mu.RUnlock()
|
|
291
|
+
if latest.Vitals == nil {
|
|
292
|
+
latest = s.sampleOnce(s.base)
|
|
293
|
+
}
|
|
294
|
+
writeJSON(w, r, latest.Vitals)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func (s *Server) handleContainers(w http.ResponseWriter, r *http.Request) {
|
|
298
|
+
s.touch()
|
|
299
|
+
s.mu.RLock()
|
|
300
|
+
latest := s.latest
|
|
301
|
+
s.mu.RUnlock()
|
|
302
|
+
if latest.Docker == nil {
|
|
303
|
+
latest = s.sampleOnce(s.base)
|
|
304
|
+
}
|
|
305
|
+
writeJSON(w, r, latest.Docker)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func (s *Server) handleStorage(w http.ResponseWriter, r *http.Request) {
|
|
309
|
+
writeJSON(w, r, s.storage.Snapshot())
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// handleStorageDir reads one directory and sizes its subfolders from the
|
|
313
|
+
// cache: /api/storage/dir?root=0&path=home/yuuki. Subfolders the cache is
|
|
314
|
+
// missing come back pending, and are walked in the background.
|
|
315
|
+
func (s *Server) handleStorageDir(w http.ResponseWriter, r *http.Request) {
|
|
316
|
+
q := r.URL.Query()
|
|
317
|
+
root, err := strconv.Atoi(q.Get("root"))
|
|
318
|
+
if err != nil {
|
|
319
|
+
http.Error(w, "bad root", http.StatusBadRequest)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
listing, ok := s.storage.List(root, q.Get("path"))
|
|
323
|
+
if !ok {
|
|
324
|
+
http.Error(w, "no such root", http.StatusNotFound)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
writeJSON(w, r, listing)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// handleRescan re-walks the subfolders of one directory, named the same way
|
|
331
|
+
// as for handleStorageDir. It has to come from the dashboard itself: the
|
|
332
|
+
// custom header cannot be set by a plain form or an <img> on some other site,
|
|
333
|
+
// so a page you happen to visit cannot keep your disks busy.
|
|
334
|
+
func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
|
|
335
|
+
if !sameOrigin(r) {
|
|
336
|
+
http.Error(w, "forbidden", http.StatusForbidden)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
q := r.URL.Query()
|
|
340
|
+
root, err := strconv.Atoi(q.Get("root"))
|
|
341
|
+
if err != nil {
|
|
342
|
+
http.Error(w, "bad root", http.StatusBadRequest)
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
status, ok := s.storage.Rescan(root, q.Get("path"))
|
|
346
|
+
if !ok {
|
|
347
|
+
http.Error(w, "no such root", http.StatusNotFound)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
writeJSON(w, r, status)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
354
|
+
writeJSON(w, r, map[string]any{
|
|
355
|
+
"version": s.version,
|
|
356
|
+
"os": runtime.GOOS,
|
|
357
|
+
"poll_interval": s.cfg.PollInterval.Seconds(),
|
|
358
|
+
"storage_interval": s.cfg.StorageInterval.Seconds(),
|
|
359
|
+
"tree_depth": s.cfg.TreeDepth,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
364
|
+
writeJSON(w, r, map[string]bool{"ok": true})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
|
368
|
+
flusher, ok := w.(http.Flusher)
|
|
369
|
+
if !ok {
|
|
370
|
+
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
ch := make(chan []byte, 1)
|
|
375
|
+
s.mu.Lock()
|
|
376
|
+
s.subs[ch] = struct{}{}
|
|
377
|
+
s.mu.Unlock()
|
|
378
|
+
s.touch()
|
|
379
|
+
defer func() {
|
|
380
|
+
s.mu.Lock()
|
|
381
|
+
delete(s.subs, ch)
|
|
382
|
+
s.mu.Unlock()
|
|
383
|
+
}()
|
|
384
|
+
|
|
385
|
+
h := w.Header()
|
|
386
|
+
h.Set("Content-Type", "text/event-stream")
|
|
387
|
+
h.Set("Cache-Control", "no-cache, no-transform")
|
|
388
|
+
h.Set("X-Accel-Buffering", "no") // tell any reverse proxy not to buffer us
|
|
389
|
+
h.Set("Connection", "keep-alive")
|
|
390
|
+
|
|
391
|
+
// One gzip stream for the life of the connection. Consecutive frames are
|
|
392
|
+
// nearly identical, and deflate's window spans several of them, so each
|
|
393
|
+
// frame after the first shrinks to a few hundred bytes — a fraction of
|
|
394
|
+
// what a phone on mobile data would otherwise pull every tick.
|
|
395
|
+
var out io.Writer = w
|
|
396
|
+
var gz *gzip.Writer
|
|
397
|
+
if acceptsGzip(r) {
|
|
398
|
+
h.Set("Content-Encoding", "gzip")
|
|
399
|
+
h.Set("Vary", "Accept-Encoding")
|
|
400
|
+
gz, _ = gzip.NewWriterLevel(w, gzip.BestSpeed)
|
|
401
|
+
defer gz.Close()
|
|
402
|
+
out = gz
|
|
403
|
+
}
|
|
404
|
+
w.WriteHeader(http.StatusOK)
|
|
405
|
+
send := func(chunks ...string) {
|
|
406
|
+
for _, c := range chunks {
|
|
407
|
+
_, _ = io.WriteString(out, c)
|
|
408
|
+
}
|
|
409
|
+
if gz != nil {
|
|
410
|
+
_ = gz.Flush()
|
|
411
|
+
}
|
|
412
|
+
flusher.Flush()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Hand the newcomer the last frame immediately, so a reconnecting phone
|
|
416
|
+
// paints real numbers instead of waiting out a poll interval.
|
|
417
|
+
s.mu.RLock()
|
|
418
|
+
payload := s.payload
|
|
419
|
+
s.mu.RUnlock()
|
|
420
|
+
if payload != nil {
|
|
421
|
+
send("data: ", string(payload), "\n\n")
|
|
422
|
+
} else {
|
|
423
|
+
send(": hello\n\n") // gets the headers and the gzip header out now
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
keepalive := time.NewTicker(25 * time.Second)
|
|
427
|
+
defer keepalive.Stop()
|
|
428
|
+
|
|
429
|
+
for {
|
|
430
|
+
select {
|
|
431
|
+
case <-r.Context().Done():
|
|
432
|
+
return
|
|
433
|
+
case <-s.base.Done():
|
|
434
|
+
// A stream never ends on its own, so without this every shutdown
|
|
435
|
+
// would sit out the full Shutdown grace period waiting on it.
|
|
436
|
+
return
|
|
437
|
+
case payload := <-ch:
|
|
438
|
+
s.touch()
|
|
439
|
+
send("data: ", string(payload), "\n\n")
|
|
440
|
+
case <-keepalive.C:
|
|
441
|
+
// Keeps mobile proxies from closing an idle stream.
|
|
442
|
+
send(": keepalive\n\n")
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* ── lifecycle ──────────────────────────────────────────────────────────── */
|
|
448
|
+
|
|
449
|
+
// Run starts the background workers, listens according to mode, and serves
|
|
450
|
+
// until ctx is cancelled.
|
|
451
|
+
func (s *Server) Run(ctx context.Context, mode access.Mode) error {
|
|
452
|
+
// Assigned before the listener accepts anything, so no handler can observe
|
|
453
|
+
// the placeholder set in New.
|
|
454
|
+
s.base = ctx
|
|
455
|
+
|
|
456
|
+
srv := &http.Server{
|
|
457
|
+
Handler: s.Handler(),
|
|
458
|
+
// No write deadline: an SSE stream is meant to stay open indefinitely.
|
|
459
|
+
ReadHeaderTimeout: 10 * time.Second,
|
|
460
|
+
IdleTimeout: 65 * time.Second,
|
|
461
|
+
}
|
|
462
|
+
mgr := access.NewManager(s.cfg.Port, srv, s.logf)
|
|
463
|
+
if err := mgr.Apply(mode); err != nil {
|
|
464
|
+
return err
|
|
465
|
+
}
|
|
466
|
+
s.accessMu.Lock()
|
|
467
|
+
s.access = mgr
|
|
468
|
+
s.accessMu.Unlock()
|
|
469
|
+
s.banner(mode)
|
|
470
|
+
if s.OnReady != nil {
|
|
471
|
+
go s.OnReady()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
var wg sync.WaitGroup
|
|
475
|
+
wg.Add(3)
|
|
476
|
+
go func() { defer wg.Done(); s.Poll(ctx) }()
|
|
477
|
+
go func() { defer wg.Done(); s.storage.Loop(ctx) }()
|
|
478
|
+
go func() { defer wg.Done(); mgr.Watch(ctx) }()
|
|
479
|
+
|
|
480
|
+
<-ctx.Done()
|
|
481
|
+
|
|
482
|
+
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
483
|
+
defer cancel()
|
|
484
|
+
_ = srv.Shutdown(shutdown)
|
|
485
|
+
mgr.Close()
|
|
486
|
+
s.docker.Close()
|
|
487
|
+
wg.Wait()
|
|
488
|
+
s.storage.Close()
|
|
489
|
+
return nil
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
func (s *Server) banner(mode access.Mode) {
|
|
493
|
+
from := ""
|
|
494
|
+
switch s.accessSource {
|
|
495
|
+
case config.SourceFlag:
|
|
496
|
+
from = " (from --access)"
|
|
497
|
+
case config.SourceEnv:
|
|
498
|
+
from = " (from the environment)"
|
|
499
|
+
case config.SourceFile:
|
|
500
|
+
from = " (from " + s.cfg.File + ")"
|
|
501
|
+
}
|
|
502
|
+
fmt.Printf("kanshi %s — access: %s%s\n", s.version, mode, from)
|
|
503
|
+
for _, l := range s.access.Links() {
|
|
504
|
+
fmt.Printf(" %-10s %s\n", kindLabel(l.Kind)+":", l.URL)
|
|
505
|
+
}
|
|
506
|
+
if mode.All {
|
|
507
|
+
s.logf("warning: access \"all\" listens on every interface, and kanshi has no login")
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
func kindLabel(k access.Kind) string {
|
|
512
|
+
switch k {
|
|
513
|
+
case access.KindLocal:
|
|
514
|
+
return "Local"
|
|
515
|
+
case access.KindLAN:
|
|
516
|
+
return "LAN"
|
|
517
|
+
case access.KindTailscale:
|
|
518
|
+
return "Tailscale"
|
|
519
|
+
case access.KindCustom:
|
|
520
|
+
return "Address"
|
|
521
|
+
}
|
|
522
|
+
return "Other"
|
|
523
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"compress/gzip"
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/hex"
|
|
8
|
+
"io/fs"
|
|
9
|
+
"net/http"
|
|
10
|
+
"path"
|
|
11
|
+
"strconv"
|
|
12
|
+
"strings"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// asset is one embedded file, prepared once at startup: hashed for its ETag
|
|
16
|
+
// and cache-busting URL, and gzipped ahead of time so no request pays for it.
|
|
17
|
+
type asset struct {
|
|
18
|
+
body []byte
|
|
19
|
+
gz []byte // nil when compression would not help (PNGs, tiny files)
|
|
20
|
+
etag string
|
|
21
|
+
ctype string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// assets serves the frontend.
|
|
25
|
+
//
|
|
26
|
+
// index.html refers to every other file as /static/<name>?v=<hash>. The hash
|
|
27
|
+
// changes whenever the file does, so those URLs can be cached forever and a
|
|
28
|
+
// returning phone downloads nothing but the page itself — which it only
|
|
29
|
+
// revalidates, with a 304 when nothing changed.
|
|
30
|
+
type assets struct {
|
|
31
|
+
dev bool // KANSHI_WEB_DIR: read from disk on every request instead
|
|
32
|
+
web fs.FS
|
|
33
|
+
index *asset
|
|
34
|
+
files map[string]*asset
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func loadAssets(web fs.FS, dev bool) (*assets, error) {
|
|
38
|
+
a := &assets{dev: dev, web: web, files: make(map[string]*asset)}
|
|
39
|
+
if dev {
|
|
40
|
+
return a, nil
|
|
41
|
+
}
|
|
42
|
+
entries, err := fs.ReadDir(web, ".")
|
|
43
|
+
if err != nil {
|
|
44
|
+
return nil, err
|
|
45
|
+
}
|
|
46
|
+
for _, e := range entries {
|
|
47
|
+
if e.IsDir() || e.Name() == "index.html" {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
body, err := fs.ReadFile(web, e.Name())
|
|
51
|
+
if err != nil {
|
|
52
|
+
return nil, err
|
|
53
|
+
}
|
|
54
|
+
a.files[e.Name()] = newAsset(e.Name(), body)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
index, err := fs.ReadFile(web, "index.html")
|
|
58
|
+
if err != nil {
|
|
59
|
+
return nil, err
|
|
60
|
+
}
|
|
61
|
+
for name, f := range a.files {
|
|
62
|
+
index = bytes.ReplaceAll(index, []byte(`"/static/`+name+`"`), []byte(`"/static/`+name+`?v=`+f.etag+`"`))
|
|
63
|
+
}
|
|
64
|
+
a.index = newAsset("index.html", index)
|
|
65
|
+
return a, nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func newAsset(name string, body []byte) *asset {
|
|
69
|
+
sum := sha256.Sum256(body)
|
|
70
|
+
a := &asset{body: body, etag: hex.EncodeToString(sum[:8]), ctype: contentType(name)}
|
|
71
|
+
if strings.HasPrefix(a.ctype, "text/") || strings.HasPrefix(a.ctype, "image/svg") {
|
|
72
|
+
var buf bytes.Buffer
|
|
73
|
+
gz, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
|
74
|
+
_, _ = gz.Write(body)
|
|
75
|
+
_ = gz.Close()
|
|
76
|
+
if buf.Len() < len(body)*9/10 {
|
|
77
|
+
a.gz = buf.Bytes()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return a
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// contentType is a fixed table rather than mime.TypeByExtension, which on
|
|
84
|
+
// Windows reads the registry — where a stray install can map .js to
|
|
85
|
+
// text/plain and stop the browser from running the script.
|
|
86
|
+
func contentType(name string) string {
|
|
87
|
+
switch path.Ext(name) {
|
|
88
|
+
case ".html":
|
|
89
|
+
return "text/html; charset=utf-8"
|
|
90
|
+
case ".css":
|
|
91
|
+
return "text/css; charset=utf-8"
|
|
92
|
+
case ".js":
|
|
93
|
+
return "text/javascript; charset=utf-8"
|
|
94
|
+
case ".svg":
|
|
95
|
+
return "image/svg+xml"
|
|
96
|
+
case ".png":
|
|
97
|
+
return "image/png"
|
|
98
|
+
case ".ico":
|
|
99
|
+
return "image/x-icon"
|
|
100
|
+
case ".json", ".webmanifest":
|
|
101
|
+
return "application/json"
|
|
102
|
+
}
|
|
103
|
+
return "application/octet-stream"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func (a *assets) serveIndex(w http.ResponseWriter, r *http.Request) {
|
|
107
|
+
if a.dev {
|
|
108
|
+
a.serveDev(w, r, "index.html")
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
a.write(w, r, a.index, false)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func (a *assets) serveStatic(w http.ResponseWriter, r *http.Request) {
|
|
115
|
+
name := strings.TrimPrefix(r.URL.Path, "/static/")
|
|
116
|
+
if a.dev {
|
|
117
|
+
a.serveDev(w, r, name)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
f := a.files[name]
|
|
121
|
+
if f == nil {
|
|
122
|
+
http.NotFound(w, r)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
a.write(w, r, f, r.URL.Query().Get("v") == f.etag)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func (a *assets) write(w http.ResponseWriter, r *http.Request, f *asset, immutable bool) {
|
|
129
|
+
h := w.Header()
|
|
130
|
+
h.Set("Content-Type", f.ctype)
|
|
131
|
+
h.Set("Vary", "Accept-Encoding")
|
|
132
|
+
if immutable {
|
|
133
|
+
h.Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
134
|
+
} else {
|
|
135
|
+
h.Set("Cache-Control", "no-cache")
|
|
136
|
+
}
|
|
137
|
+
// Weak, because the gzipped and plain bodies share it.
|
|
138
|
+
h.Set("ETag", `W/"`+f.etag+`"`)
|
|
139
|
+
if strings.Contains(r.Header.Get("If-None-Match"), `"`+f.etag+`"`) {
|
|
140
|
+
w.WriteHeader(http.StatusNotModified)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
body := f.body
|
|
144
|
+
if f.gz != nil && acceptsGzip(r) {
|
|
145
|
+
h.Set("Content-Encoding", "gzip")
|
|
146
|
+
body = f.gz
|
|
147
|
+
}
|
|
148
|
+
h.Set("Content-Length", strconv.Itoa(len(body)))
|
|
149
|
+
if r.Method != http.MethodHead {
|
|
150
|
+
_, _ = w.Write(body)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// serveDev reads straight from KANSHI_WEB_DIR, uncached, so an edit shows up
|
|
155
|
+
// on the next reload.
|
|
156
|
+
func (a *assets) serveDev(w http.ResponseWriter, r *http.Request, name string) {
|
|
157
|
+
body, err := fs.ReadFile(a.web, name)
|
|
158
|
+
if err != nil {
|
|
159
|
+
http.NotFound(w, r)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
w.Header().Set("Content-Type", contentType(name))
|
|
163
|
+
w.Header().Set("Cache-Control", "no-store")
|
|
164
|
+
_, _ = w.Write(body)
|
|
165
|
+
}
|