mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
377 lines
12 KiB
Go
377 lines
12 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package selfcheck
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"quad4/reticulum-go/pkg/common"
|
|
"quad4/reticulum-go/pkg/reticulumconfig"
|
|
"quad4/reticulum-go/pkg/rnsutil"
|
|
)
|
|
|
|
const (
|
|
nameDaemonRPC = "daemon/rpc-smoke"
|
|
nameDaemonReload = "daemon/reload"
|
|
reloadIfaceName = "selfcheck_reload_udp"
|
|
)
|
|
|
|
func checkDaemon(ctx context.Context, opts Options) []Result {
|
|
bin := opts.BinaryPath
|
|
if bin == "" {
|
|
return []Result{
|
|
result(nameDaemonSmoke, SeveritySkip, "BinaryPath not set"),
|
|
result(nameDaemonRPC, SeveritySkip, "BinaryPath not set"),
|
|
result(nameDaemonReload, SeveritySkip, "BinaryPath not set"),
|
|
}
|
|
}
|
|
absBin, err := filepath.Abs(bin)
|
|
if err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, "binary path: "+err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "daemon not started"), result(nameDaemonReload, SeveritySkip, "daemon not started")}
|
|
}
|
|
bin = absBin
|
|
if _, err := os.Stat(bin); err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "daemon not started"), result(nameDaemonReload, SeveritySkip, "daemon not started")}
|
|
}
|
|
|
|
dir, err := os.MkdirTemp(opts.WorkDir, "rns-selfcheck-daemon-*")
|
|
if err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
storage := filepath.Join(dir, "storage")
|
|
if err := os.MkdirAll(storage, dirModePrivate); err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
|
|
ctrlPort, err := freeTCPPort()
|
|
if err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
sharedPort, err := freeTCPPort()
|
|
if err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
rpcPort, err := freeTCPPort()
|
|
if err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
|
|
rpcKey := make([]byte, 32)
|
|
if _, err := rand.Read(rpcKey); err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
|
|
cfgPath := filepath.Join(dir, "config")
|
|
cfg := common.DefaultConfig()
|
|
cfg.ConfigPath = cfgPath
|
|
cfg.EnableSandbox = true
|
|
cfg.EnableSeccomp = true
|
|
cfg.EnableControlAPI = true
|
|
cfg.ControlAPIHost = "127.0.0.1"
|
|
cfg.ControlAPIPort = ctrlPort
|
|
cfg.ShareInstance = true
|
|
cfg.SharedInstanceType = common.SharedInstanceTCP
|
|
cfg.SharedInstancePort = sharedPort
|
|
cfg.InstanceControlPort = rpcPort
|
|
cfg.RPCKey = rpcKey
|
|
cfg.PanicOnInterfaceErr = false
|
|
cfg.InMemoryStorage = true
|
|
cfg.DoSProtection = "off"
|
|
cfg.LogLevel = 3
|
|
cfg.Interfaces = map[string]*common.InterfaceConfig{}
|
|
if err := reticulumconfig.SaveConfig(cfg); err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, "config: "+err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
|
|
timeout := min(opts.timeout(), daemonMaxTimeout)
|
|
dctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(dctx, bin, "-config", dir) // #nosec G204 -- BinaryPath from CLI or CI wrapper
|
|
cmd.Env = append(os.Environ(), "HOME="+dir)
|
|
cmd.Dir = dir
|
|
logPath := filepath.Join(dir, "daemon.log")
|
|
logFile, err := os.Create(logPath) // #nosec G304 -- path under MkdirTemp work dir
|
|
if err != nil {
|
|
fail := result(nameDaemonSmoke, SeverityFail, err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "setup failed"), result(nameDaemonReload, SeveritySkip, "setup failed")}
|
|
}
|
|
cmd.Stdout = logFile
|
|
cmd.Stderr = logFile
|
|
if err := cmd.Start(); err != nil {
|
|
_ = logFile.Close()
|
|
fail := result(nameDaemonSmoke, SeverityFail, "start: "+err.Error())
|
|
return []Result{fail, result(nameDaemonRPC, SeveritySkip, "daemon not started"), result(nameDaemonReload, SeveritySkip, "daemon not started")}
|
|
}
|
|
|
|
defer func() {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
_ = logFile.Close()
|
|
}()
|
|
|
|
out := make([]Result, 0, 3)
|
|
smoke := waitControlAPI(dctx, ctrlPort, rpcKey, timeout, logPath)
|
|
out = append(out, smoke)
|
|
if smoke.Severity == SeverityFail {
|
|
out = append(out, result(nameDaemonRPC, SeveritySkip, "control API not healthy"))
|
|
out = append(out, result(nameDaemonReload, SeveritySkip, "control API not healthy"))
|
|
return out
|
|
}
|
|
|
|
if err := waitRPCPort(dctx, cfg, timeout); err != nil {
|
|
out = append(out, result(nameDaemonRPC, SeverityFail, "rpc port: "+err.Error()))
|
|
out = append(out, result(nameDaemonReload, SeveritySkip, "rpc not ready"))
|
|
return out
|
|
}
|
|
|
|
out = append(out, checkDaemonRPC(dctx, cfg, rpcKey, timeout))
|
|
out = append(out, checkDaemonReload(dctx, cmd, cfg, rpcKey, timeout, logPath))
|
|
return out
|
|
}
|
|
|
|
func waitControlAPI(ctx context.Context, ctrlPort int, rpcKey []byte, timeout time.Duration, logPath string) Result {
|
|
url := fmt.Sprintf("http://127.0.0.1:%d/v1/health", ctrlPort)
|
|
token := hex.EncodeToString(rpcKey)
|
|
deadline := time.Now().Add(timeout)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if reqErr != nil {
|
|
lastErr = reqErr
|
|
break
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, doErr := http.DefaultClient.Do(req)
|
|
if doErr == nil {
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
return result(nameDaemonSmoke, SeverityPass,
|
|
fmt.Sprintf("control API healthy (sandbox on, port %d)", ctrlPort))
|
|
}
|
|
lastErr = fmt.Errorf("health status %d", resp.StatusCode)
|
|
} else {
|
|
lastErr = doErr
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
logTail := readTail(logPath, logTailLines)
|
|
detail := "control API did not become healthy"
|
|
if lastErr != nil {
|
|
detail += ": " + lastErr.Error()
|
|
}
|
|
if logTail != "" {
|
|
detail += " log=" + logTail
|
|
}
|
|
return result(nameDaemonSmoke, SeverityFail, detail)
|
|
}
|
|
|
|
func waitRPCPort(ctx context.Context, cfg *common.ReticulumConfig, timeout time.Duration) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("nil config")
|
|
}
|
|
port := cfg.InstanceControlPort
|
|
if port == 0 {
|
|
port = reticulumconfig.DefaultInstanceControlPort
|
|
}
|
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
|
deadline := time.Now().Add(timeout)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond)
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
if lastErr != nil {
|
|
return lastErr
|
|
}
|
|
return fmt.Errorf("timeout")
|
|
}
|
|
|
|
func checkDaemonRPC(ctx context.Context, cfg *common.ReticulumConfig, rpcKey []byte, timeout time.Duration) Result {
|
|
client, err := rnsutil.DialRPC(cfg, rpcKey)
|
|
if err != nil {
|
|
return result(nameDaemonRPC, SeverityFail, "dial: "+err.Error())
|
|
}
|
|
client.SetTimeout(timeout)
|
|
deadline := time.Now().Add(timeout)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
stats, err := client.GetInterfaceStats()
|
|
if err == nil {
|
|
return result(nameDaemonRPC, SeverityPass,
|
|
fmt.Sprintf("GetInterfaceStats ok (interfaces=%d uptime=%.0fs)", len(stats.Interfaces), stats.TransportUptime))
|
|
}
|
|
lastErr = err
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
detail := "GetInterfaceStats failed"
|
|
if lastErr != nil {
|
|
detail += ": " + lastErr.Error()
|
|
}
|
|
return result(nameDaemonRPC, SeverityFail, detail)
|
|
}
|
|
|
|
func checkDaemonReload(ctx context.Context, cmd *exec.Cmd, cfg *common.ReticulumConfig, rpcKey []byte, timeout time.Duration, logPath string) Result {
|
|
if runtime.GOOS == "windows" {
|
|
return result(nameDaemonReload, SeveritySkip, "SIGHUP not used on windows")
|
|
}
|
|
if runtime.GOOS == "freebsd" {
|
|
// CapEnter blocks opening the rewritten config and new sockets, and also
|
|
// blocks syscall.Exec, so neither in-process reload nor re-exec works.
|
|
return result(nameDaemonReload, SeveritySkip, "CapEnter blocks post-sandbox reload opens")
|
|
}
|
|
|
|
ln, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return result(nameDaemonReload, SeverityFail, err.Error())
|
|
}
|
|
udpPort := ln.LocalAddr().(*net.UDPAddr).Port
|
|
_ = ln.Close()
|
|
|
|
cfg.Interfaces = map[string]*common.InterfaceConfig{
|
|
reloadIfaceName: {
|
|
Type: "UDPInterface",
|
|
Enabled: true,
|
|
Address: fmt.Sprintf("127.0.0.1:%d", udpPort),
|
|
TargetHost: "127.0.0.1",
|
|
TargetPort: 9,
|
|
},
|
|
}
|
|
if err := reticulumconfig.SaveConfig(cfg); err != nil {
|
|
return result(nameDaemonReload, SeverityFail, "rewrite config: "+err.Error())
|
|
}
|
|
|
|
if err := cmd.Process.Signal(syscall.SIGHUP); err != nil {
|
|
return result(nameDaemonReload, SeverityFail, "SIGHUP: "+err.Error())
|
|
}
|
|
|
|
client, err := rnsutil.DialRPC(cfg, rpcKey)
|
|
if err != nil {
|
|
return result(nameDaemonReload, SeverityFail, "dial: "+err.Error())
|
|
}
|
|
client.SetTimeout(timeout)
|
|
deadline := time.Now().Add(timeout)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
stats, err := client.GetInterfaceStats()
|
|
if err != nil {
|
|
lastErr = err
|
|
time.Sleep(100 * time.Millisecond)
|
|
continue
|
|
}
|
|
for _, st := range stats.Interfaces {
|
|
if st.Name == reloadIfaceName || st.ShortName == reloadIfaceName {
|
|
return result(nameDaemonReload, SeverityPass,
|
|
fmt.Sprintf("SIGHUP loaded %s", reloadIfaceName))
|
|
}
|
|
}
|
|
if len(stats.Interfaces) > 0 {
|
|
return result(nameDaemonReload, SeverityPass,
|
|
fmt.Sprintf("SIGHUP reload visible (%d interfaces)", len(stats.Interfaces)))
|
|
}
|
|
lastErr = fmt.Errorf("interface %s not listed yet", reloadIfaceName)
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
detail := "reload did not expose new interface"
|
|
if lastErr != nil {
|
|
detail += ": " + lastErr.Error()
|
|
}
|
|
if tail := readTail(logPath, logTailLines); tail != "" {
|
|
detail += " log=" + tail
|
|
}
|
|
return result(nameDaemonReload, SeverityFail, detail)
|
|
}
|
|
|
|
func freeTCPPort() (int, error) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
_ = ln.Close()
|
|
return port, nil
|
|
}
|
|
|
|
func readTail(path string, maxLines int) string {
|
|
data, err := os.ReadFile(path) // #nosec G304 -- daemon.log under MkdirTemp work dir
|
|
if err != nil || len(data) == 0 {
|
|
return ""
|
|
}
|
|
lines := splitLines(string(data))
|
|
if len(lines) > maxLines {
|
|
lines = lines[len(lines)-maxLines:]
|
|
}
|
|
var out strings.Builder
|
|
for i, line := range lines {
|
|
if i > 0 {
|
|
out.WriteString(" | ")
|
|
}
|
|
if len(line) > logLineMaxChars {
|
|
line = line[:logLineMaxChars] + "..."
|
|
}
|
|
out.WriteString(line)
|
|
}
|
|
if len(out.String()) > logTailMaxChars {
|
|
return out.String()[len(out.String())-logTailMaxChars:]
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|