Files
CyberStrikeAI/cmd/server/main.go
T
Sycun cb8bc6b824 feat: update this installation's own source in one action
An operator running a fork has no honest way to upgrade: upgrade.sh downloads the
Release tarball of one hard-coded third-party repository and rsyncs it over the
tree, so "upgrade" means "overwrite yourself with somebody else's code", and the
safe alternative is to pull by hand and remember the build command.

internal/update drives the remote the directory already tracks: fetch, fast-forward,
go build into .update-staging, atomic rename of the binary (the previous one kept as
cyberstrike-ai.prev), and a .update-state.json so a rollback has a target that is
not guessed at. Refusals are the point - local source edits and a diverged branch
stop with the file names and commit counts in the error rather than being reset to
match, and operator-owned content (roles/skills/tools/agents/data/config.yaml) is put
aside before the merge and restored over it, with what was kept listed in keptContent
instead of quietly dropped.

Consumers: GET/POST /api/system/update* behind update:read / update:apply (mutations
are process-global, so they need a global-scope session); a console page at
#system-update with an apply button that names why it is blocked, a job polled from
the server and a rollback control; the same three actions as -check-update / -update
/ -update-rollback on the binary; OpenAPI entries for all five endpoints. Permission
gating no longer re-enables a control a page disabled for a reason of its own
(data-state-disabled), which every state-disabled, permission-gated button in the
console was subject to. upgrade.sh becomes a thin shell over the same implementation
and only falls back to the tarball path for installs that are not git work trees.

Docs and the zh/EN/RU dictionaries are updated. The package runs 13 tests against
real temporary git repositories; the HTTP layer, RBAC and the console page each
carry their own contract tests.
2026-10-07 12:07:26 +08:00

256 lines
7.6 KiB
Go

package main
import (
"context"
"cyberstrike-ai/internal/app"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/processguard"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/termout"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"go.uber.org/zap"
"golang.org/x/term"
)
func main() {
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file")
var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit")
checkIsolation := flag.Bool("check-process-isolation", false, "Probe task containment and cleanup, then exit without starting services")
checkUpdate := flag.Bool("check-update", false, "Report whether this installation's own repository has anything newer, then exit")
applyUpdate := flag.Bool("update", false, "Fast-forward this installation to its own remote, rebuild the binary, and exit")
updateRollback := flag.Bool("update-rollback", false, "Undo the last --update: back to the commit and binary kept before it")
flag.Parse()
if handled, code := updateCommandIfNeeded(strings.TrimSpace(*configPath), *checkUpdate, *applyUpdate, *updateRollback); handled {
os.Exit(code)
}
// 环境变量兼容(便于 systemd/docker 等不传参场景)
if *httpsBootstrap && *httpBootstrap {
fmt.Fprintln(os.Stderr, "--http and --https cannot be used together")
os.Exit(2)
}
if !*httpsBootstrap && !*httpBootstrap {
v := strings.TrimSpace(os.Getenv("CYBERSTRIKE_HTTPS"))
if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") {
*httpsBootstrap = true
}
}
// 加载配置
cp := strings.TrimSpace(*configPath)
if cp == "" {
cp = "config.yaml"
}
if strings.HasPrefix(cp, "-") {
fmt.Fprintf(os.Stderr, "Invalid -config path %q.\nIf HTTPS is also needed, use: ./cyberstrike-ai --https -config config.yaml (-config must be followed by a yaml file path).\n", cp)
os.Exit(2)
}
localConfig, err := config.EnsureLocalConfig(cp)
if err != nil {
fmt.Printf("Failed to load config: %v\n", err)
return
}
cfg, err := config.Load(cp)
if err != nil {
fmt.Printf("Failed to load config: %v\n", err)
return
}
if localConfig.Created {
termout.PrintConfigCreated()
}
if *checkIsolation {
if err := configureProcessIsolation(cfg); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
checkCtx, cancelCheck := context.WithTimeout(context.Background(), 15*time.Second)
backend, checkErr := processguard.Check(checkCtx)
cancelCheck()
if checkErr != nil {
fmt.Fprintln(os.Stderr, checkErr)
os.Exit(1)
}
fmt.Printf("{\"checked\":true,\"backend\":%q}\n", backend)
return
}
if *resetAdminPassword {
if err := runResetAdminPassword(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Failed to reset admin password: %v\n", err)
os.Exit(1)
}
return
}
if *httpBootstrap {
config.ApplyPlainHTTPBootstrap(cfg)
} else if *httpsBootstrap {
config.ApplyDevHTTPSBootstrap(cfg)
}
port := cfg.Server.Port
if port <= 0 {
port = 8080
}
scheme := "http"
if config.MainWebUIUsesHTTPS(&cfg.Server) {
scheme = "https"
}
termout.PrintStartupWebUI(termout.StartupWebUIOptions{
Scheme: scheme,
Host: cfg.Server.Host,
Port: port,
SelfSigned: scheme == "https" && cfg.Server.TLSAutoSelfSign,
HTTPRedirect: scheme == "https" && config.ServerHTTPRedirectEnabled(&cfg.Server),
})
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
fmt.Printf("Failed to configure MCP authentication: %v\n", err)
return
}
if cfg.MCP.Enabled {
config.PrintMCPConfigJSON(cfg.MCP)
}
// 初始化日志
log := logger.New(cfg.Log.Level, cfg.Log.Output, logger.DiagnosticOptions{
Dir: cfg.Log.DiagnosticDir,
Disabled: cfg.Log.DiagnosticDisabled,
RetentionDays: cfg.Log.DiagnosticRetentionDays,
})
defer log.Sync()
if err := configureProcessIsolation(cfg); err != nil {
log.Fatal("进程隔离初始化失败", "error", err)
}
probeCtx, probeCancel := context.WithTimeout(context.Background(), 15*time.Second)
backend, probeErr := processguard.Check(probeCtx)
probeCancel()
if probeErr != nil {
log.Fatal("进程隔离启动检查失败", "error", probeErr)
}
log.Info("任务进程隔离已就绪", zap.String("backend", backend))
// 创建可取消的根 context,用于优雅关闭
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 监听系统信号
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// 创建应用
application, err := app.New(cfg, log, cp)
if err != nil {
log.Fatal("应用初始化失败", "error", err)
}
// 在后台监听信号
go func() {
sig := <-sigCh
log.Info("收到系统信号,开始优雅关闭: " + sig.String())
application.Shutdown()
cancel()
}()
// 启动服务器(传入 context 以支持优雅关闭)
if err := application.RunWithContext(ctx); err != nil {
// context 取消导致的关闭不视为错误
if ctx.Err() != nil {
log.Info("服务器已优雅关闭")
} else {
log.Fatal("服务器启动失败", "error", err)
}
}
}
func runResetAdminPassword(cfg *config.Config) error {
dbPath := strings.TrimSpace(cfg.Database.Path)
if dbPath == "" {
dbPath = "data/conversations.db"
}
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("database does not exist: %s; start the service once to initialize it first", dbPath)
}
return err
}
fmt.Println("Reset built-in admin password")
fmt.Println()
password, err := readHiddenPassword("New admin password: ")
if err != nil {
return err
}
password = strings.TrimSpace(password)
if len(password) < 8 {
return fmt.Errorf("new password must be at least 8 characters")
}
confirm, err := readHiddenPassword("Confirm new password: ")
if err != nil {
return err
}
if password != strings.TrimSpace(confirm) {
return fmt.Errorf("passwords do not match")
}
hash, err := security.HashPassword(password)
if err != nil {
return err
}
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
return err
}
defer func() { _ = db.Close() }()
admin, err := db.GetRBACUserByUsername("admin")
if err != nil {
return fmt.Errorf("built-in admin account was not found; start the service once to initialize it first: %w", err)
}
if !admin.IsBuiltin {
return fmt.Errorf("admin account is not built in; refusing to reset it")
}
if err := db.UpdateRBACAdminPassword(hash); err != nil {
return err
}
fmt.Println()
fmt.Println("Admin password has been reset.")
fmt.Println("If the service is running, existing login sessions remain valid until the service restarts or the sessions expire.")
return nil
}
func readHiddenPassword(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
return string(password), nil
}
func configureProcessIsolation(cfg *config.Config) error {
isolation := cfg.Security.ProcessIsolation
return processguard.Configure(processguard.Options{Mode: isolation.Mode, CgroupRoot: isolation.CgroupRoot, MaxProcesses: isolation.MaxProcesses, MemoryMaxBytes: isolation.MemoryMaxBytes, CPUQuotaMicros: isolation.CPUQuotaMicros})
}