Skip to content

Commit 0e2096c

Browse files
feat(ssh): wire per-host auth-method learning into discovery/intelligence/liveness (#575)
The connection-profile memo (PR #566) only led the dial with the host's known-good SSH auth method on the compliance-scan path. The other three paths that talk to a managed host -- OS discovery, OS intelligence (collector), and the liveness privilege probe -- still dialed key-first every cycle, re-offering an unauthorized public key to password-only hosts (a failed publickey attempt that counts against MaxAuthTries and can trip fail2ban) on a loop. Extend the shared connprofile store into those three paths: - connprofile.WithHostID / HostIDFrom: context helpers so a transport that only receives host:port+cred can still look up + record the host's profile, without churning the SSHTransport.Dial signature (and its test stubs across discovery + collector). - discovery.SSHTransportProd gains WithProfiles + a dial seam: when a store is wired and the ctx carries a host id, it sets PreferAuth from the recorded method and records ObservedAuth after a successful dial. This one transport is what BOTH discovery and the collector dial through (via collectorSSHAdapter), so both learn at once. - discovery.go / collector.go wrap the ctx with the host id at the dial site (both hostFacts already carry HostID). - sshprivilege.Probe gains WithProfiles: it leads the dial with the recorded method (reordering buildAuthMethods for AuthBoth) and records which method authenticated via a local single-factor observer. - cmd/openwatch wires one shared connprofile.NewStore(pool) across all four paths (the scan now reuses it too). Learning stays best-effort: a missing host id, absent profile row, or store error dials in the default order and never fails the connection (hint, not a lock -- a stale hint self-heals on the next dial). Scope: the SSH auth-method dimension. sudo-mode (NOPASSWD vs password) learning for these three paths stays a follow-up -- they already probe sudo mode correctly each cycle; only the scan learns both today. Spec system-connection-profile -> v1.1.0: C-06, AC-08 (discovery/ collector transport), AC-09 (liveness probe).
1 parent ee87b9f commit 0e2096c

9 files changed

Lines changed: 491 additions & 47 deletions

File tree

cmd/openwatch/main.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -348,11 +348,22 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
348348
}
349349

350350
credSvc := credential.NewService(pool)
351+
352+
// Per-host SSH connection memory shared by every path that talks to
353+
// a managed host: the liveness privilege probe, OS discovery, OS
354+
// intelligence collection, and the compliance scan all lead the dial
355+
// with this host's last known-good auth method and record what
356+
// authenticated. Spec system-connection-profile.
357+
connStore := connprofile.NewStore(pool)
358+
351359
// Spec system-ssh-connectivity v1.2.0 C-09 / AC-18: thread the
352360
// SecurityConfig reader so the privilege probe can retry sudo -n
353361
// failures via sudo -S -k with the credential password — same
354-
// gating as the collector + discovery firewall probe.
355-
privProbe := sshprivilege.Probe(credSvc, sshprivilege.WithPolicyLoader(cfgStore))
362+
// gating as the collector + discovery firewall probe. WithProfiles
363+
// adds the per-host auth-method learning (system-connection-profile).
364+
privProbe := sshprivilege.Probe(credSvc,
365+
sshprivilege.WithPolicyLoader(cfgStore),
366+
sshprivilege.WithProfiles(connStore))
356367

357368
liveSvc := liveness.NewService(pool, audit.Emit, bus).
358369
WithConfigLoader(cfgStore.LoadConnectivity).
@@ -369,6 +380,10 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
369380
discoSvc := discovery.NewService(pool, audit.Emit, bus).
370381
WithHostLookup(discovery.PoolHostLookup{Pool: pool}).
371382
WithCredentialService(credSvc).
383+
// Profile-aware transport: lead the dial with the host's learned
384+
// SSH auth method + record what authenticated (system-connection-profile).
385+
WithSSHTransport(discovery.NewSSHTransport(owssh.ModeTOFU, owssh.NewMemoryStore()).
386+
WithProfiles(connStore)).
372387
// Spec system-ssh-connectivity v1.2.0 C-09 / AC-20: thread the
373388
// SecurityConfig reader so the firewall probe can retry a
374389
// sudo -n failure via sudo -S -k with the credential password
@@ -389,7 +404,8 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
389404
WithCredentialService(credSvc).
390405
WithHostLookup(collector.PoolHostLookup{Pool: pool}).
391406
WithSSHTransport(collectorSSHAdapter{
392-
inner: discovery.NewSSHTransport(owssh.ModeTOFU, owssh.NewMemoryStore()),
407+
inner: discovery.NewSSHTransport(owssh.ModeTOFU, owssh.NewMemoryStore()).
408+
WithProfiles(connStore),
393409
}).
394410
// Spec system-ssh-connectivity v1.1.0 C-09: load the
395411
// allow_credential_sudo_password knob at cycle start. When the
@@ -508,7 +524,7 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
508524
vars, err := cfgStore.LoadScanVars(ctx)
509525
return vars, err
510526
},
511-
Profiles: connprofile.NewStore(pool),
527+
Profiles: connStore,
512528
Policy: func(ctx context.Context) (bool, error) {
513529
cfg, err := cfgStore.LoadSecurity(ctx)
514530
return cfg.AllowCredentialSudoPassword, err

internal/connprofile/context.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package connprofile
2+
3+
import (
4+
"context"
5+
6+
"github.com/google/uuid"
7+
)
8+
9+
type hostIDKey struct{}
10+
11+
// WithHostID stashes the host id on ctx so a lower layer that only receives
12+
// host:port + credential (e.g. an SSH transport behind an interface that
13+
// can't easily grow a hostID parameter) can still look up and record this
14+
// host's connection profile.
15+
//
16+
// This is best-effort learning enrichment, not a required dial parameter:
17+
// a connection with no host id on the context simply skips the profile
18+
// lookup/record and dials in the default order.
19+
func WithHostID(ctx context.Context, id uuid.UUID) context.Context {
20+
return context.WithValue(ctx, hostIDKey{}, id)
21+
}
22+
23+
// HostIDFrom returns the host id stashed by WithHostID. The bool is false
24+
// when no (or a nil) id is present, so callers can guard the learning path.
25+
func HostIDFrom(ctx context.Context) (uuid.UUID, bool) {
26+
id, ok := ctx.Value(hostIDKey{}).(uuid.UUID)
27+
return id, ok && id != uuid.Nil
28+
}

internal/intelligence/collector/collector.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/Hanalyx/openwatch/internal/audit"
14+
"github.com/Hanalyx/openwatch/internal/connprofile"
1415
"github.com/Hanalyx/openwatch/internal/correlation"
1516
"github.com/Hanalyx/openwatch/internal/credential"
1617
"github.com/Hanalyx/openwatch/internal/eventbus"
@@ -232,6 +233,9 @@ func (s *Service) runCycleWithTransport(ctx context.Context, hf hostFacts) (Snap
232233
if s.transport == nil {
233234
return Snapshot{}, 0, errors.New("collector: ssh transport not wired")
234235
}
236+
// Carry the host id so a profile-aware transport can lead with this
237+
// host's known-good SSH auth method and record what authenticated.
238+
ctx = connprofile.WithHostID(ctx, hf.HostID)
235239
sess, err := s.transport.Dial(ctx, hf.Addr, hf.Port, hf.Cred)
236240
if err != nil {
237241
return Snapshot{}, 0, fmt.Errorf("collector: dial: %w", err)

internal/intelligence/discovery/discovery.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"github.com/Hanalyx/openwatch/internal/audit"
11+
"github.com/Hanalyx/openwatch/internal/connprofile"
1112
"github.com/Hanalyx/openwatch/internal/credential"
1213
"github.com/Hanalyx/openwatch/internal/eventbus"
1314
"github.com/Hanalyx/openwatch/internal/intelligence/probe"
@@ -266,6 +267,10 @@ func (s *Service) discoverWithTransport(ctx context.Context, hf hostFacts) (Syst
266267
return SystemFacts{}, errors.New("discovery: ssh transport not wired")
267268
}
268269

270+
// Carry the host id so a profile-aware transport can lead with this
271+
// host's known-good SSH auth method and record what authenticated.
272+
ctx = connprofile.WithHostID(ctx, hf.HostID)
273+
269274
sess, err := s.transport.Dial(ctx, hf.Addr, hf.Port, hf.Cred)
270275
if err != nil {
271276
return SystemFacts{}, fmt.Errorf("discovery: dial: %w", err)

internal/intelligence/discovery/transport.go

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@ import (
55
"errors"
66
"time"
77

8+
"github.com/google/uuid"
9+
"golang.org/x/crypto/ssh"
10+
11+
"github.com/Hanalyx/openwatch/internal/connprofile"
812
"github.com/Hanalyx/openwatch/internal/credential"
913
owssh "github.com/Hanalyx/openwatch/internal/ssh"
10-
"golang.org/x/crypto/ssh"
1114
)
1215

16+
// ConnProfileStore is the subset of connprofile the transport uses to lead
17+
// with the host's known-good SSH auth method and record what authenticated.
18+
// nil disables learning (dial in the default key-first order). The host id
19+
// is read from the context via connprofile.HostIDFrom.
20+
type ConnProfileStore interface {
21+
Get(ctx context.Context, hostID uuid.UUID) (connprofile.Profile, error)
22+
RecordSSHAuth(ctx context.Context, hostID uuid.UUID, m connprofile.SSHAuthMethod) error
23+
}
24+
1325
// SSHTransport is the seam between the discovery service and the actual
1426
// SSH path. Production uses sshTransport (wraps owssh.Dial + ssh.Session);
1527
// tests use stubSSHTransport.
@@ -42,33 +54,75 @@ const DefaultProbeTimeout = 10 * time.Second
4254
// and tests that want a real (not stubbed) transport can construct one
4355
// without internal package boundaries.
4456
type SSHTransportProd struct {
45-
mode owssh.Mode
46-
store owssh.KnownHostsStore
57+
mode owssh.Mode
58+
store owssh.KnownHostsStore
59+
profiles ConnProfileStore
60+
// dial is the seam over internal/ssh.Dial — overridden in tests to
61+
// exercise the auth-learning path (PreferAuth in / ObservedAuth out)
62+
// without standing up a real SSH server. Defaults to dialReal.
63+
dial func(ctx context.Context, host string, port int, cred *credential.Credential, opts owssh.DialOptions) (SSHSession, error)
4764
}
4865

4966
// NewSSHTransport returns a production SSHTransport with the given
5067
// host-key policy. NewService() calls this with TOFU + an in-memory
5168
// known-hosts store by default; cmd/openwatch can override with a
5269
// strict + persistent store later.
5370
func NewSSHTransport(mode owssh.Mode, store owssh.KnownHostsStore) *SSHTransportProd {
54-
return &SSHTransportProd{mode: mode, store: store}
71+
return &SSHTransportProd{mode: mode, store: store, dial: dialReal}
72+
}
73+
74+
// dialReal is the production dial: open the real SSH client and wrap it.
75+
func dialReal(ctx context.Context, host string, port int, cred *credential.Credential, opts owssh.DialOptions) (SSHSession, error) {
76+
client, err := owssh.Dial(ctx, host, port, cred, opts)
77+
if err != nil {
78+
return nil, err
79+
}
80+
return &SSHClientSession{client: client}, nil
81+
}
82+
83+
// WithProfiles enables per-host SSH auth-method learning: the transport
84+
// leads the dial with the host's recorded method and records which method
85+
// authenticated. The host id comes from connprofile.WithHostID on the ctx.
86+
// nil (the default) keeps the historical key-first, no-learning behaviour.
87+
func (t *SSHTransportProd) WithProfiles(p ConnProfileStore) *SSHTransportProd {
88+
t.profiles = p
89+
return t
5590
}
5691

5792
// Dial opens one SSH client connection and returns it as an SSHSession
58-
// that multiplexes ssh.Session per Run call.
93+
// that multiplexes ssh.Session per Run call. When a profile store is wired
94+
// and the ctx carries a host id, the dial leads with the host's recorded
95+
// auth method and records the one that authenticated (a hint, not a lock:
96+
// both methods are still offered, and a stale hint self-heals).
5997
func (t *SSHTransportProd) Dial(ctx context.Context, host string, port int, cred *credential.Credential) (SSHSession, error) {
6098
if cred == nil {
6199
return nil, errors.New("discovery: dial requires a resolved credential")
62100
}
63-
client, err := owssh.Dial(ctx, host, port, cred, owssh.DialOptions{
101+
102+
hostID, learn := connprofile.HostIDFrom(ctx)
103+
learn = learn && t.profiles != nil
104+
105+
opts := owssh.DialOptions{
64106
Mode: t.mode,
65107
Store: t.store,
66108
Timeout: owssh.DefaultDialTimeout,
67-
})
109+
}
110+
var observed string
111+
if learn {
112+
if p, err := t.profiles.Get(ctx, hostID); err == nil {
113+
opts.PreferAuth = string(p.SSHAuthMethod) // "key"/"password" match the ssh.Prefer* tokens
114+
}
115+
opts.ObservedAuth = &observed
116+
}
117+
118+
sess, err := t.dial(ctx, host, port, cred, opts)
68119
if err != nil {
69120
return nil, err
70121
}
71-
return &SSHClientSession{client: client}, nil
122+
if learn && observed != "" {
123+
_ = t.profiles.RecordSSHAuth(ctx, hostID, connprofile.SSHAuthMethod(observed))
124+
}
125+
return sess, nil
72126
}
73127

74128
// SSHClientSession is the per-host live SSH client. Each Run opens a
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// @spec system-connection-profile
2+
//
3+
// AC traceability (this file):
4+
//
5+
// AC-08 TestSSHTransportProd_AuthLearning
6+
//
7+
// The dial seam is stubbed so the auth-learning wiring (PreferAuth in,
8+
// ObservedAuth out, RecordSSHAuth on success) is exercised without a real
9+
// SSH server. The shared discovery prod transport is the one path both OS
10+
// discovery and OS intelligence (collector) dial through.
11+
12+
package discovery
13+
14+
import (
15+
"context"
16+
"errors"
17+
"testing"
18+
19+
"github.com/google/uuid"
20+
21+
"github.com/Hanalyx/openwatch/internal/connprofile"
22+
"github.com/Hanalyx/openwatch/internal/credential"
23+
owssh "github.com/Hanalyx/openwatch/internal/ssh"
24+
)
25+
26+
// recordingProfiles is an in-memory connprofile store for the test.
27+
type recordingProfiles struct {
28+
prefer connprofile.SSHAuthMethod
29+
getErr error
30+
gotID uuid.UUID
31+
recorded connprofile.SSHAuthMethod
32+
}
33+
34+
func (p *recordingProfiles) Get(_ context.Context, hostID uuid.UUID) (connprofile.Profile, error) {
35+
p.gotID = hostID
36+
if p.getErr != nil {
37+
return connprofile.Profile{}, p.getErr
38+
}
39+
return connprofile.Profile{SSHAuthMethod: p.prefer}, nil
40+
}
41+
42+
func (p *recordingProfiles) RecordSSHAuth(_ context.Context, _ uuid.UUID, m connprofile.SSHAuthMethod) error {
43+
p.recorded = m
44+
return nil
45+
}
46+
47+
func TestSSHTransportProd_AuthLearning(t *testing.T) {
48+
cred := &credential.Credential{Username: "u", AuthMethod: credential.AuthBoth, Password: "p"}
49+
50+
// stubDial captures the PreferAuth handed down and simulates the
51+
// observed method crypto/ssh would report on a successful handshake.
52+
newStubDial := func(gotPrefer *string, simulateObserved string) func(context.Context, string, int, *credential.Credential, owssh.DialOptions) (SSHSession, error) {
53+
return func(_ context.Context, _ string, _ int, _ *credential.Credential, opts owssh.DialOptions) (SSHSession, error) {
54+
*gotPrefer = opts.PreferAuth
55+
if opts.ObservedAuth != nil {
56+
*opts.ObservedAuth = simulateObserved
57+
}
58+
return learnStubSession{}, nil
59+
}
60+
}
61+
62+
t.Run("system-connection-profile/AC-08", func(t *testing.T) {
63+
hostID := uuid.Must(uuid.NewV7())
64+
profiles := &recordingProfiles{prefer: connprofile.AuthPassword}
65+
var gotPrefer string
66+
67+
tr := NewSSHTransport(owssh.ModeTOFU, owssh.NewMemoryStore()).WithProfiles(profiles)
68+
tr.dial = newStubDial(&gotPrefer, "password")
69+
70+
ctx := connprofile.WithHostID(context.Background(), hostID)
71+
if _, err := tr.Dial(ctx, "192.0.2.1", 22, cred); err != nil {
72+
t.Fatalf("dial: %v", err)
73+
}
74+
if gotPrefer != "password" {
75+
t.Errorf("lead-with: want PreferAuth=password, got %q", gotPrefer)
76+
}
77+
if profiles.gotID != hostID {
78+
t.Errorf("lookup: want Get(%s), got Get(%s)", hostID, profiles.gotID)
79+
}
80+
if profiles.recorded != connprofile.AuthPassword {
81+
t.Errorf("record: want recorded=password, got %q", profiles.recorded)
82+
}
83+
})
84+
85+
t.Run("no host id on ctx: no learning", func(t *testing.T) {
86+
profiles := &recordingProfiles{prefer: connprofile.AuthPassword}
87+
var gotPrefer string
88+
89+
tr := NewSSHTransport(owssh.ModeTOFU, owssh.NewMemoryStore()).WithProfiles(profiles)
90+
tr.dial = newStubDial(&gotPrefer, "key")
91+
92+
if _, err := tr.Dial(context.Background(), "192.0.2.1", 22, cred); err != nil {
93+
t.Fatalf("dial: %v", err)
94+
}
95+
if gotPrefer != "" {
96+
t.Errorf("no host id: want empty PreferAuth, got %q", gotPrefer)
97+
}
98+
if profiles.recorded != "" {
99+
t.Errorf("no host id: want no record, got %q", profiles.recorded)
100+
}
101+
})
102+
103+
t.Run("no store wired: no learning", func(t *testing.T) {
104+
var gotPrefer string
105+
tr := NewSSHTransport(owssh.ModeTOFU, owssh.NewMemoryStore())
106+
tr.dial = newStubDial(&gotPrefer, "key")
107+
108+
ctx := connprofile.WithHostID(context.Background(), uuid.Must(uuid.NewV7()))
109+
if _, err := tr.Dial(ctx, "192.0.2.1", 22, cred); err != nil {
110+
t.Fatalf("dial: %v", err)
111+
}
112+
if gotPrefer != "" {
113+
t.Errorf("no store: want empty PreferAuth, got %q", gotPrefer)
114+
}
115+
})
116+
117+
t.Run("profile lookup error is non-fatal", func(t *testing.T) {
118+
profiles := &recordingProfiles{getErr: errors.New("db down")}
119+
var gotPrefer string
120+
121+
tr := NewSSHTransport(owssh.ModeTOFU, owssh.NewMemoryStore()).WithProfiles(profiles)
122+
tr.dial = newStubDial(&gotPrefer, "password")
123+
124+
ctx := connprofile.WithHostID(context.Background(), uuid.Must(uuid.NewV7()))
125+
if _, err := tr.Dial(ctx, "192.0.2.1", 22, cred); err != nil {
126+
t.Fatalf("dial: want success despite lookup error, got %v", err)
127+
}
128+
if gotPrefer != "" {
129+
t.Errorf("lookup error: want default order (empty PreferAuth), got %q", gotPrefer)
130+
}
131+
// observed still recorded — learning continues even when the
132+
// lead-with hint was unavailable.
133+
if profiles.recorded != connprofile.AuthPassword {
134+
t.Errorf("record: want recorded=password, got %q", profiles.recorded)
135+
}
136+
})
137+
}
138+
139+
// learnStubSession is a no-op SSHSession for the dial-seam tests.
140+
type learnStubSession struct{}
141+
142+
func (learnStubSession) Run(context.Context, string) ([]byte, int, error) { return nil, 0, nil }
143+
func (learnStubSession) RunWithStdin(context.Context, string, []byte) ([]byte, int, error) {
144+
return nil, 0, nil
145+
}
146+
func (learnStubSession) Close() error { return nil }

0 commit comments

Comments
 (0)