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
6 changes: 6 additions & 0 deletions cmd/openwatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/Hanalyx/openwatch/internal/alerts"
"github.com/Hanalyx/openwatch/internal/audit"
"github.com/Hanalyx/openwatch/internal/config"
"github.com/Hanalyx/openwatch/internal/connprofile"
"github.com/Hanalyx/openwatch/internal/correlation"
"github.com/Hanalyx/openwatch/internal/credential"
"github.com/google/uuid"
Expand Down Expand Up @@ -506,6 +507,11 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
vars, err := cfgStore.LoadScanVars(ctx)
return vars, err
},
Profiles: connprofile.NewStore(pool),
Policy: func(ctx context.Context) (bool, error) {
cfg, err := cfgStore.LoadSecurity(ctx)
return cfg.AllowCredentialSudoPassword, err
},
}); scanErr != nil {
slog.WarnContext(bootCtx, "kensa scan wiring unavailable — on-demand scans will fail until the kensa-rules package is installed (or OPENWATCH_KENSA_RULES_DIR set)",
slog.String("error", scanErr.Error()))
Expand Down
6 changes: 6 additions & 0 deletions cmd/openwatch/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/Hanalyx/openwatch/internal/audit"
"github.com/Hanalyx/openwatch/internal/config"
"github.com/Hanalyx/openwatch/internal/connprofile"
"github.com/Hanalyx/openwatch/internal/correlation"
"github.com/Hanalyx/openwatch/internal/credential"
"github.com/Hanalyx/openwatch/internal/db"
Expand Down Expand Up @@ -183,6 +184,11 @@ func cmdWorker(cfg *config.Config, args []string, stdout, stderr *os.File) int {
vars, err := varStore.LoadScanVars(ctx)
return vars, err
},
Profiles: connprofile.NewStore(pool),
Policy: func(ctx context.Context) (bool, error) {
cfg, err := varStore.LoadSecurity(ctx)
return cfg.AllowCredentialSudoPassword, err
},
})
if err != nil {
slog.ErrorContext(bootCtx, "kensa scan wiring failed — is the kensa-rules package installed (or OPENWATCH_KENSA_RULES_DIR set)?",
Expand Down
129 changes: 129 additions & 0 deletions internal/connprofile/connprofile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Package connprofile is the per-host "last known good" SSH connection
// memory shared by every path that talks to a managed host (the liveness
// privilege probe, OS discovery, OS intelligence collection, and the
// compliance scan).
//
// The problem it solves: without memory, each connection re-discovers how
// to reach the host. It offers the public key to a host that only takes a
// password (a failed publickey attempt that counts against MaxAuthTries
// and can trip fail2ban), and it runs `sudo -n` on a host known to need a
// sudo password (a wasted round-trip before the `sudo -S` retry). This
// package records what actually worked so callers lead with the
// known-good choice.
//
// It is a HINT, never a lock. Callers still attempt the other methods if
// the recorded one fails and overwrite the record when the working choice
// changes, so a stale hint self-heals on the next connection. Treat a
// missing/unknown value as "no preference — try the normal order."
package connprofile

import (
"context"
"errors"
"fmt"

"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)

// SSHAuthMethod is the SSH auth method that last authenticated.
type SSHAuthMethod string

const (
AuthUnknown SSHAuthMethod = ""
AuthKey SSHAuthMethod = "key"
AuthPassword SSHAuthMethod = "password"
)

// SudoMode is the privilege-escalation mode that last reached root.
type SudoMode string

const (
SudoUnknown SudoMode = ""
SudoRoot SudoMode = "root" // login user is already root; no sudo
SudoNopasswd SudoMode = "nopasswd" // `sudo -n` works (NOPASSWD sudoers)
SudoPassword SudoMode = "password" // `sudo -S` with the credential password works
)

// Profile is a host's recorded connection preferences. Zero-valued fields
// (AuthUnknown / SudoUnknown) mean "not yet observed — no preference."
type Profile struct {
SSHAuthMethod SSHAuthMethod
SudoMode SudoMode
}

// Store reads and writes host connection profiles.
type Store struct {
pool *pgxpool.Pool
}

// NewStore returns a Store backed by the given pool.
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}

// Get returns the recorded profile for a host. A host with no row yet
// returns a zero Profile (both dimensions unknown) and a nil error — an
// absent hint is not an error, it just means "no preference."
func (s *Store) Get(ctx context.Context, hostID uuid.UUID) (Profile, error) {
var auth, sudo *string
err := s.pool.QueryRow(ctx,
`SELECT ssh_auth_method, sudo_mode FROM host_connection_profile WHERE host_id = $1`,
hostID,
).Scan(&auth, &sudo)
if errors.Is(err, pgx.ErrNoRows) {
return Profile{}, nil
}
if err != nil {
return Profile{}, fmt.Errorf("connprofile: get %s: %w", hostID, err)
}
p := Profile{}
if auth != nil {
p.SSHAuthMethod = SSHAuthMethod(*auth)
}
if sudo != nil {
p.SudoMode = SudoMode(*sudo)
}
return p, nil
}

// RecordSSHAuth persists the SSH auth method that just worked, leaving the
// sudo_mode column untouched. A no-op for AuthUnknown so callers can call
// it unconditionally. Recording failures are non-fatal to the caller's
// real work (a connection succeeded) — the caller logs and continues.
func (s *Store) RecordSSHAuth(ctx context.Context, hostID uuid.UUID, m SSHAuthMethod) error {
if m == AuthUnknown {
return nil
}
return s.upsert(ctx, hostID, ptr(string(m)), nil)
}

// RecordSudoMode persists the privilege mode that just reached root,
// leaving the ssh_auth_method column untouched. A no-op for SudoUnknown.
func (s *Store) RecordSudoMode(ctx context.Context, hostID uuid.UUID, m SudoMode) error {
if m == SudoUnknown {
return nil
}
return s.upsert(ctx, hostID, nil, ptr(string(m)))
}

// upsert writes the provided columns, preserving any column passed as nil
// via COALESCE so a single-dimension record never clobbers the other.
func (s *Store) upsert(ctx context.Context, hostID uuid.UUID, auth, sudo *string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO host_connection_profile (host_id, ssh_auth_method, sudo_mode, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (host_id) DO UPDATE SET
ssh_auth_method = COALESCE(EXCLUDED.ssh_auth_method, host_connection_profile.ssh_auth_method),
sudo_mode = COALESCE(EXCLUDED.sudo_mode, host_connection_profile.sudo_mode),
updated_at = now()`,
hostID, auth, sudo,
)
if err != nil {
return fmt.Errorf("connprofile: upsert %s: %w", hostID, err)
}
return nil
}

func ptr(s string) *string { return &s }
140 changes: 140 additions & 0 deletions internal/connprofile/connprofile_db_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// @spec system-connection-profile
//
// Connection-profile store integration tests. Skipped without
// OPENWATCH_TEST_DSN.

package connprofile

import (
"context"
"os"
"testing"
"time"

"github.com/Hanalyx/openwatch/internal/db"
"github.com/Hanalyx/openwatch/internal/db/migrations"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)

func testDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("OPENWATCH_TEST_DSN")
if dsn == "" {
t.Skip("set OPENWATCH_TEST_DSN to run connprofile store integration tests")
}
return dsn
}

func seedHost(t *testing.T, pool *pgxpool.Pool) uuid.UUID {
t.Helper()
ctx := context.Background()
userID, _ := uuid.NewV7()
if _, err := pool.Exec(ctx,
`INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4)`,
userID, "cp-creator-"+userID.String(), "cp-"+userID.String()+"@example.com", "x",
); err != nil {
t.Fatalf("seed user: %v", err)
}
hostID, _ := uuid.NewV7()
if _, err := pool.Exec(ctx,
`INSERT INTO hosts (id, hostname, ip_address, created_by) VALUES ($1, $2, $3::inet, $4)`,
hostID, "cp-host-"+hostID.String(), "192.0.2.10", userID,
); err != nil {
t.Fatalf("seed host: %v", err)
}
return hostID
}

func freshStore(t *testing.T) (*Store, *pgxpool.Pool) {
t.Helper()
dsn := testDSN(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
pool, err := db.NewPool(ctx, dsn, 5)
if err != nil {
t.Fatalf("NewPool: %v", err)
}
t.Cleanup(pool.Close)
if err := migrations.Apply(ctx, pool); err != nil {
t.Fatalf("migrations.Apply: %v", err)
}
return NewStore(pool), pool
}

// @ac AC-01
// AC-01: Get on a host with no recorded profile returns a zero Profile
// (both dimensions unknown) and no error.
func TestGet_AbsentIsZeroNoError(t *testing.T) {
t.Run("system-connection-profile/AC-01", func(t *testing.T) {
store, pool := freshStore(t)
hostID := seedHost(t, pool)
got, err := store.Get(context.Background(), hostID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.SSHAuthMethod != AuthUnknown || got.SudoMode != SudoUnknown {
t.Errorf("absent profile = %+v, want zero", got)
}
})
}

// @ac AC-02
// AC-02: recording one dimension never clobbers the other (COALESCE
// upsert), and re-recording overwrites that dimension.
func TestRecord_PartialUpsertPreservesOtherDimension(t *testing.T) {
t.Run("system-connection-profile/AC-02", func(t *testing.T) {
store, pool := freshStore(t)
ctx := context.Background()
hostID := seedHost(t, pool)

// Record SSH auth only; sudo stays unknown.
if err := store.RecordSSHAuth(ctx, hostID, AuthKey); err != nil {
t.Fatalf("RecordSSHAuth: %v", err)
}
got, _ := store.Get(ctx, hostID)
if got.SSHAuthMethod != AuthKey || got.SudoMode != SudoUnknown {
t.Fatalf("after RecordSSHAuth = %+v, want {key, unknown}", got)
}

// Record sudo only; SSH auth must be preserved.
if err := store.RecordSudoMode(ctx, hostID, SudoPassword); err != nil {
t.Fatalf("RecordSudoMode: %v", err)
}
got, _ = store.Get(ctx, hostID)
if got.SSHAuthMethod != AuthKey || got.SudoMode != SudoPassword {
t.Fatalf("after RecordSudoMode = %+v, want {key, password}", got)
}

// Overwrite a dimension (host reconfigured key->password).
if err := store.RecordSSHAuth(ctx, hostID, AuthPassword); err != nil {
t.Fatalf("RecordSSHAuth overwrite: %v", err)
}
got, _ = store.Get(ctx, hostID)
if got.SSHAuthMethod != AuthPassword || got.SudoMode != SudoPassword {
t.Fatalf("after overwrite = %+v, want {password, password}", got)
}
})
}

// @ac AC-03
// AC-03: recording an unknown value is a no-op (callers may call
// unconditionally) and never creates a row.
func TestRecord_UnknownIsNoOp(t *testing.T) {
t.Run("system-connection-profile/AC-03", func(t *testing.T) {
store, pool := freshStore(t)
ctx := context.Background()
hostID := seedHost(t, pool)
if err := store.RecordSSHAuth(ctx, hostID, AuthUnknown); err != nil {
t.Fatalf("RecordSSHAuth(unknown): %v", err)
}
if err := store.RecordSudoMode(ctx, hostID, SudoUnknown); err != nil {
t.Fatalf("RecordSudoMode(unknown): %v", err)
}
var n int
_ = pool.QueryRow(ctx, `SELECT count(*) FROM host_connection_profile WHERE host_id = $1`, hostID).Scan(&n)
if n != 0 {
t.Errorf("unknown records created %d rows, want 0", n)
}
})
}
41 changes: 41 additions & 0 deletions internal/db/migrations/0035_host_connection_profile.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- 0035_host_connection_profile.sql
--
-- Per-host "last known good" SSH connection profile. OpenWatch talks to a
-- managed host from four code paths (liveness privilege probe, OS
-- discovery, OS intelligence collection, and the compliance scan). Each
-- has to decide, every time it connects, (a) which SSH auth method to
-- offer first and (b) how to escalate to root. Without memory, every
-- connection re-discovers this: it offers the public key even to a host
-- that only accepts the password (a failed publickey attempt that counts
-- against MaxAuthTries and can trip fail2ban), and it runs `sudo -n` even
-- on a host known to need a sudo password (a wasted round-trip before the
-- `sudo -S` retry).
--
-- This table records what actually worked last time so each path can lead
-- with the known-good choice. It is a hint, never a lock: callers still
-- fall back to the other methods if the recorded one fails (keys rotate,
-- sudoers change) and rewrite the row when the working choice changes, so
-- a stale hint self-heals on the next connection.
--
-- One row per host, UPSERTed by whichever path last connected. Columns are
-- nullable: a value is absent until that dimension has been observed once.

-- +goose Up
CREATE TABLE host_connection_profile (
host_id UUID PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,

-- The SSH auth method that last authenticated successfully. Drives the
-- order auth methods are offered to crypto/ssh on the next dial.
ssh_auth_method TEXT CHECK (ssh_auth_method IN ('key', 'password')),

-- The privilege mode that last reached root successfully:
-- 'root' — the login user is already root; no sudo wrapping.
-- 'nopasswd' — `sudo -n` succeeded (NOPASSWD sudoers).
-- 'password' — `sudo -S` with the credential password succeeded.
sudo_mode TEXT CHECK (sudo_mode IN ('root', 'nopasswd', 'password')),

updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE IF EXISTS host_connection_profile;
Loading
Loading