Files
ARTEX/cmd/artex/main.go
BaaRai 07cc87c1a3 Encrypt stored secrets at rest in PostgreSQL
Seal the four credential columns with AES-256-GCM (random nonce per value,
AAD "<table>.<column>"): llm_profiles.api_key and task_credentials.password
store "enc:v1:<base64(nonce||ciphertext)>"; mcp_servers.env and
notification_channels.config store {"__artex_enc":"enc:v1:..."} over the
canonical JSON. The db layer encrypts on write and decrypts on read, so agent
tools (LLM provider, MCP, notifier, test credentials) get plaintext
server-side. Decryption failure is an error, never a plaintext fallback.

Key: ARTEX_SECRET_KEY (32 bytes, base64 or hex) or keys/secret.key next to
jwt.key, generated with crypto/rand at first start (0600). Startup refuses
when encrypted values exist and no key is available, and when the key does
not decrypt them. An idempotent backfill under the schema advisory lock
encrypts legacy plaintext rows and logs only a count.

API: MCP env is masked on read and masked placeholders on write keep the
stored value; task credential passwords are masked server-side and a masked
password on write is refused. ARTEX_SECRET_KEY is scrubbed from the process
environment with the other startup secrets.
2026-11-13 14:42:56 +01:00

150 lines
4.6 KiB
Go

// Command artex runs the ARTEX backend: the dual SQLite graph stores,
// the event-driven exploration engine, and the JSON HTTP API consumed by the
// shadcn/ui frontend.
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"syscall"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/config"
"github.com/Autumn-27/artex/server"
)
// version is the build version, injected at release time via
// -ldflags "-X main.version=<tag>". Defaults to "dev" for local builds.
var version = "dev"
const banner = `
_ ____ _____ _______ __
/ \ | _ \_ _| ____\ \/ /
/ _ \ | |_) || | | _| \ /
/ ___ \| _ < | | | |___ / \
/_/ \_\_| \_\|_| |_____/_/\_\
`
// printBanner writes the startup banner + version/runtime info to stdout.
func printBanner(addr string) {
fmt.Print(banner)
fmt.Println(" AI autonomous penetration testing system")
fmt.Printf(" version %s · %s/%s · %s · listening on %s\n\n",
version, runtime.GOOS, runtime.GOARCH, runtime.Version(), addr)
}
// main maps run's result onto the process exit code. The body lives in a
// function that can *return* rather than os.Exit, so its own defers still run.
func main() {
os.Exit(run())
}
func run() int {
var (
addr = flag.String("addr", "127.0.0.1:8787", "HTTP listen address")
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
proxy = flag.String("proxy", "127.0.0.1:8788", "traffic recording proxy address (empty to disable)")
)
flag.Parse()
// hand the build version to the server package so GET /api/health can report it
// to the frontend top bar.
server.BuildVersion = version
printBanner(*addr)
// capture backend logs into the in-memory sink (still to stderr) so the /logs
// page can show a live log stream. Do this first, to catch startup logs too.
server.StartLogCapture()
// surface which config file the binary reads (absolute, so `go run`'s relative
// "config.json" — resolved against the CWD — is unambiguous).
cfgPath := config.Path()
if abs, e := filepath.Abs(cfgPath); e == nil {
cfgPath = abs
}
if _, e := os.Stat(cfgPath); e == nil {
log.Printf("[config] config file: %s", cfgPath)
} else {
log.Printf("[config] config file: %s (not found — will only try the environment variable ARTEX_PG_DSN)", cfgPath)
}
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, shutdown := shutdownContext(sigCtx)
defer shutdown(agent.AbortShutdown)
mgr, err := server.NewManager(*dataDir, *proxy)
if err != nil {
log.Fatalf("open stores: %v", err)
}
defer mgr.Close()
skillDir, err := config.SkillDir()
if err != nil {
log.Printf("[config] %v", err)
return 1
}
if abs, err := filepath.Abs(skillDir); err == nil {
skillDir = abs
}
log.Printf("[config] skill directory: %s", skillDir)
// JWT signing key directory (also holds secret.key, see server.NewManager).
keyDir := config.KeyDir()
log.Printf("[config] JWT key directory: %s", keyDir)
// Proxies whose X-Forwarded-For the login rate limit reads; empty trusts none.
trusted, err := server.ParseTrustedProxies(os.Getenv("ARTEX_TRUSTED_PROXIES"))
if err != nil {
log.Printf("[config] ARTEX_TRUSTED_PROXIES: %v", err)
return 1
}
if len(trusted) > 0 {
log.Printf("[config] trusted proxies: %v", trusted)
}
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub(), server.WithTrustedProxies(trusted))
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("ARTEX %s backend listening on %s (data=%s, workers=%d)", version, *addr, *dataDir, mgr.Workers())
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("serve: %v", err)
}
}()
// Wait for a stop signal.
<-ctx.Done()
log.Println("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
return 0
}
// shutdownContext deliberately does not derive from signalCtx. If it did, the
// parent's plain context.Canceled could win the race before AbortShutdown was
// attached to the child, losing the diagnostic cause in every running Agent.
func shutdownContext(signalCtx context.Context) (context.Context, context.CancelCauseFunc) {
ctx, shutdown := context.WithCancelCause(context.Background())
go func() {
select {
case <-signalCtx.Done():
shutdown(agent.AbortShutdown)
case <-ctx.Done():
}
}()
return ctx, shutdown
}