Skip to content

Commit a8123f9

Browse files
committed
Completed: epithet-19 - Host Eligibility Checking with Match Patterns
**Changes made:** - Added `matchPatterns []string` field to Broker struct - Updated `broker.New()` to accept match patterns parameter - Implemented `shouldHandle()` method using `filepath.Match` for pattern matching - Added host eligibility checking as Step 1 of the 5-step workflow in `broker.Match()` - Fully implemented the agent command to create and start the broker with signal handling - Added comprehensive tests (Test_ShouldHandle with 8 cases, Test_MatchWithPatternFiltering) - Fixed nil pointer dereference bug in `broker.Close()` - Updated all existing tests to provide match patterns **Tests:** All passing ✅ **Issues closed:** - epithet-19 (host eligibility checking) - epithet-12 (epithet match command epic - all sub-tasks complete)
1 parent 4566cb6 commit a8123f9

File tree

7 files changed

+490
-25
lines changed

7 files changed

+490
-25
lines changed

.beads/issues.jsonl

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@
1010
{"id":"epithet-18","title":"Implement 5-step certificate validation workflow","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:03:32.80585-07:00","closed_at":"2025-10-25T15:03:32.80585-07:00","dependencies":[{"issue_id":"epithet-18","depends_on_id":"epithet-12","type":"parent-child","created_at":"2025-10-22T16:09:07.719097-07:00","created_by":"import"}]}
1111
{"id":"epithet-19","title":"Add host eligibility checking (match patterns)","description":"","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-19","depends_on_id":"epithet-12","type":"parent-child","created_at":"2025-10-22T16:09:07.719466-07:00","created_by":"import"}]}
1212
{"id":"epithet-20","title":"Return success/failure to OpenSSH properly","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","closed_at":"2025-10-22T16:36:42.750426903Z","dependencies":[{"issue_id":"epithet-20","depends_on_id":"epithet-12","type":"parent-child","created_at":"2025-10-22T16:09:07.719802-07:00","created_by":"import"}]}
13-
{"id":"epithet-21","title":"Implement certificate request flow: auth token → CA → signed certificate","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-21","depends_on_id":"epithet-13","type":"parent-child","created_at":"2025-10-22T16:09:07.720137-07:00","created_by":"import"}]}
14-
{"id":"epithet-22","title":"Pass connection details (host, user, port) to CA for principal determination","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-22","depends_on_id":"epithet-13","type":"parent-child","created_at":"2025-10-22T16:09:07.720431-07:00","created_by":"import"}]}
13+
{"id":"epithet-21","title":"Implement certificate request flow: auth token → CA → signed certificate","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:02.800425-07:00","closed_at":"2025-10-25T15:10:02.800425-07:00","dependencies":[{"issue_id":"epithet-21","depends_on_id":"epithet-13","type":"parent-child","created_at":"2025-10-22T16:09:07.720137-07:00","created_by":"import"}]}
14+
{"id":"epithet-22","title":"Pass connection details (host, user, port) to CA for principal determination","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:02.875796-07:00","closed_at":"2025-10-25T15:10:02.875796-07:00","dependencies":[{"issue_id":"epithet-22","depends_on_id":"epithet-13","type":"parent-child","created_at":"2025-10-22T16:09:07.720431-07:00","created_by":"import"}]}
1515
{"id":"epithet-23","title":"Handle CA errors and policy denials","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-23","depends_on_id":"epithet-13","type":"parent-child","created_at":"2025-10-22T16:09:07.720708-07:00","created_by":"import"}]}
16-
{"id":"epithet-24","title":"Store returned certificates with expiry times","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-24","depends_on_id":"epithet-13","type":"parent-child","created_at":"2025-10-22T16:09:07.720997-07:00","created_by":"import"}]}
17-
{"id":"epithet-25","title":"Create per-connection agent instances using pkg/agent.Agent","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-25","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.721274-07:00","created_by":"import"}]}
18-
{"id":"epithet-26","title":"Implement map connection hash (%C) → agent instance","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-26","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.721543-07:00","created_by":"import"}]}
19-
{"id":"epithet-27","title":"Implement agent socket path management at ~/.epithet/sockets/%C","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-27","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.721819-07:00","created_by":"import"}]}
20-
{"id":"epithet-28","title":"Implement certificate swapping/renewal in existing agents (via UseCredential)","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-28","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.722075-07:00","created_by":"import"}]}
21-
{"id":"epithet-29","title":"Implement agent lifecycle and cleanup","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-29","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.722345-07:00","created_by":"import"}]}
16+
{"id":"epithet-24","title":"Store returned certificates with expiry times","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:13.727953-07:00","closed_at":"2025-10-25T15:10:13.727953-07:00","dependencies":[{"issue_id":"epithet-24","depends_on_id":"epithet-13","type":"parent-child","created_at":"2025-10-22T16:09:07.720997-07:00","created_by":"import"}]}
17+
{"id":"epithet-25","title":"Create per-connection agent instances using pkg/agent.Agent","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:13.805836-07:00","closed_at":"2025-10-25T15:10:13.805836-07:00","dependencies":[{"issue_id":"epithet-25","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.721274-07:00","created_by":"import"}]}
18+
{"id":"epithet-26","title":"Implement map connection hash (%C) → agent instance","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:23.197188-07:00","closed_at":"2025-10-25T15:10:23.197188-07:00","dependencies":[{"issue_id":"epithet-26","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.721543-07:00","created_by":"import"}]}
19+
{"id":"epithet-27","title":"Implement agent socket path management at ~/.epithet/sockets/%C","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:23.276431-07:00","closed_at":"2025-10-25T15:10:23.276431-07:00","dependencies":[{"issue_id":"epithet-27","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.721819-07:00","created_by":"import"}]}
20+
{"id":"epithet-28","title":"Implement certificate swapping/renewal in existing agents (via UseCredential)","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:38.620417-07:00","closed_at":"2025-10-25T15:10:38.620417-07:00","dependencies":[{"issue_id":"epithet-28","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.722075-07:00","created_by":"import"}]}
21+
{"id":"epithet-29","title":"Implement agent lifecycle and cleanup","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-25T15:10:38.697488-07:00","closed_at":"2025-10-25T15:10:38.697488-07:00","dependencies":[{"issue_id":"epithet-29","depends_on_id":"epithet-14","type":"parent-child","created_at":"2025-10-22T16:09:07.722345-07:00","created_by":"import"}]}
2222
{"id":"epithet-30","title":"Implement match pattern evaluation (which hosts epithet should handle)","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-30","depends_on_id":"epithet-15","type":"parent-child","created_at":"2025-10-22T16:09:07.722621-07:00","created_by":"import"}]}
2323
{"id":"epithet-31","title":"Implement proper error handling throughout broker","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-31","depends_on_id":"epithet-15","type":"parent-child","created_at":"2025-10-22T16:09:07.722886-07:00","created_by":"import"}]}
2424
{"id":"epithet-32","title":"Add logging and observability","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-22T16:09:07.715489-07:00","updated_at":"2025-10-22T16:09:07.715489-07:00","dependencies":[{"issue_id":"epithet-32","depends_on_id":"epithet-15","type":"parent-child","created_at":"2025-10-22T16:09:07.723201-07:00","created_by":"import"}]}

cmd/epithet/agent.go

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,79 @@
11
package main
22

33
import (
4+
"context"
5+
"fmt"
46
"log/slog"
7+
"os"
8+
"os/signal"
9+
"path/filepath"
10+
"syscall"
11+
12+
"github.com/epithet-ssh/epithet/pkg/broker"
513
)
614

715
type AgentCLI struct {
8-
Match []string `help:"Match patterns" short:"m" required:"true"`
9-
CaURL string `help:"CA URL" name:"ca-url" short:"c" required:"true"`
10-
Auth string `help:"Authentication command" short:"a" required:"true"`
11-
Broker string `help:"Broker socket path" short:"b" require:"true"`
16+
Match []string `help:"Match patterns" short:"m" required:"true"`
17+
CaURL string `help:"CA URL" name:"ca-url" short:"c" required:"true"`
18+
Auth string `help:"Authentication command" short:"a" required:"true"`
19+
BrokerSock string `help:"Broker socket path" name:"broker-sock" short:"b" default:"~/.epithet/broker.sock"`
20+
AgentSockDir string `help:"Agent socket directory" name:"agent-sock-dir" default:"~/.epithet/sockets"`
1221
}
1322

1423
func (a *AgentCLI) Run(logger *slog.Logger) error {
1524
logger.Debug("agent command received", "agent", a)
25+
26+
// Expand home directory in paths
27+
brokerSock, err := expandPath(a.BrokerSock)
28+
if err != nil {
29+
return fmt.Errorf("failed to expand broker socket path: %w", err)
30+
}
31+
32+
agentSockDir, err := expandPath(a.AgentSockDir)
33+
if err != nil {
34+
return fmt.Errorf("failed to expand agent socket directory: %w", err)
35+
}
36+
37+
// Create broker
38+
b := broker.New(*logger, brokerSock, a.Auth, a.CaURL, agentSockDir, a.Match)
39+
40+
// Set up context with cancellation on signals
41+
ctx, cancel := context.WithCancel(context.Background())
42+
defer cancel()
43+
44+
sigChan := make(chan os.Signal, 1)
45+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
46+
go func() {
47+
<-sigChan
48+
logger.Info("received shutdown signal")
49+
cancel()
50+
}()
51+
52+
// Start broker
53+
logger.Info("starting broker", "socket", brokerSock, "patterns", a.Match)
54+
err = b.Serve(ctx)
55+
if err != nil && err != context.Canceled {
56+
return fmt.Errorf("broker serve error: %w", err)
57+
}
58+
59+
logger.Info("broker shutdown complete")
1660
return nil
1761
}
62+
63+
// expandPath expands ~ to the user's home directory
64+
func expandPath(path string) (string, error) {
65+
if len(path) == 0 || path[0] != '~' {
66+
return path, nil
67+
}
68+
69+
home, err := os.UserHomeDir()
70+
if err != nil {
71+
return "", err
72+
}
73+
74+
if len(path) == 1 {
75+
return home, nil
76+
}
77+
78+
return filepath.Join(home, path[1:]), nil
79+
}

pkg/broker/agent_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestBroker_AgentMapInitialized(t *testing.T) {
1414
socketPath := t.TempDir() + "/broker.sock"
1515
agentSocketDir := t.TempDir() + "/sockets"
1616

17-
b := New(*testLogger(t), socketPath, authCommand, "http://localhost:9999", agentSocketDir)
17+
b := New(*testLogger(t), socketPath, authCommand, "http://localhost:9999", agentSocketDir, []string{"*"})
1818

1919
// Verify agents map is initialized
2020
require.NotNil(t, b.agents)
@@ -27,7 +27,7 @@ func TestBroker_NoAgentReturnsNotAllowed(t *testing.T) {
2727
socketPath := "/tmp/test-broker.sock"
2828
agentSocketDir := t.TempDir() + "/sockets"
2929

30-
b := New(*testLogger(t), socketPath, authCommand, "http://localhost:9999", agentSocketDir)
30+
b := New(*testLogger(t), socketPath, authCommand, "http://localhost:9999", agentSocketDir, []string{"*"})
3131

3232
// Serve in background
3333
go func() {

pkg/broker/broker.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,20 @@ type Broker struct {
3737
certStore *CertificateStore
3838
agents map[policy.ConnectionHash]agentEntry // connectionHash → agent
3939
caClient *caclient.Client
40-
agentSocketDir string // Directory for agent sockets (e.g., ~/.epithet/sockets)
40+
agentSocketDir string // Directory for agent sockets (e.g., ~/.epithet/sockets)
41+
matchPatterns []string // Host patterns that epithet should handle (e.g., "*.example.com")
4142
}
4243

4344
// New creates a new Broker instance. This does not start listening - call Serve() to begin accepting connections.
44-
func New(log slog.Logger, socketPath string, authCommand string, caURL string, agentSocketDir string) *Broker {
45+
func New(log slog.Logger, socketPath string, authCommand string, caURL string, agentSocketDir string, matchPatterns []string) *Broker {
4546
return &Broker{
4647
auth: NewAuth(authCommand),
4748
certStore: NewCertificateStore(),
4849
agents: make(map[policy.ConnectionHash]agentEntry),
4950
brokerSocketPath: socketPath,
5051
caClient: caclient.New(caURL),
5152
agentSocketDir: agentSocketDir,
53+
matchPatterns: matchPatterns,
5254
done: make(chan struct{}),
5355
log: log,
5456
}
@@ -99,7 +101,15 @@ func (b *Broker) Match(input MatchRequest, output *MatchResponse) error {
99101
b.lock.Lock()
100102
defer b.lock.Unlock()
101103

102-
// Check if agent already exists for this connection hash
104+
// Step 1: Check if this host should be handled by epithet at all
105+
if !b.shouldHandle(input.Connection.RemoteHost) {
106+
b.log.Debug("host does not match any patterns, ignoring", "host", input.Connection.RemoteHost, "patterns", b.matchPatterns)
107+
output.Allow = false
108+
output.Error = fmt.Sprintf("host %s does not match any configured patterns", input.Connection.RemoteHost)
109+
return nil
110+
}
111+
112+
// Step 2: Check if agent already exists for this connection hash
103113
if entry, exists := b.agents[input.Connection.Hash]; exists {
104114
// Check if agent's certificate is still valid (with buffer)
105115
if time.Now().Add(expiryBuffer).Before(entry.expiresAt) {
@@ -113,11 +123,11 @@ func (b *Broker) Match(input MatchRequest, output *MatchResponse) error {
113123
delete(b.agents, input.Connection.Hash)
114124
}
115125

116-
// Step 2: Check for existing, valid certificate in cert store
126+
// Step 3: Check for existing, valid certificate in cert store
117127
cred, found := b.certStore.Lookup(input.Connection)
118128
if found {
119129
b.log.Debug("found valid certificate in store", "host", input.Connection.RemoteHost)
120-
// Step 3: Set up agent with existing certificate
130+
// Step 4: Set up agent with existing certificate
121131
err := b.ensureAgent(input.Connection.Hash, cred)
122132
if err != nil {
123133
b.log.Error("failed to create agent", "error", err)
@@ -129,7 +139,7 @@ func (b *Broker) Match(input MatchRequest, output *MatchResponse) error {
129139
return nil
130140
}
131141

132-
// Step 4: No valid certificate exists, request one from CA
142+
// Step 5: No valid certificate exists, request one from CA
133143
b.log.Debug("no valid certificate found, requesting from CA", "host", input.Connection.RemoteHost)
134144

135145
// Check if we have an auth token, if not authenticate
@@ -181,7 +191,7 @@ func (b *Broker) Match(input MatchRequest, output *MatchResponse) error {
181191

182192
b.log.Debug("certificate obtained and stored", "host", input.Connection.RemoteHost, "policy", certResp.Policy.HostPattern)
183193

184-
// Step 3: Create agent with new certificate
194+
// Step 4: Create agent with new certificate
185195
credential := agent.Credential{
186196
PrivateKey: privateKey,
187197
Certificate: certResp.Certificate,
@@ -257,12 +267,39 @@ func (b *Broker) ensureAgent(connectionHash policy.ConnectionHash, credential ag
257267
return nil
258268
}
259269

270+
// shouldHandle checks if the given hostname matches any of the configured match patterns.
271+
// Returns true if epithet should handle this connection, false otherwise.
272+
func (b *Broker) shouldHandle(hostname string) bool {
273+
for _, pattern := range b.matchPatterns {
274+
matched, err := filepath.Match(pattern, hostname)
275+
if err != nil {
276+
b.log.Warn("invalid match pattern", "pattern", pattern, "error", err)
277+
continue
278+
}
279+
if matched {
280+
return true
281+
}
282+
}
283+
return false
284+
}
285+
260286
// LookupCertificate finds a valid certificate for the given connection.
261287
// Returns the Credential and true if found and not expired, otherwise returns false.
262288
func (b *Broker) LookupCertificate(conn policy.Connection) (agent.Credential, bool) {
263289
return b.certStore.Lookup(conn)
264290
}
265291

292+
// AgentSocketPath returns the socket path for a given connection hash.
293+
// This is used by SSH to connect to the per-connection agent.
294+
func (b *Broker) AgentSocketPath(hash policy.ConnectionHash) string {
295+
return filepath.Join(b.agentSocketDir, string(hash))
296+
}
297+
298+
// BrokerSocketPath returns the path to the broker's RPC socket.
299+
func (b *Broker) BrokerSocketPath() string {
300+
return b.brokerSocketPath
301+
}
302+
266303
// StoreCertificate adds or updates a certificate for a given policy pattern.
267304
func (b *Broker) StoreCertificate(pc PolicyCert) {
268305
b.certStore.Store(pc)
@@ -308,7 +345,10 @@ func (b *Broker) Done() <-chan struct{} {
308345

309346
func (b *Broker) Close() {
310347
b.closeOnce.Do(func() {
311-
_ = b.brokerListener.Close()
348+
// Only close listener if it was successfully created
349+
if b.brokerListener != nil {
350+
_ = b.brokerListener.Close()
351+
}
312352

313353
// Close all agents
314354
b.lock.Lock()

0 commit comments

Comments
 (0)