Files
l-ui/internal/config/config.go
T
Muse 739c18ce48
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Waiting to run
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Waiting to run
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Waiting to run
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
release: panel V2.7.0 / agent line A2.6.0
Agent code changed in the rename batch (lui-agent binary, LUI_ env prefix, /etc/l-ui paths and services), so the agent line moves 2.5.0 -> 2.6.0 and its default-expectation tests move with it; panel version is injected at build time (V2.7.0). install-agent.sh RELEASE_BASE and install-master.sh LUI_URL move V2.6.0 -> V2.7.0. Repo name settled as rogue/l-ui (user decision 2026-10-24); the rename-batch placeholder warnings in docs/release.md, selfupdate.go and install-master.sh are closed by the Gitea rename that precedes publishing this release.
2026-10-24 20:53:51 +08:00

457 lines
14 KiB
Go

// Package config provides configuration management utilities for the L-UI panel,
// including version information, logging levels, database paths, and environment variable handling.
package config
import (
_ "embed"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
//go:embed version
var version string
//go:embed name
var name string
// buildCommit and buildDate are injected at build time via `-ldflags -X` for
// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They
// stay empty for a plain `go build` and for stable tagged releases, which is how
// IsDevBuild tells a rolling dev build apart from a stable/local one.
//
// buildVersion is injected the same way by this fork's release builds
// (e.g. "3.7.0-agent.1", matching the Gitea release tag) so the binary reports
// the fork release it was built from — the embedded version file always names
// the upstream base release, which made the update check compare "3.7.0"
// against the fork's "v3.7.0-agent.1" tag and report a phantom update forever.
// It stays empty for a plain `go build`, which falls back to the embedded file.
var (
buildCommit string
buildDate string
buildVersion string
// displayVersion is this fork's own user-facing release number ("1.0.0"
// in "V1.0.0" / "A1.0.0"), injected via `-ldflags -X` by release builds.
// It is deliberately separate from buildVersion (the upstream-shaped
// internal version): display surfaces (panel header, node list, agent
// telemetry) read the V/A display version, while internal comparisons
// and advertisements keep using GetBaseVersion/GetPanelVersion.
displayVersion string
// agentDisplayVersion is the AGENT line's own release number,
// injected the same way. Since the version-line decoupling it is
// deliberately NOT derived from displayVersion: the panel version
// (displayVersion) advances with every panel release, while the
// agent version advances only when agent code actually changes.
// Release builds inject both; a build that injects neither reports
// the panel default and the agent-line default below, which are
// intentionally different numbers.
agentDisplayVersion string
)
// LogLevel represents the logging level for the application.
type LogLevel string
// Logging level constants
const (
Debug LogLevel = "debug"
Info LogLevel = "info"
Notice LogLevel = "notice"
Warning LogLevel = "warning"
Error LogLevel = "error"
)
// GetBaseVersion returns the release version of the L-UI panel
// (e.g. "3.4.0"). This is the panel's own version, not the Xray version. For the
// version a panel advertises/displays (which adds a "dev+<sha>" label on dev
// builds), use GetPanelVersion.
//
// A fork release build injects buildVersion via `-ldflags -X`, which wins over
// the embedded version file; every version consumer (UI display, update check,
// panel-version advertisement to a master) reads through here, so the injected
// value is the single source of truth for the running binary.
func GetBaseVersion() string {
if v := strings.TrimSpace(buildVersion); v != "" {
return v
}
return strings.TrimSpace(version)
}
// GetName returns the name of the L-UI application.
func GetName() string {
return strings.TrimSpace(name)
}
// GetBuildCommit returns the short git commit this binary was built from, or an
// empty string for a plain/local build or a stable tagged release.
func GetBuildCommit() string {
return strings.TrimSpace(buildCommit)
}
// GetBuildDate returns the UTC build timestamp injected at build time, or empty.
func GetBuildDate() string {
return strings.TrimSpace(buildDate)
}
// IsDevBuild reports whether this binary is a CI per-commit (dev channel) build,
// detected by the injected commit. Stable releases and local builds return false.
func IsDevBuild() bool {
return GetBuildCommit() != ""
}
// GetPanelVersion returns the version a panel advertises to a managing master
// node and displays in the UI: the plain version for stable builds, or
// "dev+<short commit>" for dev builds. The dev form mirrors the master's
// getPanelUpdateInfo latestVersion so a node on the current dev commit compares
// as up to date instead of always showing "update available".
func GetPanelVersion() string {
if !IsDevBuild() {
return GetBaseVersion()
}
commit := GetBuildCommit()
if len(commit) > 8 {
commit = commit[:8]
}
return "dev+" + commit
}
// defaultDisplayVersion is the display version reported when a build does
// not inject one (plain `go build` / `go run`).
const defaultDisplayVersion = "2.0.0"
// GetDisplayVersion returns the bare numeric display version ("1.0.0"):
// the injected display version when the build provided one, else the
// default. Display versions advance in single-digit carry steps
// (1.0.9 -> 1.1.0 -> ... -> 1.9.9 -> 2.0.0); each segment stays 0-9.
func GetDisplayVersion() string {
if v := strings.TrimSpace(displayVersion); v != "" {
return v
}
return defaultDisplayVersion
}
// bareDisplayVersion is GetDisplayVersion without a single leading
// V/v. Release builds inject the number either bare ("2.1.0") or in
// the master's V-form ("V2.1.0"); the letter-prefixed getters below
// must compose from the bare form so the letter never doubles
// ("VV2.1.0" / "AV2.1.0" — the latter is what agent rows showed when
// the V-form was injected).
func bareDisplayVersion() string {
v := GetDisplayVersion()
if len(v) > 1 && (v[0] == 'V' || v[0] == 'v') {
return v[1:]
}
return v
}
// GetMasterDisplayVersion is the master's display version ("V1.0.0"),
// shown in the panel's version slot.
func GetMasterDisplayVersion() string {
return "V" + bareDisplayVersion()
}
// defaultAgentDisplayVersion is the agent line's version when a build
// does not inject one. It names the last release in which agent code
// actually changed (A2.6.0 — the V2.7.0 rename batch touched agent code:
// lui-agent binary, LUI_ env prefix, /etc/l-ui paths and services;
// before that the line stood at A2.5.0 while the panel line advanced
// alone). Bump it ONLY together with a real agent-code change, in the
// same commit; panel releases must never move it.
const defaultAgentDisplayVersion = "2.6.0"
// GetAgentDisplayVersion is the agent's display version ("A2.6.0"),
// reported in agent telemetry and shown on agent node rows. It reads
// ONLY the agent line's own injected version (falling back to the
// agent-line default): changing the panel's displayVersion injection
// must never move it — that independence is the decoupling contract.
func GetAgentDisplayVersion() string {
return "A" + bareAgentDisplayVersion()
}
// bareAgentDisplayVersion is the agent line's bare numeric version,
// normalized like bareDisplayVersion (a single leading display letter
// is stripped, so builds may inject "2.3.0" or "A2.3.0").
func bareAgentDisplayVersion() string {
v := strings.TrimSpace(agentDisplayVersion)
if v == "" {
v = defaultAgentDisplayVersion
}
if len(v) > 1 && (v[0] == 'A' || v[0] == 'a' || v[0] == 'V' || v[0] == 'v') {
return v[1:]
}
return v
}
// ParseDisplayVersion parses a display version into its three numeric
// segments. It accepts the bare form ("1.0.0") and the letter-prefixed
// display forms ("V1.0.0", "A1.0.0", "v1.0.0"). Anything else — dev labels,
// suffixed builds like "3.7.0-agent.1", wrong segment counts, non-numeric
// segments — reports ok=false so callers can fall back instead of guessing.
func ParseDisplayVersion(s string) (parts [3]int, ok bool) {
s = strings.TrimSpace(s)
if parsed, good := parseDisplayParts(s); good {
return parsed, true
}
// Retry once without a single leading display letter (V/A, any case).
if len(s) > 1 && (s[0] == 'V' || s[0] == 'v' || s[0] == 'A' || s[0] == 'a') {
if parsed, good := parseDisplayParts(s[1:]); good {
return parsed, true
}
}
return [3]int{}, false
}
func parseDisplayParts(s string) ([3]int, bool) {
var parts [3]int
segs := strings.Split(s, ".")
if len(segs) != 3 {
return parts, false
}
for i, seg := range segs {
if seg == "" {
return parts, false
}
for _, r := range seg {
if r < '0' || r > '9' {
return parts, false
}
}
n, err := strconv.Atoi(seg)
if err != nil {
return parts, false
}
parts[i] = n
}
return parts, true
}
// CompareDisplayVersion compares two display versions segment by segment
// (numeric, so the single-digit carry rule 1.0.9 < 1.1.0 holds naturally).
// ok=false when either side is not a parseable display version.
func CompareDisplayVersion(a, b string) (cmp int, ok bool) {
pa, okA := ParseDisplayVersion(a)
pb, okB := ParseDisplayVersion(b)
if !okA || !okB {
return 0, false
}
for i := range 3 {
if pa[i] != pb[i] {
if pa[i] < pb[i] {
return -1, true
}
return 1, true
}
}
return 0, true
}
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
func GetLogLevel() LogLevel {
if IsDebug() {
return Debug
}
logLevel := os.Getenv("LUI_LOG_LEVEL")
if logLevel == "" {
return Info
}
return LogLevel(logLevel)
}
// IsDebug returns true if debug mode is enabled via the LUI_DEBUG environment variable.
func IsDebug() bool {
return os.Getenv("LUI_DEBUG") == "true"
}
// IsSkipHSTS returns true if skipping HSTS mode is enabled via the LUI_SKIP_HSTS environment variable.
func IsSkipHSTS() bool {
return os.Getenv("LUI_SKIP_HSTS") == "true"
}
func GetPortOverride() (port int, configured bool, err error) {
value, ok := os.LookupEnv("LUI_PORT")
if !ok || strings.TrimSpace(value) == "" {
return 0, false, nil
}
port, err = strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, true, fmt.Errorf("parse LUI_PORT: %w", err)
}
if port < 1 || port > 65535 {
return 0, true, fmt.Errorf("LUI_PORT must be between 1 and 65535")
}
return port, true, nil
}
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via LUI_BIN_FOLDER.
func GetBinFolderPath() string {
binFolderPath := os.Getenv("LUI_BIN_FOLDER")
if binFolderPath == "" {
binFolderPath = "bin"
}
return binFolderPath
}
func getBaseDir() string {
exePath, err := os.Executable()
if err != nil {
return "."
}
exeDir := filepath.Dir(exePath)
exeDirLower := strings.ToLower(filepath.ToSlash(exeDir))
if strings.Contains(exeDirLower, "/appdata/local/temp/") || strings.Contains(exeDirLower, "/go-build") {
wd, err := os.Getwd()
if err != nil {
return "."
}
return wd
}
return exeDir
}
// GetDBFolderPath returns the path to the database folder based on environment variables or platform defaults.
func GetDBFolderPath() string {
dbFolderPath := os.Getenv("LUI_DB_FOLDER")
if dbFolderPath != "" {
return dbFolderPath
}
if runtime.GOOS == "windows" {
return getBaseDir()
}
return "/etc/l-ui"
}
// GetDBPath returns the full path to the database file.
func GetDBPath() string {
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
}
// GetUpdateStatusFilePath returns the path to the panel self-update status
// file update.sh writes on completion. It lives beside the database, outside
// LUI_MAIN_FOLDER, so it survives an update regardless of what happens to
// that folder.
func GetUpdateStatusFilePath() string {
return filepath.Join(GetDBFolderPath(), "update-status.json")
}
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
func GetDBKind() string {
v := strings.ToLower(strings.TrimSpace(os.Getenv("LUI_DB_TYPE")))
switch v {
case "postgres", "postgresql", "pg":
return "postgres"
default:
return "sqlite"
}
}
// GetDBDSN returns the PostgreSQL DSN from LUI_DB_DSN. Empty for sqlite.
func GetDBDSN() string {
return strings.TrimSpace(os.Getenv("LUI_DB_DSN"))
}
// GetNodeTokenEncryptionMode returns off, migration, or required. Explicit
// policy prevents a missing key from silently downgrading encrypted storage.
func GetNodeTokenEncryptionMode() string {
return strings.TrimSpace(os.Getenv("NODE_TOKEN_ENCRYPTION"))
}
// GetNodeTokenKeyFile returns the mode-0600 keyring path, configurable through
// LUI_NODE_TOKEN_KEY_FILE.
func GetNodeTokenKeyFile() string {
if p := strings.TrimSpace(os.Getenv("LUI_NODE_TOKEN_KEY_FILE")); p != "" {
return p
}
return "/etc/l-ui/node_token_key.json"
}
// GetNodeTokenKeyEnv returns the name of the env var holding a single base64
// 32-byte node-token key (secondary to the key file). Empty value => unused.
func GetNodeTokenKeyEnv() string {
return "LUI_NODE_TOKEN_KEY"
}
// GetEnvFilePaths returns the candidate service environment file paths (the file
// systemd loads via EnvironmentFile) across the supported distro families.
func GetEnvFilePaths() []string {
if runtime.GOOS == "windows" {
return nil
}
return []string{
"/etc/default/l-ui",
"/etc/conf.d/l-ui",
"/etc/sysconfig/l-ui",
}
}
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
func GetLogFolder() string {
logFolderPath := os.Getenv("LUI_LOG_FOLDER")
if logFolderPath != "" {
return logFolderPath
}
// Under `go test` the Windows default below is CWD-relative ("./log"), which
// scatters a log/ directory through the source tree (one per tested package).
// Redirect test runs to a shared temp folder so the source tree stays clean.
if testing.Testing() {
return filepath.Join(os.TempDir(), "l-ui-test-log")
}
if runtime.GOOS == "windows" {
return filepath.Join(".", "log")
}
return "/var/log/l-ui"
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
if err != nil {
return err
}
return out.Sync()
}
func init() {
if runtime.GOOS != "windows" {
return
}
if os.Getenv("LUI_DB_FOLDER") != "" {
return
}
// Rename migration (one-time, Windows only): the pre-rename DB
// lived at /etc/x-ui/x-ui.db; the new DB is <exe dir>/l-ui.db.
// This is the only pre-rename path still referenced in code, and
// only as the copy source — normal operation never reads it.
// Operator migration (docs/migration-l-ui.md) moves the DB by mv;
// this branch is a safety net for a Windows panel that never got it.
oldDBPath := "/etc/x-ui/x-ui.db"
newDBFolder := GetDBFolderPath()
newDBPath := fmt.Sprintf("%s/%s.db", newDBFolder, GetName())
_, err := os.Stat(newDBPath)
if err == nil {
return // new exists
}
_, err = os.Stat(oldDBPath)
if os.IsNotExist(err) {
return // old does not exist
}
_ = copyFile(oldDBPath, newDBPath) // ignore error
}