Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package config

import (
"encoding/json"
"flag"
"fmt"
"os"
"strings"
)

// Load reads a JSON config file and returns it as a map.
func Load(path string) (map[string]interface{}, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()

var cfg map[string]interface{}
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return nil, err
}
return cfg, nil
}

// ApplyToFlags overrides flag defaults from config for any flag not
// explicitly set on the command line. Call this AFTER flag.Parse().
// Keys in the config can use either hyphens or underscores (e.g.
// "log-level" or "log_level" both match the -log-level flag).
func ApplyToFlags(cfg map[string]interface{}) {
explicit := make(map[string]bool)
flag.Visit(func(f *flag.Flag) {
explicit[f.Name] = true
})

flag.VisitAll(func(f *flag.Flag) {
if explicit[f.Name] {
return
}
val, ok := cfg[f.Name]
if !ok {
// Try underscore variant: log-level → log_level
val, ok = cfg[strings.ReplaceAll(f.Name, "-", "_")]
}
if !ok {
return
}
switch v := val.(type) {
case string:
f.Value.Set(v)
case float64:
f.Value.Set(fmt.Sprintf("%v", v))
case bool:
f.Value.Set(fmt.Sprintf("%v", v))
}
})
}
180 changes: 180 additions & 0 deletions config/zz_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package config_test

import (
"flag"
"os"
"path/filepath"
"testing"

"github.com/pilot-protocol/common/config"
)

func TestLoadValidJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cfg.json")
body := `{"log_level":"debug","port":8080,"verbose":true}`
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg["log_level"] != "debug" {
t.Errorf("log_level = %v, want debug", cfg["log_level"])
}
if cfg["port"].(float64) != 8080 {
t.Errorf("port = %v, want 8080", cfg["port"])
}
if cfg["verbose"] != true {
t.Errorf("verbose = %v, want true", cfg["verbose"])
}
}

func TestLoadMissingFile(t *testing.T) {
_, err := config.Load("/nonexistent/path/cfg.json")
if err == nil {
t.Fatal("expected error for missing file")
}
}

func TestLoadMalformedJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
if err := os.WriteFile(path, []byte("{not json"), 0644); err != nil {
t.Fatal(err)
}
_, err := config.Load(path)
if err == nil {
t.Fatal("expected parse error")
}
}

// ApplyToFlags tests must serialize because flag package has global state.
// We use a dedicated FlagSet per test, but package-level flag.Visit reads
// flag.CommandLine — so we temporarily swap it.
func withFreshCommandLine(t *testing.T) *flag.FlagSet {
t.Helper()
saved := flag.CommandLine
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
t.Cleanup(func() { flag.CommandLine = saved })
return flag.CommandLine
}

func TestApplyToFlagsSetsUnsetFlags(t *testing.T) {
fs := withFreshCommandLine(t)
var level string
var port int
var verbose bool
fs.StringVar(&level, "log-level", "info", "")
fs.IntVar(&port, "port", 9000, "")
fs.BoolVar(&verbose, "verbose", false, "")

// Parse with no args so nothing is explicitly set
if err := fs.Parse(nil); err != nil {
t.Fatalf("Parse: %v", err)
}

cfg := map[string]interface{}{
"log-level": "debug",
"port": float64(8080),
"verbose": true,
}
config.ApplyToFlags(cfg)

if level != "debug" {
t.Errorf("log-level = %q, want debug", level)
}
if port != 8080 {
t.Errorf("port = %d, want 8080", port)
}
if verbose != true {
t.Errorf("verbose = %v, want true", verbose)
}
}

func TestApplyToFlagsPreservesExplicitlySetFlags(t *testing.T) {
fs := withFreshCommandLine(t)
var level string
fs.StringVar(&level, "log-level", "info", "")

// Explicitly set on the command line — config must NOT override.
if err := fs.Parse([]string{"-log-level=warn"}); err != nil {
t.Fatalf("Parse: %v", err)
}

cfg := map[string]interface{}{"log-level": "debug"}
config.ApplyToFlags(cfg)

if level != "warn" {
t.Errorf("log-level = %q, want warn (explicit flag must win over config)", level)
}
}

func TestApplyToFlagsUnderscoreVariantMatches(t *testing.T) {
fs := withFreshCommandLine(t)
var level string
fs.StringVar(&level, "log-level", "info", "")
if err := fs.Parse(nil); err != nil {
t.Fatal(err)
}

// Config uses underscore; flag uses hyphen. ApplyToFlags should match them.
cfg := map[string]interface{}{"log_level": "debug"}
config.ApplyToFlags(cfg)

if level != "debug" {
t.Errorf("log-level = %q, want debug (underscore→hyphen match)", level)
}
}

func TestApplyToFlagsHyphenVariantTakesPrecedenceOverUnderscore(t *testing.T) {
fs := withFreshCommandLine(t)
var level string
fs.StringVar(&level, "log-level", "info", "")
if err := fs.Parse(nil); err != nil {
t.Fatal(err)
}

// If both keys present, the exact flag-name match (log-level) must win.
cfg := map[string]interface{}{
"log-level": "debug",
"log_level": "warn",
}
config.ApplyToFlags(cfg)

if level != "debug" {
t.Errorf("log-level = %q, want debug (exact match wins)", level)
}
}

func TestApplyToFlagsIgnoresUnknownKeys(t *testing.T) {
fs := withFreshCommandLine(t)
var level string
fs.StringVar(&level, "log-level", "info", "")
if err := fs.Parse(nil); err != nil {
t.Fatal(err)
}
config.ApplyToFlags(map[string]interface{}{"unrelated-flag": "xyz"})
if level != "info" {
t.Errorf("log-level changed unexpectedly: %q", level)
}
}

func TestApplyToFlagsSkipsUnsupportedTypes(t *testing.T) {
fs := withFreshCommandLine(t)
var level string
fs.StringVar(&level, "log-level", "info", "")
if err := fs.Parse(nil); err != nil {
t.Fatal(err)
}
// Nested map / array — should be silently skipped (not panic)
config.ApplyToFlags(map[string]interface{}{
"log-level": map[string]interface{}{"nested": "value"},
})
if level != "info" {
t.Errorf("log-level changed from nested map: %q (unsupported type should skip)", level)
}
}
19 changes: 19 additions & 0 deletions coreapi/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

// Package coreapi defines the L10 plugin runtime contract.
//
// The interfaces in this package are the only surface a plugin
// (L11) ever sees of the daemon. Plugins import coreapi; the daemon
// implements coreapi; the bridge happens at lifecycle bootstrap
// (cmd/daemon/main.go registers concrete plugins against the
// daemon's coreapi implementations).
//
// See docs/architecture/01-LAYERS.md §10 for the layer's role,
// docs/architecture/03-INVARIANTS.md for the principles this
// package enforces, and docs/architecture/06-CHANGES.md §2 for
// the rationale of each interface signature.
//
// Stability contract: every exported identifier in this package is
// part of the daemon-plugin ABI. Removing or renaming any of them
// breaks every plugin. Additions are forward-compatible.
package coreapi
22 changes: 22 additions & 0 deletions coreapi/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package coreapi

import "errors"

// Sentinel errors returned by the L10 surface.
var (
// ErrRegistryStarted is returned by ServiceRegistry.Register and
// ServiceRegistry.StartAll when StartAll has already been called.
// Plugins must register before bootstrap.
ErrRegistryStarted = errors.New("coreapi: service registry already started")

// ErrServiceNotReady indicates a Service.Start call was made on a
// dependency that itself hasn't completed Start. Surface only —
// Service implementations shouldn't return this; the registry will.
ErrServiceNotReady = errors.New("coreapi: dependency service not ready")

// ErrPeerNotFound is the canonical "directory has no record" error
// from PeerResolver. Plugins should match on errors.Is.
ErrPeerNotFound = errors.New("coreapi: peer not found")
)
31 changes: 31 additions & 0 deletions coreapi/events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package coreapi

import "time"

// Event is one item published to the EventBus. Topics are
// dot-namespaced (e.g., "tunnel.established", "security.nonce_replay").
// Payload keys/values are plugin-defined; subscribers parse them.
type Event struct {
Topic string
NodeID uint32
Time time.Time
Payload map[string]any
}

// EventBus is the publish/subscribe channel that replaces inline
// webhook.Emit calls inside core layers. Core (L2-L7) publishes;
// the webhook plugin (and any other observability plugin) subscribes.
//
// Publish is non-blocking. If the bus is over capacity, the event is
// dropped (and a metric counter is incremented inside the daemon
// implementation). This keeps L2 readLoop / L6 decrypt latency bounded.
//
// Subscribe returns a buffered channel and an unsubscribe func. Pattern
// is a glob: "tunnel.*" matches "tunnel.established" but not
// "security.nonce_replay".
type EventBus interface {
Publish(topic string, payload map[string]any)
Subscribe(pattern string) (<-chan Event, func())
}
15 changes: 15 additions & 0 deletions coreapi/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package coreapi

import "crypto/ed25519"

// Identity is the daemon's own identity — its Ed25519 keypair, its
// stable nodeID, its 48-bit address. Plugins may sign arbitrary bytes
// (e.g., for plugin-level auth proofs) but cannot replace the identity.
type Identity interface {
NodeID() uint32
Address() Addr
PublicKey() ed25519.PublicKey
Sign(msg []byte) ([]byte, error)
}
Loading
Loading