Files
hamlab/internal/config/config.go
T
Mitchell-JohnsonandClaude Opus 5 fd88c4ff78 A KVM tab: paste an address, get the device's own page
Ham Lab proxies each KVM from its own origin, which is what makes the simple version
possible. A KVM that speaks only plain HTTP would otherwise be blocked outright as mixed
content in a TLS console; served from here it loads, it is behind this login rather than a
second one, and the iframe is same-origin.

Nothing in it knows anything about any particular device. The prefix /kvm/<id>/ is only the
entry point — a device UI asks for its assets by absolute path, which no prefix can rewrite
away, so any request Ham Lab does not serve itself is routed back to whichever KVM the
asking page belongs to, worked out from the Referer and from a cookie when there is no
Referer, as on a WebSocket upgrade. Ham Lab's own routes always match first, so a device can
never shadow the console, and the fallback is gated on a live session.

The list is hub-side on purpose: a KVM earns its keep when the machine is down, which is
when that machine's own Ham Lab cannot answer.

On the keyboard, a page cannot forward keys into an iframe and does not need to — one with
focus receives them directly. Fullscreen plus navigator.keyboard.lock() is what takes
Escape, Tab and Ctrl-W back from the browser; that is Chromium-only and the tab says so
rather than pretending otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:39:27 -07:00

190 lines
6.0 KiB
Go

// Package config loads and saves hamlab's JSON config file.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"hamlab/internal/store"
)
// Token is an API token another Ham Lab (a hub) uses to manage this host. Only the
// SHA-256 of the token is kept.
type Token struct {
Name string `json:"name"`
Hash string `json:"hash"`
Created time.Time `json:"created"`
// Scope is what the token may do. "" is everything (a hub's token: full control of
// this machine). ScopeSensorsRead only reads sensors — what a dashboard gets.
Scope string `json:"scope,omitempty"`
// Sensors narrows a sensors:read token to the IDs these patterns match. Empty is all.
Sensors []string `json:"sensors,omitempty"`
// IssuedBy is the account that made the token (or paired the hub that holds it).
IssuedBy string `json:"issued_by,omitempty"`
}
const ScopeSensorsRead = "sensors:read"
// Node is a remote Ham Lab this host (as hub) manages. The token is the one that
// node issued to us; the fingerprint pins its self-signed certificate.
type Node struct {
Name string `json:"name"`
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Token string `json:"token"`
Added time.Time `json:"added"`
}
// User is an account on this machine's console. Only the argon2id hash is kept.
type User struct {
Name string `json:"name"`
Hash string `json:"hash"`
Created time.Time `json:"created"`
LastSeen time.Time `json:"last_seen,omitempty"`
}
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
// is Updates, not Update, because Config already has an Update method.
type UpdateSource struct {
// URL is a Gitea/Forgejo repo API base or a static directory; empty means the built-in
// default, and "off" means never look.
URL string `json:"url,omitempty"`
// Token is sent when the source is behind a login, so a private Gitea can serve its
// own fleet without the instance being opened to everyone.
Token string `json:"token,omitempty"`
// Check is "daily" (the default) or "never".
Check string `json:"check,omitempty"`
}
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
// the hub and proxies each one, because a KVM matters exactly when the machine it is
// attached to cannot answer for itself.
type KVM struct {
ID string `json:"id"`
Name string `json:"name"`
// URL is the device's own address, as you would type it into a browser on the LAN.
URL string `json:"url"`
// Machine is which host it is attached to, for display only — it need not be a machine
// Ham Lab manages, and usually the point is that it is not answering.
Machine string `json:"machine,omitempty"`
Added time.Time `json:"added"`
}
type Config struct {
Listen string `json:"listen"`
// PasswordHash is the single login of installs before users existed. On load it becomes
// the first user (see MigrateUsers) and is then dropped from the file.
PasswordHash string `json:"password_hash,omitempty"`
Users []User `json:"users,omitempty"`
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
KVMs []KVM `json:"kvms,omitempty"`
// Updates: where a newer build comes from. See UpdateSource.
Updates UpdateSource `json:"update,omitempty"`
path string
mu sync.Mutex
}
// Candidates lists where config.json may live under a home directory, preferred first:
// the XDG location, then ~/apps/hamlab, which installs before v0.2 used.
func Candidates(home string) []string {
return []string{filepath.Join(home, ".config", "hamlab", "config.json"), filepath.Join(home, "apps", "hamlab", "config.json")}
}
// DefaultPath is the config used when neither -config nor $HAMLAB_CONFIG says otherwise:
// whichever candidate exists, else the preferred one.
func DefaultPath() string {
home, _ := os.UserHomeDir()
c := Candidates(home)
for _, p := range c {
if _, err := os.Stat(p); err == nil {
return p
}
}
return c[0]
}
func Default(path string) *Config {
return &Config{Listen: "127.0.0.1:8100", path: path}
}
// Load reads the config at path. A missing file yields the defaults.
func Load(path string) (*Config, error) {
c := Default(path)
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return c, nil
}
// MigrateUsers turns the pre-users single password into the first account, named after
// the OS user the console runs as. Reports whether anything changed (the caller saves).
func (c *Config) MigrateUsers(name string) bool {
if len(c.Users) > 0 || c.PasswordHash == "" {
return false
}
c.Users = []User{{Name: name, Hash: c.PasswordHash, Created: time.Now()}}
c.PasswordHash = ""
return true
}
// User finds an account by name. Call under View/Update or on a config nobody else holds.
func (c *Config) User(name string) (User, bool) {
for _, u := range c.Users {
if u.Name == name {
return u, true
}
}
return User{}, false
}
func (c *Config) Path() string { return c.path }
func (c *Config) DataDir() string { return filepath.Dir(c.path) }
func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.save()
}
func (c *Config) save() error {
if err := os.MkdirAll(c.DataDir(), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return store.WriteFileAtomic(c.path, append(b, '\n'), 0o600)
}
// Update applies fn under the lock and saves.
func (c *Config) Update(fn func(*Config)) error {
c.mu.Lock()
defer c.mu.Unlock()
fn(c)
return c.save()
}
// View reads under the lock.
func (c *Config) View(fn func(*Config)) {
c.mu.Lock()
defer c.mu.Unlock()
fn(c)
}