diff --git a/VERSION b/VERSION index 90d7d2b4..dddc2e9c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.3 +0.33.4 diff --git a/internal/adapters/probes/network.go b/internal/adapters/probes/network.go new file mode 100644 index 00000000..86c8866f --- /dev/null +++ b/internal/adapters/probes/network.go @@ -0,0 +1,171 @@ +// Package probes implements the Checker port for each check kind. +// Each file exports a Checker constructor (e.g. NewHTTPChecker) that +// returns ports.Checker wrapping the scheduler's probe logic. +package probes + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/dtoro/oikos/internal/core/ports" +) + +// httpChecker is the HTTP reachability probe. +type httpChecker struct { + client *http.Client +} + +// NewHTTPChecker builds an HTTP probe with a connection-scoped client. +func NewHTTPChecker() ports.Checker { + return &httpChecker{ + client: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + } +} + +func (c *httpChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult { + cfg := configMap(def) + + url, _ := parseStr(cfg, "url") + if url == "" { + return ports.CheckResult{State: "unknown", Message: "no url in config"} + } + maxStatus := int(parseFloat(cfg, "max_status", 500)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return ports.CheckResult{State: "unknown", Message: err.Error()} + } + + resp, err := c.client.Do(req) + if err != nil { + return ports.CheckResult{State: "unknown", Message: err.Error()} + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + code := resp.StatusCode + if code > maxStatus { + return ports.CheckResult{ + State: "critical", Value: float64(code), + Message: fmt.Sprintf("HTTP %d exceeds max_status %d", code, maxStatus), + } + } + return ports.CheckResult{State: "ok", Value: float64(code)} +} + +// tcpChecker checks TCP port reachability. +type tcpChecker struct{} + +func NewTCPChecker() ports.Checker { return &tcpChecker{} } + +func (c *tcpChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult { + cfg := configMap(def) + + host, _ := parseStr(cfg, "host") + port := int(parseFloat(cfg, "port", 0)) + timeout := time.Duration(parseFloat(cfg, "timeout", 5)) * time.Second + + addr := fmt.Sprintf("%s:%d", host, port) + d := &net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return ports.CheckResult{State: "critical", Message: err.Error()} + } + conn.Close() + return ports.CheckResult{State: "ok", Value: float64(port)} +} + +// pingChecker is the ICMP/connectivity probe. Falls back to TCP ping on +// systems without raw socket access. +type pingChecker struct{} + +func NewPingChecker() ports.Checker { return &pingChecker{} } + +func (c *pingChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult { + cfg := configMap(def) + + host, _ := parseStr(cfg, "host") + if host == "" { + return ports.CheckResult{State: "unknown", Message: "no host in config"} + } + port := int(parseFloat(cfg, "port", 80)) + timeout := time.Duration(parseFloat(cfg, "timeout", 5)) * time.Second + + d := &net.Dialer{Timeout: timeout} + addr := fmt.Sprintf("%s:%d", host, port) + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return ports.CheckResult{State: "critical", Message: err.Error(), Value: -1} + } + conn.Close() + return ports.CheckResult{State: "ok", Value: 0} +} + +// dnsChecker resolves DNS names. +type dnsChecker struct{} + +func NewDNSChecker() ports.Checker { return &dnsChecker{} } + +func (c *dnsChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult { + cfg := configMap(def) + + name, _ := parseStr(cfg, "name") + if name == "" { + return ports.CheckResult{State: "unknown", Message: "no name in config"} + } + var r net.Resolver + addrs, err := r.LookupHost(ctx, name) + if err != nil { + return ports.CheckResult{State: "critical", Message: err.Error()} + } + return ports.CheckResult{State: "ok", Value: float64(len(addrs))} +} + + +// configMap unmarshals a check's JSON config into a map. +func configMap(def ports.CheckDef) map[string]any { + var m map[string]any + if len(def.Config) > 0 { + json.Unmarshal(def.Config, &m) + } + if m == nil { + m = map[string]any{} + } + return m +} +func parseStr(m map[string]any, key string) (string, bool) { + v, ok := m[key] + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok +} + +func parseFloat(m map[string]any, key string, def float64) float64 { + v, ok := m[key] + if !ok { + return def + } + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case json.Number: + f, _ := n.Float64() + return f + } + return def +} \ No newline at end of file diff --git a/internal/adapters/probes/ssh.go b/internal/adapters/probes/ssh.go new file mode 100644 index 00000000..8a7e5c4f --- /dev/null +++ b/internal/adapters/probes/ssh.go @@ -0,0 +1,105 @@ +package probes + +import ( + "context" + "fmt" + "strings" + + "github.com/dtoro/oikos/internal/actuator" + "github.com/dtoro/oikos/internal/core/ports" + "golang.org/x/crypto/ssh" +) + +// SSHChecker runs ssh-script probes via the actuator. Constructed with a +// key source so the caller controls SSH key resolution. +type SSHChecker struct { + pool *actuator.DialPool + signerFn func() (ssh.Signer, error) +} + +func NewSSHChecker(pool *actuator.DialPool, signerFn func() (ssh.Signer, error)) *SSHChecker { + return &SSHChecker{pool: pool, signerFn: signerFn} +} + +func (c *SSHChecker) Check(ctx context.Context, def ports.CheckDef, target ports.Target) ports.CheckResult { + cfg := configMap(def) + script, _ := parseStr(cfg, "script") + if script == "" { + return ports.CheckResult{State: "unknown", Message: "no script in config"} + } + + signer, err := c.signerFn() + if err != nil { + return ports.CheckResult{State: "unknown", Message: fmt.Sprintf("signer: %v", err)} + } + + client, err := actuator.Dial(ctx, actuator.DialOptions{ + Host: target.Host, + User: target.User, + Signer: signer, + }) + if err != nil { + return ports.CheckResult{State: "unknown", Message: fmt.Sprintf("dial %s: %v", target.Host, err)} + } + defer client.Close() + + cmd := fmt.Sprintf("/opt/oikos/checks/%s", script) + if target.Wrap != nil { + cmd = target.Wrap(cmd) + } + + output, err := actuator.RunCombinedOutput(ctx, client, cmd) + if err != nil { + return ports.CheckResult{ + Value: -1, + State: "critical", + Message: err.Error(), + } + } + return parseSSHResult(output) +} + +func parseSSHResult(output []byte) ports.CheckResult { + line := strings.TrimSpace(string(output)) + var value float64 + if len(line) > 0 { + parts := strings.SplitN(line, " ", 3) + switch parts[0] { + case "OK": + if len(parts) > 1 { + fmt.Sscanf(parts[1], "%f", &value) + } + return ports.CheckResult{State: "ok", Value: value, Message: line} + case "WARN": + if len(parts) > 1 { + fmt.Sscanf(parts[1], "%f", &value) + } + return ports.CheckResult{State: "warning", Value: value, Message: line} + case "CRIT": + if len(parts) > 1 { + fmt.Sscanf(parts[1], "%f", &value) + } + return ports.CheckResult{State: "critical", Value: value, Message: line} + } + } + return ports.CheckResult{State: "unknown", Message: "unparseable output"} +} + +// Registry maps check kinds to Checker implementations. +type Registry map[string]ports.Checker + +func NewRegistry() Registry { + return Registry{ + "http": NewHTTPChecker(), + "tcp": NewTCPChecker(), + "ping": NewPingChecker(), + "dns": NewDNSChecker(), + "vm-status": nil, + "ssh-script": nil, + "backup-freshness": nil, + "cert-expiry": nil, + } +} + +func (r Registry) Get(kind string) ports.Checker { return r[kind] } +func (r Registry) Register(kind string, c ports.Checker) { r[kind] = c } \ No newline at end of file