mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
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.
225 lines
6.8 KiB
Go
225 lines
6.8 KiB
Go
// Package config loads runtime configuration from a JSON file, with environment
|
|
// variables taking precedence. Currently it carries the PostgreSQL connection.
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Database is the PostgreSQL connection config. Either set DSN directly, or set
|
|
// the component fields and a DSN is assembled from them.
|
|
type Database struct {
|
|
DSN string `json:"dsn"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user"`
|
|
Password string `json:"password"`
|
|
DBName string `json:"dbname"`
|
|
SSLMode string `json:"sslmode"`
|
|
}
|
|
|
|
// Config is the on-disk config file shape.
|
|
type Config struct {
|
|
Database Database `json:"database"`
|
|
SkillDir string `json:"skill_dir"`
|
|
}
|
|
|
|
// BaseDir is the directory that anchors all runtime artifacts (config.json and
|
|
// the data/ store). It is the directory the running binary lives in, so a
|
|
// distributed executable keeps its files next to itself on any OS (Windows,
|
|
// Linux, …) regardless of the working directory it is launched from.
|
|
//
|
|
// When launched via `go run`, the binary is throwaway: it sits either in a temp
|
|
// build dir (cache miss → fresh link) OR straight inside the Go build cache
|
|
// (cache hit → run from $GOCACHE/.../...-d). We detect both and fall back to the
|
|
// current working directory so dev artifacts (data/, transcripts) and config
|
|
// resolve against the project dir, not the throwaway binary's location.
|
|
func BaseDir() string {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
dir := filepath.Dir(exe)
|
|
if isGoRunDir(dir) {
|
|
return "." // throwaway `go run` binary → use CWD
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// KeyDir holds jwt.key and secret.key: ARTEX_JWT_KEY_DIR (the Docker image points
|
|
// it at the /app/keys volume) or the base dir. Never the browsable data dir.
|
|
func KeyDir() string {
|
|
dir := strings.TrimSpace(os.Getenv("ARTEX_JWT_KEY_DIR"))
|
|
if dir == "" {
|
|
dir = BaseDir()
|
|
}
|
|
if abs, err := filepath.Abs(dir); err == nil {
|
|
dir = abs
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// isGoRunDir reports whether dir is where `go run` parked its executable: under
|
|
// the system temp dir (cache miss), or anywhere inside a Go build cache
|
|
// (…/go-build/…, the cache-hit case — NOT under os.TempDir(), which is why the
|
|
// old temp-only check failed intermittently). In both cases the binary is
|
|
// throwaway, so config/data must resolve against the CWD.
|
|
func isGoRunDir(dir string) bool {
|
|
if tmp := os.TempDir(); tmp != "" {
|
|
if rel, err := filepath.Rel(tmp, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return true
|
|
}
|
|
}
|
|
for _, seg := range strings.Split(filepath.ToSlash(dir), "/") {
|
|
if seg == "go-build" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Path returns the config file path. Resolution order:
|
|
// 1. env ARTEX_CONFIG (explicit override)
|
|
// 2. ./config.json in the current working directory (running from the project
|
|
// dir — robust no matter where `go run` placed the temp/cached binary)
|
|
// 3. config.json next to the executable (a distributed binary keeps it beside)
|
|
//
|
|
// The first existing file wins. If none exist, the CWD path is returned so the
|
|
// "not found" message points at the project dir the user most likely expected.
|
|
func Path() string {
|
|
if v := strings.TrimSpace(os.Getenv("ARTEX_CONFIG")); v != "" {
|
|
return v
|
|
}
|
|
var candidates []string
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
candidates = append(candidates, filepath.Join(cwd, "config.json"))
|
|
}
|
|
candidates = append(candidates, filepath.Join(BaseDir(), "config.json"))
|
|
for _, c := range candidates {
|
|
if _, err := os.Stat(c); err == nil {
|
|
return c
|
|
}
|
|
}
|
|
return candidates[0]
|
|
}
|
|
|
|
// Load reads and parses the config file. A missing file yields a zero Config (so
|
|
// callers fall back to defaults). A file that exists but cannot be read or parsed
|
|
// is an error: ignoring it would run with defaults the operator did not choose.
|
|
func Load() (Config, error) {
|
|
var c Config
|
|
path := Path()
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return c, nil
|
|
}
|
|
if err != nil {
|
|
return c, fmt.Errorf("read config file %s: %w", path, err)
|
|
}
|
|
if err := json.Unmarshal(b, &c); err != nil {
|
|
return Config{}, fmt.Errorf("parse config file %s: %w", path, err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// SkillDir returns the skill root directory with precedence:
|
|
//
|
|
// env ARTEX_SKILL_DIR > config file (skill_dir) > BaseDir()/skills
|
|
//
|
|
// The directory is created if it does not exist.
|
|
func SkillDir() (string, error) {
|
|
var d string
|
|
if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" {
|
|
d = v
|
|
} else {
|
|
c, err := Load()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if v := strings.TrimSpace(c.SkillDir); v != "" {
|
|
d = v
|
|
} else {
|
|
d = filepath.Join(BaseDir(), "skills")
|
|
}
|
|
}
|
|
_ = os.MkdirAll(d, 0o755)
|
|
return d, nil
|
|
}
|
|
|
|
// PostgresDSN resolves the connection string with precedence:
|
|
//
|
|
// env ARTEX_PG_DSN > config file (database.dsn, or assembled from fields)
|
|
//
|
|
// There is NO built-in fallback: when neither source supplies a database config,
|
|
// it returns an error naming the config path it inspected, so startup fails loudly
|
|
// instead of silently connecting to a wrong default. source describes where the
|
|
// DSN came from (for startup logging).
|
|
func PostgresDSN() (dsn, source string, err error) {
|
|
if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" {
|
|
return v, "environment variable ARTEX_PG_DSN", nil
|
|
}
|
|
c, err := Load()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
db := c.Database
|
|
if d := strings.TrimSpace(db.DSN); d != "" {
|
|
return d, "config file " + Path() + " (database.dsn)", nil
|
|
}
|
|
if db.Host != "" || db.DBName != "" || db.User != "" {
|
|
return db.buildDSN(), "config file " + Path() + " (database fields)", nil
|
|
}
|
|
return "", "", fmt.Errorf("no database config found: the environment variable ARTEX_PG_DSN is not set, and config file %s provides no database (dsn or host/user/dbname). Create the config file or set the environment variable and retry", Path())
|
|
}
|
|
|
|
func (d Database) buildDSN() string {
|
|
host := d.Host
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
port := d.Port
|
|
if port == 0 {
|
|
port = 5432
|
|
}
|
|
ssl := d.SSLMode
|
|
if ssl == "" {
|
|
ssl = "disable"
|
|
}
|
|
u := url.URL{
|
|
Scheme: "postgres",
|
|
Host: host + ":" + strconv.Itoa(port),
|
|
Path: "/" + d.DBName,
|
|
}
|
|
if d.User != "" {
|
|
if d.Password != "" {
|
|
u.User = url.UserPassword(d.User, d.Password)
|
|
} else {
|
|
u.User = url.User(d.User)
|
|
}
|
|
}
|
|
u.RawQuery = url.Values{"sslmode": {ssl}}.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// String is a redacted view of the resolved DSN (password masked) for logging.
|
|
func Redact(dsn string) string {
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return dsn
|
|
}
|
|
if u.User != nil {
|
|
if _, hasPw := u.User.Password(); hasPw {
|
|
u.User = url.UserPassword(u.User.Username(), "****")
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s", u.String())
|
|
}
|