Files
HODA/internal/config/config.go
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

113 lines
3.4 KiB
Go

// Package config reads HODA's settings from environment variables.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
)
// Mode selects what an instance does: the primary serves the dashboard, an
// agent only reports its own host's apps to the primary.
type Mode string
const (
ModePrimary Mode = "primary"
ModeAgent Mode = "agent"
)
// Config holds every setting; the README lists what each one does.
type Config struct {
Mode Mode
Port string
DockerHost string
LabelPrefix string
Title string
NodeName string
Peers []string
PeerToken string
PeerInterval time.Duration
LinkTarget string
ResyncInterval time.Duration
IconRefresh time.Duration
IconCDN string
DataDir string
LogLevel slog.Level
}
// Load reads the configuration from the process environment.
func Load() (Config, error) {
return parse(os.Getenv)
}
func parse(getenv func(string) string) (Config, error) {
get := func(key, fallback string) string {
if v := strings.TrimSpace(getenv(key)); v != "" {
return v
}
return fallback
}
c := Config{
Mode: Mode(strings.ToLower(get("MODE", string(ModePrimary)))),
Port: get("PORT", "8080"),
DockerHost: get("DOCKER_HOST", "unix:///var/run/docker.sock"),
LabelPrefix: strings.TrimSuffix(get("LABEL_PREFIX", "sema.dashboard"), "."),
Title: get("DASHBOARD_TITLE", "HODA"),
NodeName: get("NODE_NAME", ""),
PeerToken: get("PEER_TOKEN", ""),
LinkTarget: get("LINK_TARGET", "_blank"),
IconCDN: strings.TrimSuffix(get("ICON_CDN", "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons"), "/"),
DataDir: get("DATA_DIR", "/data"),
}
if c.NodeName == "" {
c.NodeName, _ = os.Hostname()
}
for _, p := range strings.Split(getenv("PEERS"), ",") {
if p = strings.TrimSuffix(strings.TrimSpace(p), "/"); p != "" {
c.Peers = append(c.Peers, p)
}
}
var errs []error
duration := func(key, fallback string) time.Duration {
raw := get(key, fallback)
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
errs = append(errs, fmt.Errorf("%s=%q is not a positive duration such as 5s or 24h", key, raw))
}
return d
}
c.PeerInterval = duration("PEER_INTERVAL", "5s")
c.ResyncInterval = duration("RESYNC_INTERVAL", "60s")
c.IconRefresh = duration("ICON_REFRESH", "24h")
if err := c.LogLevel.UnmarshalText([]byte(get("LOG_LEVEL", "info"))); err != nil {
errs = append(errs, errors.New("LOG_LEVEL must be debug, info, warn or error"))
}
if c.Mode != ModePrimary && c.Mode != ModeAgent {
errs = append(errs, fmt.Errorf("MODE=%q must be primary or agent", c.Mode))
}
if n, err := strconv.Atoi(c.Port); err != nil || n < 1 || n > 65535 {
errs = append(errs, fmt.Errorf("PORT=%q is not a valid port", c.Port))
}
if c.LinkTarget != "_blank" && c.LinkTarget != "_self" {
errs = append(errs, fmt.Errorf("LINK_TARGET=%q must be _blank or _self", c.LinkTarget))
}
if c.Mode == ModeAgent && len(c.Peers) > 0 {
errs = append(errs, errors.New("PEERS only applies to MODE=primary"))
}
if (c.Mode == ModeAgent || len(c.Peers) > 0) && c.PeerToken == "" {
errs = append(errs, errors.New("PEER_TOKEN is required when MODE=agent or PEERS is set"))
}
for _, p := range c.Peers {
if !strings.HasPrefix(p, "http://") && !strings.HasPrefix(p, "https://") {
errs = append(errs, fmt.Errorf("PEERS entry %q must start with http:// or https://", p))
}
}
return c, errors.Join(errs...)
}