A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is what make release already writes and what install.sh already downloads. The binary is checked against SHA256SUMS before it goes near the running one, and a token is sent when the source is behind a login, so a private Gitea serves its own fleet without opening the instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the installer never disagree about which build is newer. Settings gains an Updates card: installed, published, when it last looked, the source, release notes, Check for updates, and Install. The rail badges Settings when a newer build is out. Nothing installs unasked — it restarts the daemon and drops terminals. scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release. Deliberate, and not part of deploy: deploy pushes to the machines this hub owns, publishing offers the build to every install anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
5.4 KiB
Go
177 lines
5.4 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"`
|
|
}
|
|
|
|
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"`
|
|
// DemoURL, when set, is linked from the Widgets tab: a page outside Ham Lab that
|
|
// shows the sensor API being used (examples/external-demo).
|
|
DemoURL string `json:"demo_url,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)
|
|
}
|