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
33 changes: 33 additions & 0 deletions cmd/fj-bellows/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/hstern/fj-bellows/internal/forgejo"
"github.com/hstern/fj-bellows/internal/orchestrator"
"github.com/hstern/fj-bellows/internal/provider"
"github.com/hstern/fj-bellows/internal/transport/wg"
"github.com/hstern/fj-bellows/internal/transport/wgboot"

// Register in-tree providers.
Expand Down Expand Up @@ -132,6 +133,15 @@ func run(opts runOpts, log *slog.Logger, logBus *logbus.Bus) error {
// implement the method (e.g. the docker provider, which has no
// Linode-style firewall) are unaffected.
applyTransportToProvider(prov, cfg.Transport)
// Under cache-gateway mode, load (or generate) the orchestrator's WG
// keypair BEFORE Configure runs. Configure provisions the cache VM,
// and its cloud-init needs the orchestrator's public key baked in
// so wg-quick on the cache can come up referencing the right peer
// (FJB-99 Phase A). Phase B will move the wgboot.Boot call to AFTER
// Configure and have it reuse this same key.
if err := plumbOrchestratorWGPubkey(prov, cfg.Transport); err != nil {
return err
}
// Bound the Configure-time network calls (provider sentinel fetches,
// firewall API, etc.) so a hung upstream can't wedge startup forever.
cfgCtx, cancelCfg := context.WithTimeout(context.Background(), 60*time.Second)
Expand Down Expand Up @@ -602,6 +612,29 @@ func applyTransportToProvider(prov provider.Provider, t config.Transport) {
}
}

// plumbOrchestratorWGPubkey loads (or generates) the orchestrator's WG
// keypair under cache-gateway mode and pushes the public key into the
// provider so the cache cloud-init can bake it in as the WG peer
// pubkey (FJB-99 Phase A). Duck-typed for the same reason as
// applyTransportToProvider; providers that don't carry a managed cache
// skip the push.
//
// No-op for ssh-mode deployments (no WG block configured) and for
// providers without SetOrchestratorWGPubkey (e.g. docker).
func plumbOrchestratorWGPubkey(prov provider.Provider, t config.Transport) error {
if t.Mode != config.TransportCacheGateway || t.WG == nil {
return nil
}
priv, err := wg.LoadOrGenerateKey(t.WG.PrivateKeyFile)
if err != nil {
return fmt.Errorf("orchestrator wg key: %w", err)
}
if sp, ok := prov.(interface{ SetOrchestratorWGPubkey(string) }); ok {
sp.SetOrchestratorWGPubkey(priv.PublicKey().String())
}
return nil
}

// sshDispatcherFrom builds the SSH dispatcher from config.
func sshDispatcherFrom(cfg *config.Config, signer ssh.Signer) *orchestrator.SSHDispatcher {
return &orchestrator.SSHDispatcher{
Expand Down
87 changes: 87 additions & 0 deletions cmd/fjbagent/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Command fjbagent is the small daemon that runs on every fj-bellows worker
// and cache VM. It exposes ConnectRPC services the orchestrator dials into
// over the WG fabric — Health (FJB-94), and later Exec (FJB-93) and
// reachability probes (FJB-97).
//
// Listen address depends on the role:
// - cache: the cache's own WG inner address (e.g. 100.64.0.2:9001)
// - worker: the worker's VPC IP (e.g. 10.0.0.5:9001); the orchestrator
// reaches it through the cache-gateway routing FJB-54 set up
//
// Bind-address selection is the operator's job via -listen (typically set
// by the systemd unit from a cloud-init-substituted value). The agent
// itself doesn't care — it binds whatever it's told.
package main

import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"

"github.com/hstern/fj-bellows/internal/agent"
)

// version is the build version, stamped at link time via
//
// -ldflags "-X main.version=<git describe output>"
//
// Defaults to "dev" for local builds.
var version = "dev"

func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "fjbagent: %v\n", err)
os.Exit(1)
}
}

// run does the real work in a function with no os.Exit so deferred cleanup
// (the signal-context cancel) actually executes on error paths. main is a
// thin wrapper that translates the returned error into an exit code.
func run() error {
listen := flag.String("listen", "127.0.0.1:9001", "host:port to bind the agent's ConnectRPC server (typically the target's VPC IP or WG inner address)")
tokenFile := flag.String("token-file", "", "path to the bearer-token file (mode 0600); empty disables auth (test-only)")
logLevel := flag.String("log-level", "info", "slog level: debug|info|warn|error")
flag.Parse()

log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: parseLevel(*logLevel),
}))

opts := []agent.Option{}
if *tokenFile != "" {
tok, err := agent.LoadToken(*tokenFile)
if err != nil {
return err
}
opts = append(opts, agent.WithBearerToken(tok))
} else {
log.Warn("agent running without bearer-token auth; do not use in production")
}

h := agent.NewHandler(version, time.Now())
srv := agent.NewServer(*listen, h, log, opts...)

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

return srv.Run(ctx)
}

func parseLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
Loading
Loading