+139
-82
@@ -1,14 +1,15 @@
|
||||
package main
|
||||
|
||||
//test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -25,23 +26,22 @@ import (
|
||||
|
||||
type Item struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"` // monitor | response
|
||||
Script string `yaml:"script"`
|
||||
Refresh int `yaml:"refresh"` // monitor: seconds
|
||||
Data string `yaml:"data"` // monitor: text | percentage
|
||||
Args int `yaml:"args"` // response: arg count
|
||||
ArgTypes []string `yaml:"arg_types"` // response: text | number per arg
|
||||
Args int `yaml:"args"` // exec: arg count
|
||||
ArgTypes []string `yaml:"arg_types"` // exec: text | number per arg
|
||||
}
|
||||
|
||||
// Section is a full view on the web page: a list of items plus sidebar metadata.
|
||||
type Section struct {
|
||||
Name string `yaml:"name"`
|
||||
Icon string `yaml:"icon"`
|
||||
Items []Item `yaml:"items"`
|
||||
type Link struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Sections []Section `yaml:"sections"`
|
||||
Links []Link `yaml:"links"`
|
||||
Monitor []Item `yaml:"monitor"`
|
||||
Exec []Item `yaml:"exec"`
|
||||
}
|
||||
|
||||
func loadConfig(path string) (*Config, error) {
|
||||
@@ -56,27 +56,21 @@ func loadConfig(path string) (*Config, error) {
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
for _, sec := range cfg.Sections {
|
||||
for _, it := range sec.Items {
|
||||
switch it.Type {
|
||||
case "monitor":
|
||||
if it.Refresh <= 0 {
|
||||
return nil, fmt.Errorf("item %q: refresh must be > 0", it.Name)
|
||||
}
|
||||
if it.Data != "text" && it.Data != "percentage" {
|
||||
return nil, fmt.Errorf("item %q: data must be text|percentage", it.Name)
|
||||
}
|
||||
case "response":
|
||||
if len(it.ArgTypes) != it.Args {
|
||||
return nil, fmt.Errorf("item %q: arg_types must have args entries", it.Name)
|
||||
}
|
||||
for _, t := range it.ArgTypes {
|
||||
if t != "text" && t != "number" {
|
||||
return nil, fmt.Errorf("item %q: arg type must be text|number", it.Name)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("item %q: unknown type %q", it.Name, it.Type)
|
||||
for _, it := range cfg.Monitor {
|
||||
if it.Refresh <= 0 {
|
||||
return nil, fmt.Errorf("monitor %q: refresh must be > 0", it.Name)
|
||||
}
|
||||
if it.Data != "text" && it.Data != "percentage" {
|
||||
return nil, fmt.Errorf("monitor %q: data must be text|percentage", it.Name)
|
||||
}
|
||||
}
|
||||
for _, it := range cfg.Exec {
|
||||
if len(it.ArgTypes) != it.Args {
|
||||
return nil, fmt.Errorf("exec %q: arg_types must have args entries", it.Name)
|
||||
}
|
||||
for _, t := range it.ArgTypes {
|
||||
if t != "text" && t != "number" {
|
||||
return nil, fmt.Errorf("exec %q: arg type must be text|number", it.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,8 +81,9 @@ func loadConfig(path string) (*Config, error) {
|
||||
|
||||
type server struct {
|
||||
cfg *Config
|
||||
scripts map[string]*Item // by item name, read-only after start
|
||||
monVals sync.Map // item name -> string (last monitor output)
|
||||
exec map[string]*Item // by item name, read-only after start
|
||||
monVals sync.Map // monitor item name -> string (last output)
|
||||
docsDir string
|
||||
}
|
||||
|
||||
const execTimeout = 10 * time.Second
|
||||
@@ -104,56 +99,113 @@ func runScript(s *Item, args ...string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *server) startMonitors() {
|
||||
for _, sec := range s.cfg.Sections {
|
||||
for _, it := range sec.Items {
|
||||
if it.Type != "monitor" {
|
||||
continue
|
||||
}
|
||||
sc := it
|
||||
log.Printf("monitor started: %s (%s) every %ds", sc.Name, sc.Script, sc.Refresh)
|
||||
go func(sc *Item) {
|
||||
for {
|
||||
v, err := runScript(sc)
|
||||
if err != nil {
|
||||
log.Printf("monitor %s: %v", sc.Name, err)
|
||||
v = "error: " + err.Error()
|
||||
} else {
|
||||
log.Printf("monitor %s -> %s", sc.Name, strings.TrimSpace(v))
|
||||
}
|
||||
s.monVals.Store(sc.Name, v)
|
||||
time.Sleep(time.Duration(sc.Refresh) * time.Second)
|
||||
for i := range s.cfg.Monitor {
|
||||
sc := &s.cfg.Monitor[i]
|
||||
log.Printf("monitor started: %s (%s) every %ds", sc.Name, sc.Script, sc.Refresh)
|
||||
go func(sc *Item) {
|
||||
for {
|
||||
v, err := runScript(sc)
|
||||
if err != nil {
|
||||
log.Printf("monitor %s: %v", sc.Name, err)
|
||||
v = "error: " + err.Error()
|
||||
} else {
|
||||
log.Printf("monitor %s -> %s", sc.Name, strings.TrimSpace(v))
|
||||
}
|
||||
}(&sc)
|
||||
}
|
||||
s.monVals.Store(sc.Name, v)
|
||||
time.Sleep(time.Duration(sc.Refresh) * time.Second)
|
||||
}
|
||||
}(sc)
|
||||
}
|
||||
}
|
||||
|
||||
func toProto(it *Item, val string) *homelabv1.Item {
|
||||
p := &homelabv1.Item{Name: it.Name, Type: it.Type, Data: it.Data, Args: int32(it.Args), ArgTypes: it.ArgTypes}
|
||||
if it.Type == "monitor" {
|
||||
p.Value = val
|
||||
}
|
||||
return p
|
||||
// --- docs ---
|
||||
|
||||
func docHash(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (s *server) ListSections(context.Context, *connect.Request[homelabv1.ListSectionsRequest]) (*connect.Response[homelabv1.ListSectionsResponse], error) {
|
||||
resp := &homelabv1.ListSectionsResponse{}
|
||||
for _, sec := range s.cfg.Sections {
|
||||
ps := &homelabv1.Section{Name: sec.Name, Icon: sec.Icon}
|
||||
for _, it := range sec.Items {
|
||||
val, _ := s.monVals.Load(it.Name)
|
||||
v, _ := val.(string)
|
||||
ps.Items = append(ps.Items, toProto(&it, v))
|
||||
// docPath resolves a doc name safely: plain file name only, no separators, always inside docsDir.
|
||||
func (s *server) docPath(name string) (string, error) {
|
||||
if filepath.Base(name) != name || filepath.Ext(name) != ".md" {
|
||||
return "", fmt.Errorf("bad doc name %q", name)
|
||||
}
|
||||
p := filepath.Join(s.docsDir, name)
|
||||
if filepath.Dir(p) != s.docsDir {
|
||||
return "", fmt.Errorf("bad doc name %q", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *server) ListDocs(context.Context, *connect.Request[homelabv1.ListDocsRequest]) (*connect.Response[homelabv1.ListDocsResponse], error) {
|
||||
resp := &homelabv1.ListDocsResponse{}
|
||||
entries, err := os.ReadDir(s.docsDir)
|
||||
if err != nil {
|
||||
return connect.NewResponse(resp), nil // no docs dir = no docs
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
resp.Sections = append(resp.Sections, ps)
|
||||
b, err := os.ReadFile(filepath.Join(s.docsDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp.Docs = append(resp.Docs, &homelabv1.DocMeta{Name: e.Name(), Hash: docHash(b)})
|
||||
}
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *server) GetDoc(_ context.Context, req *connect.Request[homelabv1.GetDocRequest]) (*connect.Response[homelabv1.GetDocResponse], error) {
|
||||
p, err := s.docPath(req.Msg.GetName())
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
return connect.NewResponse(&homelabv1.GetDocResponse{Content: string(b)}), nil
|
||||
}
|
||||
|
||||
func (s *server) SaveDoc(_ context.Context, req *connect.Request[homelabv1.SaveDocRequest]) (*connect.Response[homelabv1.SaveDocResponse], error) {
|
||||
p, err := s.docPath(req.Msg.GetName())
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, err)
|
||||
}
|
||||
content := []byte(req.Msg.GetContent())
|
||||
// ponytail: client authority — last write wins, no base-hash check; add optimistic locking if concurrent edits appear
|
||||
if err := os.WriteFile(p, content, 0o644); err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, err)
|
||||
}
|
||||
log.Printf("doc saved: %s (%d bytes)", filepath.Base(p), len(content))
|
||||
return connect.NewResponse(&homelabv1.SaveDocResponse{Hash: docHash(content)}), nil
|
||||
}
|
||||
|
||||
// --- content RPC ---
|
||||
|
||||
func (s *server) ListContent(context.Context, *connect.Request[homelabv1.ListContentRequest]) (*connect.Response[homelabv1.ListContentResponse], error) {
|
||||
resp := &homelabv1.ListContentResponse{}
|
||||
for _, l := range s.cfg.Links {
|
||||
resp.Links = append(resp.Links, &homelabv1.Link{Name: l.Name, Url: l.URL})
|
||||
}
|
||||
for i := range s.cfg.Monitor {
|
||||
it := &s.cfg.Monitor[i]
|
||||
val, _ := s.monVals.Load(it.Name)
|
||||
v, _ := val.(string)
|
||||
resp.Monitors = append(resp.Monitors, &homelabv1.Item{Name: it.Name, Data: it.Data, Value: v})
|
||||
}
|
||||
for i := range s.cfg.Exec {
|
||||
it := &s.cfg.Exec[i]
|
||||
resp.Exec = append(resp.Exec, &homelabv1.Item{Name: it.Name, Args: int32(it.Args), ArgTypes: it.ArgTypes})
|
||||
}
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *server) RunScript(_ context.Context, req *connect.Request[homelabv1.RunScriptRequest]) (*connect.Response[homelabv1.RunScriptResponse], error) {
|
||||
it, ok := s.scripts[req.Msg.GetName()]
|
||||
if !ok || it.Type != "response" {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no response script %q", req.Msg.GetName()))
|
||||
it, ok := s.exec[req.Msg.GetName()]
|
||||
if !ok {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("no exec item %q", req.Msg.GetName()))
|
||||
}
|
||||
if len(req.Msg.GetArgs()) != it.Args {
|
||||
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%q takes %d args, got %d", it.Name, it.Args, len(req.Msg.GetArgs())))
|
||||
@@ -180,22 +232,26 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
svc := &server{cfg: cfg, scripts: map[string]*Item{}}
|
||||
for _, sec := range cfg.Sections {
|
||||
for i := range sec.Items {
|
||||
svc.scripts[sec.Items[i].Name] = &sec.Items[i]
|
||||
}
|
||||
docsDir := "docs"
|
||||
if err := os.MkdirAll(docsDir, 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "docs:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
svc := &server{cfg: cfg, exec: map[string]*Item{}, docsDir: docsDir}
|
||||
for i := range cfg.Exec {
|
||||
svc.exec[cfg.Exec[i].Name] = &cfg.Exec[i]
|
||||
}
|
||||
svc.startMonitors()
|
||||
|
||||
log.Printf("config %s: %d sections, %d items", cfgPath, len(cfg.Sections), len(svc.scripts))
|
||||
log.Printf("config %s: %d links, %d monitors, %d exec", cfgPath, len(cfg.Links), len(cfg.Monitor), len(cfg.Exec))
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
// HTTP request log, except the ListSections heartbeat (every 3s per client — pure noise).
|
||||
// HTTP request log, except the ListContent heartbeat (every 3s per client — pure noise).
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return c.Request().URL.Path == "/homelab.v1.HomelabService/ListSections"
|
||||
return c.Request().URL.Path == "/homelab.v1.HomelabService/ListContent"
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -204,10 +260,11 @@ func main() {
|
||||
mux.Handle(homelabv1connect.NewHomelabServiceHandler(svc))
|
||||
e.Any("/homelab.v1.HomelabService/*", echo.WrapHandler(mux))
|
||||
|
||||
// sw.js must never be cached, otherwise iOS serves a stale service worker.
|
||||
// sw.js and index.html must never be cached: stale index = stale asset references = iOS never updates.
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if c.Request().URL.Path == "/sw.js" || c.Request().URL.Path == "/manifest.webmanifest" {
|
||||
p := c.Request().URL.Path
|
||||
if p == "/sw.js" || p == "/manifest.webmanifest" || p == "/index.html" || p == "/" {
|
||||
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
return next(c)
|
||||
|
||||
Reference in New Issue
Block a user