Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/securefile"
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
Expand All @@ -47,6 +48,50 @@ func init() {
buildinfo.BuildDate = BuildDate
}

type authPersister interface {
PersistAuthFiles(ctx context.Context, message string, paths ...string) error
}

func applyAuthEncryptionConfig(cfg *config.Config, authDir string, persister authPersister, migrate bool) {
if cfg == nil {
securefile.ConfigureAuthEncryption(securefile.AuthEncryptionSettings{})
return
}
settings := securefile.AuthEncryptionSettings{
Enabled: cfg.AuthEncryption.Enabled,
AllowPlaintextFallback: cfg.AuthEncryption.AllowPlaintextFallback,
}
secret := securefile.ResolveAuthEncryptionSecret(settings.Secret)
settings.Secret = secret
securefile.ConfigureAuthEncryption(settings)
if secret == "" {
if settings.Enabled {
log.Warn("auth-encryption enabled but no key configured; set CLIPROXY_AUTH_ENCRYPTION_KEY or CLI_PROXY_API_AUTH_ENCRYPTION_KEY")
} else if migrate {
log.Warn("auth-encryption disabled but no key configured; encrypted auth files cannot be decrypted without CLIPROXY_AUTH_ENCRYPTION_KEY or CLI_PROXY_API_AUTH_ENCRYPTION_KEY")
}
}
if !migrate || secret == "" {
return
}
changed, err := securefile.MigrateAuthJSONDir(authDir, settings)
if err != nil {
log.WithError(err).Warn("auth encryption migration encountered errors")
}
if len(changed) == 0 {
return
}
log.Infof("auth encryption migration updated %d auth file(s)", len(changed))
if persister == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := persister.PersistAuthFiles(ctx, "Migrate auth encryption", changed...); err != nil {
log.WithError(err).Warn("failed to persist auth encryption migration")
}
}

// main is the entry point of the application.
// It parses command-line flags, loads configuration, and starts the appropriate
// service based on the provided flags (login, codex-login, or server mode).
Expand Down Expand Up @@ -421,6 +466,13 @@ func main() {
} else {
cfg.AuthDir = resolvedAuthDir
}
if repoRoot, ok := util.FindGitRepoRoot(cfg.AuthDir); ok {
if useGitStore {
log.Warnf("auth-dir %q is inside a git repository (%q); git-backed token storage is enabled, ensure the repo/remote is private and tokens are not exposed", cfg.AuthDir, repoRoot)
} else {
log.Warnf("auth-dir %q is inside a git repository (%q); do not commit token files, add it to .gitignore or set auth-dir outside the repo", cfg.AuthDir, repoRoot)
}
}
managementasset.SetCurrentConfig(cfg)

// Create login options to be used in authentication flows.
Expand All @@ -439,6 +491,14 @@ func main() {
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
}

var persister authPersister
if store := sdkAuth.GetTokenStore(); store != nil {
if p, ok := store.(authPersister); ok {
persister = p
}
}
applyAuthEncryptionConfig(cfg, cfg.AuthDir, persister, true)

// Register built-in access providers before constructing services.
configaccess.Register()

Expand Down
8 changes: 8 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ remote-management:
# Authentication directory (supports ~ for home directory)
auth-dir: "~/.cli-proxy-api"

# Auth file encryption-at-rest
auth-encryption:
enabled: false
allow-plaintext-fallback: true
# Encryption key is read from env:
# - CLIPROXY_AUTH_ENCRYPTION_KEY
# - CLI_PROXY_API_AUTH_ENCRYPTION_KEY

# API keys for authentication
api-keys:
- "your-api-key-1"
Expand Down
28 changes: 22 additions & 6 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/securefile"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
Expand Down Expand Up @@ -345,7 +346,7 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {

// Read file to get type field
full := filepath.Join(h.cfg.AuthDir, name)
if data, errRead := os.ReadFile(full); errRead == nil {
if data, errRead := readAuthJSON(full); errRead == nil {
typeValue := gjson.GetBytes(data, "type").String()
emailValue := gjson.GetBytes(data, "email").String()
fileData["type"] = typeValue
Expand Down Expand Up @@ -457,6 +458,17 @@ func authAttribute(auth *coreauth.Auth, key string) string {
return auth.Attributes[key]
}

func readAuthJSON(path string) ([]byte, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("auth file path is empty")
}
data, _, err := securefile.ReadAuthJSONFile(path)
if err != nil {
return nil, err
}
return data, nil
}

func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
if auth == nil || len(auth.Attributes) == 0 {
return false
Expand All @@ -476,7 +488,7 @@ func (h *Handler) DownloadAuthFile(c *gin.Context) {
return
}
full := filepath.Join(h.cfg.AuthDir, name)
data, err := os.ReadFile(full)
data, err := readAuthJSON(full)
if err != nil {
if os.IsNotExist(err) {
c.JSON(404, gin.H{"error": "file not found"})
Expand Down Expand Up @@ -512,11 +524,15 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
return
}
data, errRead := os.ReadFile(dst)
data, errRead := readAuthJSON(dst)
if errRead != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
return
}
if errWrite := securefile.WriteAuthJSONFile(dst, data); errWrite != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to secure auth file: %v", errWrite)})
return
}
if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
c.JSON(500, gin.H{"error": errReg.Error()})
return
Expand Down Expand Up @@ -544,8 +560,8 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
dst = abs
}
}
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
if errWrite := securefile.WriteAuthJSONFile(dst, data); errWrite != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to secure auth file: %v", errWrite)})
return
}
if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
Expand Down Expand Up @@ -649,7 +665,7 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []
}
if data == nil {
var err error
data, err = os.ReadFile(path)
data, err = readAuthJSON(path)
if err != nil {
return fmt.Errorf("failed to read auth file: %w", err)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ type Config struct {
// AuthDir is the directory where authentication token files are stored.
AuthDir string `yaml:"auth-dir" json:"-"`

// AuthEncryption controls encryption-at-rest for auth JSON files.
AuthEncryption AuthEncryptionConfig `yaml:"auth-encryption" json:"auth-encryption"`

// Debug enables or disables debug-level logging and other debug features.
Debug bool `yaml:"debug" json:"debug"`

Expand Down Expand Up @@ -117,6 +120,14 @@ type RemoteManagement struct {
PanelGitHubRepository string `yaml:"panel-github-repository"`
}

// AuthEncryptionConfig controls auth file encryption settings.
type AuthEncryptionConfig struct {
// Enabled toggles encryption-at-rest for auth JSON files.
Enabled bool `yaml:"enabled" json:"enabled"`
// AllowPlaintextFallback enables best-effort re-encryption of plaintext auth files when enabled.
AllowPlaintextFallback bool `yaml:"allow-plaintext-fallback" json:"allow-plaintext-fallback"`
}

// QuotaExceeded defines the behavior when API quota limits are exceeded.
// It provides configuration options for automatic failover mechanisms.
type QuotaExceeded struct {
Expand Down
8 changes: 4 additions & 4 deletions internal/logging/request_logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st
responseToWrite = response
}

logFile, errOpen := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
logFile, errOpen := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if errOpen != nil {
return fmt.Errorf("failed to create log file: %w", errOpen)
}
Expand Down Expand Up @@ -344,7 +344,7 @@ func (l *FileRequestLogger) generateErrorFilename(url string, requestID ...strin
// - error: An error if directory creation fails, nil otherwise
func (l *FileRequestLogger) ensureLogsDir() error {
if _, err := os.Stat(l.logsDir); os.IsNotExist(err) {
return os.MkdirAll(l.logsDir, 0755)
return os.MkdirAll(l.logsDir, 0o700)
}
return nil
}
Expand Down Expand Up @@ -917,7 +917,7 @@ func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[st
content.WriteString("\n")

content.WriteString("=== REQUEST BODY ===\n")
content.Write(body)
content.Write(util.MaskSensitiveJSON(body))
content.WriteString("\n\n")

return content.String()
Expand Down Expand Up @@ -1082,7 +1082,7 @@ func (w *FileStreamingLogWriter) Close() error {
return nil
}

logFile, errOpen := os.OpenFile(w.logFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
logFile, errOpen := os.OpenFile(w.logFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if errOpen != nil {
w.cleanupTempFiles()
return fmt.Errorf("failed to create log file: %w", errOpen)
Expand Down
94 changes: 94 additions & 0 deletions internal/securefile/atomic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package securefile

import (
"fmt"
"os"
"path/filepath"
"time"
)

// EnsurePrivateDir creates dirPath (and parents) with 0700 permissions.
func EnsurePrivateDir(dirPath string) error {
if dirPath == "" {
return fmt.Errorf("securefile: dir path is empty")
}
if err := os.MkdirAll(dirPath, 0o700); err != nil {
return err
}
// Best-effort permission hardening. Ignore errors (e.g., non-POSIX FS).
_ = os.Chmod(dirPath, 0o700)
return nil
}

// AtomicWriteFile writes data to path using a temp file + rename, and attempts to fsync.
// mode controls the final file permissions.
func AtomicWriteFile(path string, data []byte, mode os.FileMode) error {
if path == "" {
return fmt.Errorf("securefile: path is empty")
}
dir := filepath.Dir(path)
if err := EnsurePrivateDir(dir); err != nil {
return err
}

tmp, err := os.CreateTemp(dir, ".tmp.*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()

if mode == 0 {
mode = 0o600
}
if err := tmp.Chmod(mode); err != nil {
// Best-effort: ignore chmod failure on some filesystems.
}

if _, err := tmp.Write(data); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}

if err := os.Rename(tmpName, path); err != nil {
return err
}

// Best-effort: ensure final mode.
_ = os.Chmod(path, mode)
return nil
}

// ReadFileRawLocked reads the file at path while holding an advisory lock on path+".lock".
func ReadFileRawLocked(path string) ([]byte, error) {
lockPath := path + ".lock"
var out []byte
err := WithLock(lockPath, 10*time.Second, func() error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
out = data
return nil
})
if err != nil {
return nil, err
}
return out, nil
}

// WriteFileRawLocked writes data to path using an advisory lock on path+".lock" and atomic replace.
func WriteFileRawLocked(path string, data []byte, mode os.FileMode) error {
lockPath := path + ".lock"
return WithLock(lockPath, 10*time.Second, func() error {
return AtomicWriteFile(path, data, mode)
})
}
Loading