mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
* feat(daemon): add CC_LOG_MAX_BACKUPS env var support (#1222) PR #1243 only addressed CC_LOG_MAX_SIZE while leaving the backup count hard-wired to one (.log.1). That still loses any post-mortem context older than one rotation, which is the same class of failure users reported on #1222. This change adds the matching knob so the post-mortem trail is configurable, with the same flag > env > default priority used for size. - daemon: add ParseLogBackups(s) (>=1, no unit suffix, error echoes input) and DefaultLogMaxBackups = 3. - daemon: extend RotatingWriter with maxBackups; rotateLocked walks the chain (delete .N, shift .(N-1) -> .N .. .1 -> .2, rename active -> .1, reopen) and a public Rotate() hook for tests/SIGHUP. - daemon: Config/Meta gain LogMaxBackups; Resolve() defaults to 3. - cmd/cc-connect: resolveLogMaxBackups + preScanLogMaxBackupsFlag + --log-max-backups flag; startup log now reports max_backups and its source. The rotating-writer setup happens before flag.Parse so the pre-scan keeps the flag effective there too. - daemon/launchd.go, daemon/systemd.go, daemon/windows.go: thread CC_LOG_MAX_BACKUPS through the service templates so a fresh install picks it up. - tests: TestParseLogBackups (19 subtests + error-echo), three new RotatingWriter tests (chain, disabled, fallback), four resolver tests + pre-scan tests in cmd/cc-connect. TestIssue1222_BackupRetention pins the new env-var behaviour as the regression test for the follow-up to #1222. * fix(daemon): silence errcheck on logrotate_test.go defer Close QA review (run 27109765660) flagged defer w.Close() in the 4 backup-related tests added by #1260. Wrap each in defer func() { _ = w.Close() }() so errcheck is satisfied without changing test semantics (temp-dir cleanup is best-effort). Verified locally: - golangci-lint --new-from-rev origin/main ./daemon/... -> 0 issues - go test -count=1 -tags no_web ./daemon/ ./cmd/cc-connect/ -> ok --------- Co-authored-by: cc-connect dev-claudecode <dev-claudecode@cc-connect.local> Co-authored-by: Claude <noreply@anthropic.com>
357 lines
10 KiB
Go
357 lines
10 KiB
Go
//go:build linux
|
|
|
|
package daemon
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
systemdServiceName = ServiceName + ".service"
|
|
)
|
|
|
|
type systemdManager struct {
|
|
system bool // true = system-level (/etc/systemd/system), false = user-level (~/.config/systemd/user)
|
|
}
|
|
|
|
func newPlatformManager() (Manager, error) {
|
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
|
return nil, fmt.Errorf("systemctl not found: systemd is required on Linux; if running in a container without systemd, use nohup, tmux, or screen instead")
|
|
}
|
|
|
|
isRoot := os.Getuid() == 0
|
|
|
|
if isRoot {
|
|
if err := checkSystemdRunning(true); err != nil {
|
|
return nil, err
|
|
}
|
|
return &systemdManager{system: true}, nil
|
|
}
|
|
|
|
if err := checkSystemdRunning(false); err != nil {
|
|
return nil, err
|
|
}
|
|
return &systemdManager{system: false}, nil
|
|
}
|
|
|
|
func (m *systemdManager) Platform() string {
|
|
if m.system {
|
|
return "systemd (system)"
|
|
}
|
|
return "systemd (user)"
|
|
}
|
|
|
|
func (m *systemdManager) Install(cfg Config) error {
|
|
unitPath := m.unitPath()
|
|
|
|
if err := os.MkdirAll(filepath.Dir(unitPath), 0755); err != nil {
|
|
return fmt.Errorf("create systemd dir: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0755); err != nil {
|
|
return fmt.Errorf("create log dir: %w", err)
|
|
}
|
|
|
|
unit := m.buildUnit(cfg)
|
|
// 0600: unit file may contain captured secret values (config.toml ${ENV}
|
|
// placeholders and any EnvDiscoverer extension output). For system-level
|
|
// units (/etc/systemd/system/) the file is owned by root and remains
|
|
// readable by root only; for user-level units under
|
|
// ~/.config/systemd/user it remains owner-only. WriteFile only applies
|
|
// perm on create, so Chmod afterwards is required to harden reinstalls
|
|
// of pre-existing 0644 units from earlier cc-connect versions.
|
|
if err := os.WriteFile(unitPath, []byte(unit), 0600); err != nil {
|
|
return fmt.Errorf("write unit file: %w", err)
|
|
}
|
|
if err := os.Chmod(unitPath, 0600); err != nil {
|
|
return fmt.Errorf("chmod unit file: %w", err)
|
|
}
|
|
|
|
for _, cmdArgs := range [][]string{
|
|
m.sysArgs("daemon-reload"),
|
|
m.sysArgs("enable", systemdServiceName),
|
|
m.sysArgs("restart", systemdServiceName),
|
|
} {
|
|
if out, err := runSystemctl(cmdArgs...); err != nil {
|
|
return fmt.Errorf("systemctl %s: %s (%w)", strings.Join(cmdArgs, " "), out, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *systemdManager) Uninstall() error {
|
|
if _, err := runSystemctl(m.sysArgs("disable", "--now", systemdServiceName)...); err != nil {
|
|
slog.Warn("systemd: disable failed", "error", err)
|
|
}
|
|
|
|
unitPath := m.unitPath()
|
|
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("remove unit: %w", err)
|
|
}
|
|
|
|
if _, err := runSystemctl(m.sysArgs("daemon-reload")...); err != nil {
|
|
slog.Warn("systemd: daemon-reload failed", "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *systemdManager) Start() error {
|
|
out, err := runSystemctl(m.sysArgs("start", systemdServiceName)...)
|
|
if err != nil {
|
|
return fmt.Errorf("start: %s (%w)", out, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *systemdManager) Stop() error {
|
|
out, err := runSystemctl(m.sysArgs("stop", systemdServiceName)...)
|
|
if err != nil {
|
|
return fmt.Errorf("stop: %s (%w)", out, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *systemdManager) Restart() error {
|
|
out, err := runSystemctl(m.sysArgs("restart", systemdServiceName)...)
|
|
if err != nil {
|
|
return fmt.Errorf("restart: %s (%w)", out, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *systemdManager) Status() (*Status, error) {
|
|
st := &Status{Platform: m.Platform()}
|
|
|
|
unitPath := m.unitPath()
|
|
if _, err := os.Stat(unitPath); err != nil {
|
|
return st, nil
|
|
}
|
|
st.Installed = true
|
|
|
|
out, err := runSystemctl(m.sysArgs("show", systemdServiceName,
|
|
"--no-page", "--property", "ActiveState,MainPID")...)
|
|
if err != nil {
|
|
return st, nil
|
|
}
|
|
|
|
props := parseKeyValue(out)
|
|
if strings.EqualFold(props["ActiveState"], "active") {
|
|
st.Running = true
|
|
}
|
|
if pid, err := strconv.Atoi(props["MainPID"]); err == nil && pid > 0 {
|
|
st.PID = pid
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// ── helpers ─────────────────────────────────────────────────
|
|
|
|
// sysArgs prepends --user flag for user-level managers.
|
|
func (m *systemdManager) sysArgs(args ...string) []string {
|
|
if m.system {
|
|
return args
|
|
}
|
|
return append([]string{"--user"}, args...)
|
|
}
|
|
|
|
func (m *systemdManager) unitPath() string {
|
|
if m.system {
|
|
return filepath.Join("/etc/systemd/system", systemdServiceName)
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".config", "systemd", "user", systemdServiceName)
|
|
}
|
|
|
|
func (m *systemdManager) buildUnit(cfg Config) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("[Unit]\n")
|
|
sb.WriteString("Description=cc-connect - AI Agent Chat Bridge\n")
|
|
sb.WriteString("After=network-online.target\n")
|
|
sb.WriteString("Wants=network-online.target\n\n")
|
|
|
|
sb.WriteString("[Service]\n")
|
|
sb.WriteString("Type=simple\n")
|
|
fmt.Fprintf(&sb, "ExecStart=%s\n", cfg.BinaryPath)
|
|
fmt.Fprintf(&sb, "WorkingDirectory=%s\n", cfg.WorkDir)
|
|
sb.WriteString("Restart=on-failure\n")
|
|
sb.WriteString("RestartSec=10\n")
|
|
fmt.Fprintf(&sb, "Environment=\"CC_LOG_FILE=%s\"\n", cfg.LogFile)
|
|
fmt.Fprintf(&sb, "Environment=\"CC_LOG_MAX_SIZE=%d\"\n", cfg.LogMaxSize)
|
|
fmt.Fprintf(&sb, "Environment=\"CC_LOG_MAX_BACKUPS=%d\"\n", cfg.LogMaxBackups)
|
|
if cfg.EnvPATH != "" {
|
|
fmt.Fprintf(&sb, "Environment=\"PATH=%s\"\n", cfg.EnvPATH)
|
|
}
|
|
if len(cfg.EnvExtra) > 0 {
|
|
keys := make([]string, 0, len(cfg.EnvExtra))
|
|
for key := range cfg.EnvExtra {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if !isValidEnvName(key) {
|
|
slog.Warn("daemon: systemd: dropping invalid env name from EnvExtra",
|
|
"key", key)
|
|
continue
|
|
}
|
|
value := cfg.EnvExtra[key]
|
|
if value == "" {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&sb, "Environment=\"%s=%s\"\n", key, escapeSystemdEnvValue(value))
|
|
}
|
|
}
|
|
sb.WriteString("\n[Install]\n")
|
|
if m.system {
|
|
sb.WriteString("WantedBy=multi-user.target\n")
|
|
} else {
|
|
sb.WriteString("WantedBy=default.target\n")
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// escapeSystemdEnvValue prepares a value for inclusion inside the double
|
|
// quotes of an `Environment="KEY=VALUE"` directive. Per systemd.exec(5),
|
|
// backslashes and double quotes need escaping; literal newlines and tabs
|
|
// must be encoded as `\n` / `\t` so the unit file remains a single line.
|
|
func escapeSystemdEnvValue(v string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(v))
|
|
for _, r := range v {
|
|
switch r {
|
|
case '\\':
|
|
b.WriteString(`\\`)
|
|
case '"':
|
|
b.WriteString(`\"`)
|
|
case '\n':
|
|
b.WriteString(`\n`)
|
|
case '\r':
|
|
b.WriteString(`\r`)
|
|
case '\t':
|
|
b.WriteString(`\t`)
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// runSystemctl is a var (not a func) so tests can stub it without
|
|
// requiring a real systemctl process on the test host.
|
|
var runSystemctl = func(args ...string) (string, error) {
|
|
cmd := exec.Command("systemctl", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
func checkSystemdRunning(system bool) error {
|
|
var args []string
|
|
if system {
|
|
args = []string{"is-system-running"}
|
|
} else {
|
|
args = []string{"--user", "is-system-running"}
|
|
}
|
|
|
|
out, _ := runSystemctl(args...)
|
|
state := strings.TrimSpace(strings.ToLower(out))
|
|
|
|
// These states all mean systemd is usable for managing services
|
|
switch state {
|
|
case "running", "degraded", "starting", "initializing":
|
|
return nil
|
|
}
|
|
|
|
// "offline" = systemd exists but is not PID 1 (WSL2 without systemd, some containers)
|
|
// "not been booted" / empty = no systemd at all
|
|
wsl := isWSL2()
|
|
|
|
if system {
|
|
if wsl {
|
|
return fmt.Errorf("systemd is not active in this WSL2 instance.\n" +
|
|
" Add the following to /etc/wsl.conf and restart WSL (wsl --shutdown):\n" +
|
|
" [boot]\n" +
|
|
" systemd=true\n" +
|
|
" Or use: nohup cc-connect > cc-connect.log 2>&1 &")
|
|
}
|
|
if state == "offline" || strings.Contains(state, "not been booted") {
|
|
return fmt.Errorf("systemd is not active (state: %s).\n"+
|
|
" If running in a container, systemd is typically not available.\n"+
|
|
" Use nohup, tmux, or screen instead:\n"+
|
|
" nohup cc-connect > cc-connect.log 2>&1 &", state)
|
|
}
|
|
return fmt.Errorf("systemd check failed (state: %s).\n"+
|
|
" Use nohup as alternative: nohup cc-connect > cc-connect.log 2>&1 &", state)
|
|
}
|
|
|
|
// User-level failures
|
|
if wsl {
|
|
return fmt.Errorf("systemd user session not available in WSL2.\n" +
|
|
" Add the following to /etc/wsl.conf and restart WSL (wsl --shutdown):\n" +
|
|
" [boot]\n" +
|
|
" systemd=true\n" +
|
|
" Or use: nohup cc-connect > cc-connect.log 2>&1 &")
|
|
}
|
|
|
|
user := os.Getenv("USER")
|
|
return fmt.Errorf("systemd user session not available.\n"+
|
|
" This often happens when connecting via SSH without a systemd login session.\n"+
|
|
" Try one of:\n"+
|
|
" 1. Run as root: sudo cc-connect daemon install (uses system-level systemd)\n"+
|
|
" 2. loginctl enable-linger %s && export XDG_RUNTIME_DIR=/run/user/$(id -u)\n"+
|
|
" 3. Use nohup/tmux instead: nohup cc-connect > cc-connect.log 2>&1 &", user)
|
|
}
|
|
|
|
func isWSL2() bool {
|
|
data, err := os.ReadFile("/proc/version")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(string(data))
|
|
return strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
|
|
}
|
|
|
|
func parseKeyValue(text string) map[string]string {
|
|
m := make(map[string]string)
|
|
for _, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || !strings.Contains(line, "=") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
m[parts[0]] = parts[1]
|
|
}
|
|
return m
|
|
}
|
|
|
|
// CheckLinger returns true if linger is enabled for the user, false otherwise.
|
|
// If linger is not enabled, user-level systemd services will stop when
|
|
// the user's last login session ends (e.g., SSH disconnect).
|
|
func CheckLinger() (enabled bool, user string) {
|
|
user = os.Getenv("USER")
|
|
if user == "" {
|
|
user = "unknown"
|
|
}
|
|
|
|
// Check if we're in system mode (root)
|
|
if os.Getuid() == 0 {
|
|
return true, user // Linger check not relevant for system mode
|
|
}
|
|
|
|
// Check linger status via loginctl
|
|
out, err := exec.Command("loginctl", "show-user", user, "-p", "Linger").Output()
|
|
if err != nil {
|
|
// loginctl not available or error - assume linger is disabled
|
|
slog.Debug("linger check failed", "error", err)
|
|
return false, user
|
|
}
|
|
|
|
linger := strings.TrimSpace(string(out))
|
|
return linger == "Linger=yes", user
|
|
}
|