diff --git a/cmd/fjbagent/main.go b/cmd/fjbagent/main.go new file mode 100644 index 0000000..fab5d4b --- /dev/null +++ b/cmd/fjbagent/main.go @@ -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=" +// +// 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 + } +} diff --git a/gen/fjbellows/agent/v1/agent.pb.go b/gen/fjbellows/agent/v1/agent.pb.go new file mode 100644 index 0000000..a62be3c --- /dev/null +++ b/gen/fjbellows/agent/v1/agent.pb.go @@ -0,0 +1,208 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: fjbellows/agent/v1/agent.proto + +package agentv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HealthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // ready is true on every successful invocation; the field is present so + // operator tooling can do `.ready` instead of `error == nil`. It exists + // for future-proofing too — a future agent may want to return ready=false + // while still being reachable (e.g. during a quiesce window). + Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"` + // build_version is the agent binary's version string. Populated from + // -ldflags "-X main.version=…" at link time; "dev" when unset. + BuildVersion string `protobuf:"bytes,2,opt,name=build_version,json=buildVersion,proto3" json:"build_version,omitempty"` + // started_at is when the agent process started. Subtracting this from + // now gives the operator-visible uptime. + StartedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + // hostname is the OS hostname as reported by os.Hostname() at startup. + // Useful when the orchestrator's name → addr map drifts from what the + // VM actually thinks it is. + Hostname string `protobuf:"bytes,4,opt,name=hostname,proto3" json:"hostname,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_fjbellows_agent_v1_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_fjbellows_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *HealthResponse) GetReady() bool { + if x != nil { + return x.Ready + } + return false +} + +func (x *HealthResponse) GetBuildVersion() string { + if x != nil { + return x.BuildVersion + } + return "" +} + +func (x *HealthResponse) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +func (x *HealthResponse) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +var File_fjbellows_agent_v1_agent_proto protoreflect.FileDescriptor + +const file_fjbellows_agent_v1_agent_proto_rawDesc = "" + + "\n" + + "\x1efjbellows/agent/v1/agent.proto\x12\x12fjbellows.agent.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x0f\n" + + "\rHealthRequest\"\xa2\x01\n" + + "\x0eHealthResponse\x12\x14\n" + + "\x05ready\x18\x01 \x01(\bR\x05ready\x12#\n" + + "\rbuild_version\x18\x02 \x01(\tR\fbuildVersion\x129\n" + + "\n" + + "started_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12\x1a\n" + + "\bhostname\x18\x04 \x01(\tR\bhostname2_\n" + + "\fAgentService\x12O\n" + + "\x06Health\x12!.fjbellows.agent.v1.HealthRequest\x1a\".fjbellows.agent.v1.HealthResponseB\xcb\x01\n" + + "\x16com.fjbellows.agent.v1B\n" + + "AgentProtoP\x01Z;github.com/hstern/fj-bellows/gen/fjbellows/agent/v1;agentv1\xa2\x02\x03FAX\xaa\x02\x12Fjbellows.Agent.V1\xca\x02\x12Fjbellows\\Agent\\V1\xe2\x02\x1eFjbellows\\Agent\\V1\\GPBMetadata\xea\x02\x14Fjbellows::Agent::V1b\x06proto3" + +var ( + file_fjbellows_agent_v1_agent_proto_rawDescOnce sync.Once + file_fjbellows_agent_v1_agent_proto_rawDescData []byte +) + +func file_fjbellows_agent_v1_agent_proto_rawDescGZIP() []byte { + file_fjbellows_agent_v1_agent_proto_rawDescOnce.Do(func() { + file_fjbellows_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_fjbellows_agent_v1_agent_proto_rawDesc), len(file_fjbellows_agent_v1_agent_proto_rawDesc))) + }) + return file_fjbellows_agent_v1_agent_proto_rawDescData +} + +var file_fjbellows_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_fjbellows_agent_v1_agent_proto_goTypes = []any{ + (*HealthRequest)(nil), // 0: fjbellows.agent.v1.HealthRequest + (*HealthResponse)(nil), // 1: fjbellows.agent.v1.HealthResponse + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp +} +var file_fjbellows_agent_v1_agent_proto_depIdxs = []int32{ + 2, // 0: fjbellows.agent.v1.HealthResponse.started_at:type_name -> google.protobuf.Timestamp + 0, // 1: fjbellows.agent.v1.AgentService.Health:input_type -> fjbellows.agent.v1.HealthRequest + 1, // 2: fjbellows.agent.v1.AgentService.Health:output_type -> fjbellows.agent.v1.HealthResponse + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_fjbellows_agent_v1_agent_proto_init() } +func file_fjbellows_agent_v1_agent_proto_init() { + if File_fjbellows_agent_v1_agent_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_fjbellows_agent_v1_agent_proto_rawDesc), len(file_fjbellows_agent_v1_agent_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_fjbellows_agent_v1_agent_proto_goTypes, + DependencyIndexes: file_fjbellows_agent_v1_agent_proto_depIdxs, + MessageInfos: file_fjbellows_agent_v1_agent_proto_msgTypes, + }.Build() + File_fjbellows_agent_v1_agent_proto = out.File + file_fjbellows_agent_v1_agent_proto_goTypes = nil + file_fjbellows_agent_v1_agent_proto_depIdxs = nil +} diff --git a/gen/fjbellows/agent/v1/agentv1connect/agent.connect.go b/gen/fjbellows/agent/v1/agentv1connect/agent.connect.go new file mode 100644 index 0000000..c13e1fd --- /dev/null +++ b/gen/fjbellows/agent/v1/agentv1connect/agent.connect.go @@ -0,0 +1,118 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: fjbellows/agent/v1/agent.proto + +package agentv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // AgentServiceName is the fully-qualified name of the AgentService service. + AgentServiceName = "fjbellows.agent.v1.AgentService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // AgentServiceHealthProcedure is the fully-qualified name of the AgentService's Health RPC. + AgentServiceHealthProcedure = "/fjbellows.agent.v1.AgentService/Health" +) + +// AgentServiceClient is a client for the fjbellows.agent.v1.AgentService service. +type AgentServiceClient interface { + // Health returns a readiness snapshot of the agent process. Used by the + // orchestrator's readiness gate and the e2e harness to assert "agent is + // up and responding to RPCs". The response always populates `ready=true` + // when the call reaches the handler — the field exists so callers can + // grep one boolean instead of inspecting the absence of an error. + Health(context.Context, *connect.Request[v1.HealthRequest]) (*connect.Response[v1.HealthResponse], error) +} + +// NewAgentServiceClient constructs a client for the fjbellows.agent.v1.AgentService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AgentServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + agentServiceMethods := v1.File_fjbellows_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + return &agentServiceClient{ + health: connect.NewClient[v1.HealthRequest, v1.HealthResponse]( + httpClient, + baseURL+AgentServiceHealthProcedure, + connect.WithSchema(agentServiceMethods.ByName("Health")), + connect.WithClientOptions(opts...), + ), + } +} + +// agentServiceClient implements AgentServiceClient. +type agentServiceClient struct { + health *connect.Client[v1.HealthRequest, v1.HealthResponse] +} + +// Health calls fjbellows.agent.v1.AgentService.Health. +func (c *agentServiceClient) Health(ctx context.Context, req *connect.Request[v1.HealthRequest]) (*connect.Response[v1.HealthResponse], error) { + return c.health.CallUnary(ctx, req) +} + +// AgentServiceHandler is an implementation of the fjbellows.agent.v1.AgentService service. +type AgentServiceHandler interface { + // Health returns a readiness snapshot of the agent process. Used by the + // orchestrator's readiness gate and the e2e harness to assert "agent is + // up and responding to RPCs". The response always populates `ready=true` + // when the call reaches the handler — the field exists so callers can + // grep one boolean instead of inspecting the absence of an error. + Health(context.Context, *connect.Request[v1.HealthRequest]) (*connect.Response[v1.HealthResponse], error) +} + +// NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + agentServiceMethods := v1.File_fjbellows_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + agentServiceHealthHandler := connect.NewUnaryHandler( + AgentServiceHealthProcedure, + svc.Health, + connect.WithSchema(agentServiceMethods.ByName("Health")), + connect.WithHandlerOptions(opts...), + ) + return "/fjbellows.agent.v1.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AgentServiceHealthProcedure: + agentServiceHealthHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAgentServiceHandler struct{} + +func (UnimplementedAgentServiceHandler) Health(context.Context, *connect.Request[v1.HealthRequest]) (*connect.Response[v1.HealthResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("fjbellows.agent.v1.AgentService.Health is not implemented")) +} diff --git a/internal/agent/handler.go b/internal/agent/handler.go new file mode 100644 index 0000000..d15e199 --- /dev/null +++ b/internal/agent/handler.go @@ -0,0 +1,57 @@ +// Package agent implements fjbagent — the small daemon that runs on every +// fj-bellows worker and cache VM. It exposes ConnectRPC services the +// orchestrator dials into (Health here; Exec lands in FJB-93). Auth is a +// per-deployment shared secret on the Authorization header; the wire is +// plain HTTP/2 cleartext on top of the WG fabric. +package agent + +import ( + "context" + "os" + "time" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + agentv1 "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1" +) + +// Handler is the AgentService implementation. It holds process-scope +// facts (start time, hostname, build version) that Health returns. Field +// values are captured once at NewHandler time so a slow os.Hostname call +// doesn't block every RPC. +type Handler struct { + buildVersion string + startedAt time.Time + hostname string +} + +// NewHandler captures the process-scope health facts. version is the +// build's main.version string ("dev" when unstamped); now is the start +// time anchor (taken at NewHandler so a delayed Run still reports an +// honest start moment). +func NewHandler(version string, now time.Time) *Handler { + h, err := os.Hostname() + if err != nil { + // Hostname lookup is best-effort. An empty string in the response + // is a clear "we didn't get it" rather than a fabricated guess. + h = "" + } + return &Handler{ + buildVersion: version, + startedAt: now, + hostname: h, + } +} + +// Health returns the readiness snapshot. Always succeeds; the existence +// of the response is itself the "agent is up" signal the orchestrator +// readiness gate watches for. +func (h *Handler) Health(_ context.Context, _ *connect.Request[agentv1.HealthRequest]) (*connect.Response[agentv1.HealthResponse], error) { + return connect.NewResponse(&agentv1.HealthResponse{ + Ready: true, + BuildVersion: h.buildVersion, + StartedAt: timestamppb.New(h.startedAt), + Hostname: h.hostname, + }), nil +} diff --git a/internal/agent/handler_test.go b/internal/agent/handler_test.go new file mode 100644 index 0000000..92c8f4d --- /dev/null +++ b/internal/agent/handler_test.go @@ -0,0 +1,54 @@ +package agent + +import ( + "context" + "testing" + "time" + + "connectrpc.com/connect" + + agentv1 "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1" +) + +func TestHealth_PopulatesProcessFacts(t *testing.T) { + t.Parallel() + + anchor := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + h := NewHandler("v0.5.0", anchor) + + resp, err := h.Health(context.Background(), connect.NewRequest(&agentv1.HealthRequest{})) + if err != nil { + t.Fatalf("Health: %v", err) + } + got := resp.Msg + + if !got.Ready { + t.Errorf("ready = false, want true") + } + if got.BuildVersion != "v0.5.0" { + t.Errorf("build_version = %q, want %q", got.BuildVersion, "v0.5.0") + } + if got.StartedAt == nil { + t.Fatal("started_at is nil") + } + if !got.StartedAt.AsTime().Equal(anchor) { + t.Errorf("started_at = %v, want %v", got.StartedAt.AsTime(), anchor) + } + // hostname comes from os.Hostname(); we don't assert a specific value, + // only that the field exists (it may legitimately be empty if the call + // failed in this environment). + _ = got.Hostname +} + +func TestHealth_DevVersionDefault(t *testing.T) { + t.Parallel() + + h := NewHandler("dev", time.Now()) + resp, err := h.Health(context.Background(), connect.NewRequest(&agentv1.HealthRequest{})) + if err != nil { + t.Fatalf("Health: %v", err) + } + if resp.Msg.BuildVersion != "dev" { + t.Errorf("build_version = %q, want %q", resp.Msg.BuildVersion, "dev") + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go new file mode 100644 index 0000000..ea89098 --- /dev/null +++ b/internal/agent/server.go @@ -0,0 +1,158 @@ +package agent + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1/agentv1connect" +) + +// Server is the agent's HTTP front door. One http.Server multiplexes: +// - ConnectRPC handlers at /./ +// - Plain HTTP /healthz for sd_notify-less readiness checks (curl --fail +// style; not the gated readiness probe — that's AgentService.Health). +// +// The wire is HTTP/1.1 + HTTP/2 cleartext; encryption + authentication of +// the link is WG's responsibility, and on workers the cache↔worker leg is +// on the private VPC subnet behind Linode's VPC isolation. +type Server struct { + listen string + srv *http.Server + log *slog.Logger +} + +// Option configures a Server at construction time. +type Option func(*config) + +type config struct { + token string +} + +// WithBearerToken enforces an Authorization: Bearer header on every +// Connect RPC. The plain HTTP /healthz shim stays open so a sysadmin can +// curl the agent without the token. Empty token disables the middleware +// — useful for unit tests; production deployments always set it. +func WithBearerToken(token string) Option { + return func(c *config) { c.token = token } +} + +// NewServer builds the server but does not start it. listen is a host:port +// suitable for net.Listen("tcp", ...). The handler argument owns the +// process-scope health state. +func NewServer(listen string, h *Handler, log *slog.Logger, opts ...Option) *Server { + if log == nil { + log = slog.Default() + } + var cfg config + for _, opt := range opts { + opt(&cfg) + } + + mux := http.NewServeMux() + + path, connectHandler := agentv1connect.NewAgentServiceHandler(h) + mux.Handle(path, connectHandler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + + var protos http.Protocols + protos.SetHTTP1(true) + protos.SetUnencryptedHTTP2(true) + + return &Server{ + listen: listen, + log: log, + srv: &http.Server{ + Handler: bearerAuth(mux, cfg.token), + Protocols: &protos, + ReadHeaderTimeout: 5 * time.Second, + }, + } +} + +// Handler returns the underlying mux for tests that want to mount the server +// behind httptest.NewServer without binding a real TCP port. +func (s *Server) Handler() http.Handler { + return s.srv.Handler +} + +// Run binds the listener and serves until ctx is cancelled, at which point +// it initiates a short graceful shutdown. +func (s *Server) Run(ctx context.Context) error { + var lc net.ListenConfig + ln, err := lc.Listen(ctx, "tcp", s.listen) + if err != nil { + return fmt.Errorf("agent listen %s: %w", s.listen, err) + } + s.log.Info("agent listening", "addr", ln.Addr().String()) + + serveErr := make(chan error, 1) + go func() { + err := s.srv.Serve(ln) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + serveErr <- err + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.srv.Shutdown(shutdownCtx); err != nil { + s.log.Error("agent shutdown", "err", err) + } + return <-serveErr + case err := <-serveErr: + return err + } +} + +// bearerAuth wraps next to require Authorization: Bearer on Connect +// RPC paths. /healthz stays open. Empty token disables the middleware. +func bearerAuth(next http.Handler, token string) http.Handler { + if token == "" { + return next + } + expected := "Bearer " + token + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + got := r.Header.Get("Authorization") + if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer realm="fjbagent"`) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthenticated"}`)) + return + } + next.ServeHTTP(w, r) + }) +} + +// LoadToken reads a single-line bearer token from path. Empty files and +// missing files are errors so the operator can't accidentally end up with +// the empty-token disable-auth branch by leaving the file blank. +func LoadToken(path string) (string, error) { + //nolint:gosec // G304: path comes from operator-supplied -token-file flag, not user input. + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read agent token: %w", err) + } + tok := strings.TrimSpace(string(b)) + if tok == "" { + return "", errors.New("agent token file is empty") + } + return tok, nil +} diff --git a/internal/agent/server_test.go b/internal/agent/server_test.go new file mode 100644 index 0000000..8e2f1b8 --- /dev/null +++ b/internal/agent/server_test.go @@ -0,0 +1,205 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + + agentv1 "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1" + "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1/agentv1connect" +) + +func TestServer_HealthOverHTTP_NoAuth(t *testing.T) { + t.Parallel() + + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil) + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + client := agentv1connect.NewAgentServiceClient(http.DefaultClient, ts.URL) + resp, err := client.Health(context.Background(), connect.NewRequest(&agentv1.HealthRequest{})) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !resp.Msg.Ready { + t.Errorf("ready = false, want true") + } + if resp.Msg.BuildVersion != "test" { + t.Errorf("build_version = %q, want %q", resp.Msg.BuildVersion, "test") + } +} + +func TestServer_BearerAuth_RejectsMissingToken(t *testing.T) { + t.Parallel() + + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil, WithBearerToken("s3cret")) + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + client := agentv1connect.NewAgentServiceClient(http.DefaultClient, ts.URL) + _, err := client.Health(context.Background(), connect.NewRequest(&agentv1.HealthRequest{})) + if err == nil { + t.Fatal("Health succeeded without token, want unauthenticated") + } + if connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Errorf("code = %v, want CodeUnauthenticated; err = %v", connect.CodeOf(err), err) + } +} + +func TestServer_BearerAuth_AcceptsCorrectToken(t *testing.T) { + t.Parallel() + + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil, WithBearerToken("s3cret")) + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + interceptor := connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + req.Header().Set("Authorization", "Bearer s3cret") + return next(ctx, req) + } + }) + client := agentv1connect.NewAgentServiceClient(http.DefaultClient, ts.URL, connect.WithInterceptors(interceptor)) + resp, err := client.Health(context.Background(), connect.NewRequest(&agentv1.HealthRequest{})) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !resp.Msg.Ready { + t.Errorf("ready = false, want true") + } +} + +func TestServer_BearerAuth_RejectsWrongToken(t *testing.T) { + t.Parallel() + + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil, WithBearerToken("right")) + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + interceptor := connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + req.Header().Set("Authorization", "Bearer wrong") + return next(ctx, req) + } + }) + client := agentv1connect.NewAgentServiceClient(http.DefaultClient, ts.URL, connect.WithInterceptors(interceptor)) + _, err := client.Health(context.Background(), connect.NewRequest(&agentv1.HealthRequest{})) + if err == nil { + t.Fatal("Health succeeded with wrong token, want unauthenticated") + } + if connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Errorf("code = %v, want CodeUnauthenticated", connect.CodeOf(err)) + } +} + +func TestServer_HealthzOpenWithoutToken(t *testing.T) { + t.Parallel() + + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil, WithBearerToken("s3cret")) + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + // No Authorization header; /healthz must still answer 200. + resp, err := http.Get(ts.URL + "/healthz") //nolint:noctx // test + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +func TestServer_Run_ShutsDownOnContextCancel(t *testing.T) { + t.Parallel() + + h := NewHandler("test", time.Now()) + srv := NewServer("127.0.0.1:0", h, nil) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- srv.Run(ctx) }() + + // Give the listener time to bind. We don't have a sync hook for that + // here; in production main.go relies on systemd's Type=notify. For the + // test, a tiny sleep is acceptable to ensure Serve has started. + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case err := <-done: + if err != nil { + t.Errorf("Run returned %v after cancel, want nil", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not return within 5s of context cancel") + } +} + +func TestLoadToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + tests := []struct { + name string + content string + want string + wantErr string + }{ + {name: "trimmed", content: " abc\n", want: "abc"}, + {name: "bare", content: "xyz", want: "xyz"}, + {name: "empty", content: "", wantErr: "empty"}, + {name: "whitespace-only", content: " \n ", wantErr: "empty"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := filepath.Join(dir, tt.name) + if err := os.WriteFile(p, []byte(tt.content), 0o600); err != nil { + t.Fatal(err) + } + got, err := LoadToken(p) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("LoadToken: got nil error, want one containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("LoadToken err = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("LoadToken: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestLoadToken_Missing(t *testing.T) { + t.Parallel() + _, err := LoadToken(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Fatal("LoadToken: got nil error, want one for missing file") + } +} diff --git a/proto/fjbellows/agent/v1/agent.proto b/proto/fjbellows/agent/v1/agent.proto new file mode 100644 index 0000000..bb15ce0 --- /dev/null +++ b/proto/fjbellows/agent/v1/agent.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package fjbellows.agent.v1; + +option go_package = "github.com/hstern/fj-bellows/gen/fjbellows/agent/v1;agentv1"; + +import "google/protobuf/timestamp.proto"; + +// AgentService runs on every worker and cache VM. The orchestrator dials it +// over the WG fabric (direct WG peer for cache, via cache-gateway routing for +// workers) and uses it to drive operator-side control RPCs that need code +// running on the target — interactive shell, command exec, reachability +// probes, and a future log-tail surface. +// +// Auth is a per-deployment shared secret presented in the Authorization header +// (Bearer scheme). The wire is plain HTTP/2 cleartext — WG already encrypts +// and authenticates the orchestrator↔cache leg, the cache↔worker leg is on +// the private VPC subnet, and adding TLS would mean another cert lifecycle. +// +// FJB-94 ships only Health; FJB-93 will add Exec, FJB-97 will reuse Health +// for the gRPC-layer reachability probe. +service AgentService { + // Health returns a readiness snapshot of the agent process. Used by the + // orchestrator's readiness gate and the e2e harness to assert "agent is + // up and responding to RPCs". The response always populates `ready=true` + // when the call reaches the handler — the field exists so callers can + // grep one boolean instead of inspecting the absence of an error. + rpc Health(HealthRequest) returns (HealthResponse); +} + +message HealthRequest {} + +message HealthResponse { + // ready is true on every successful invocation; the field is present so + // operator tooling can do `.ready` instead of `error == nil`. It exists + // for future-proofing too — a future agent may want to return ready=false + // while still being reachable (e.g. during a quiesce window). + bool ready = 1; + + // build_version is the agent binary's version string. Populated from + // -ldflags "-X main.version=…" at link time; "dev" when unset. + string build_version = 2; + + // started_at is when the agent process started. Subtracting this from + // now gives the operator-visible uptime. + google.protobuf.Timestamp started_at = 3; + + // hostname is the OS hostname as reported by os.Hostname() at startup. + // Useful when the orchestrator's name → addr map drifts from what the + // VM actually thinks it is. + string hostname = 4; +}