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
238 changes: 238 additions & 0 deletions cmd/server/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package main

import (
"context"
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v8/internal/store"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)

// backendEnv carries what a backend needs to read its settings and place its spool.
type backendEnv struct {
// lookup returns the first non-empty value among the given environment keys.
lookup func(keys ...string) (string, bool)
// workDir is the process working directory.
workDir string
// writableBase is the preferred base for local spools, empty when none is known.
writableBase string
}

// spoolBase returns the configured local path, falling back to the writable base and then the working directory.
func (e backendEnv) spoolBase(configured string) string {
if configured != "" {
return configured
}
if e.writableBase != "" {
return e.writableBase
}
return e.workDir
}

// openedBackend is a started remote-backed store together with the paths the server runs from.
type openedBackend struct {
tokenStore coreauth.Store
configPath string
// authDir overrides the configured auth directory once the config is loaded.
authDir string
// ready logs that the backend is serving, called only after the config loaded.
ready func()
}

// openBackendFunc starts a remote-backed store.
type openBackendFunc func() (*openedBackend, error)

// storeBackend describes one remote token store. detect returns nil when the
// environment does not select the backend, otherwise a function that starts it.
type storeBackend struct {
name string
detect func(env backendEnv) openBackendFunc
}

// storeBackends lists the backends in selection priority; the first one whose
// settings are present wins. Adding a backend means adding one entry.
var storeBackends = []storeBackend{
{name: "postgres", detect: detectPostgresBackend},
{name: "object", detect: detectObjectBackend},
{name: "git", detect: detectGitBackend},
}

// selectBackend returns the opener of the first backend selected by the environment, or nil.
func selectBackend(env backendEnv) openBackendFunc {
for _, backend := range storeBackends {
if open := backend.detect(env); open != nil {
return open
}
}
return nil
}

func detectPostgresBackend(env backendEnv) openBackendFunc {
dsn, ok := env.lookup("PGSTORE_DSN", "pgstore_dsn")
if !ok {
return nil
}
schema, _ := env.lookup("PGSTORE_SCHEMA", "pgstore_schema")
localPath, _ := env.lookup("PGSTORE_LOCAL_PATH", "pgstore_local_path")
spool := filepath.Join(env.spoolBase(localPath), "pgstore")
return func() (*openedBackend, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
inst, err := store.NewPostgresStore(ctx, store.PostgresStoreConfig{
DSN: dsn,
Schema: schema,
SpoolDir: spool,
})
cancel()
if err != nil {
return nil, fmt.Errorf("failed to initialize postgres token store: %w", err)
}
examplePath := filepath.Join(env.workDir, "config.example.yaml")
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if errBootstrap := inst.Bootstrap(ctx, examplePath); errBootstrap != nil {
return nil, fmt.Errorf("failed to bootstrap postgres-backed config: %w", errBootstrap)
}
return &openedBackend{
tokenStore: inst,
configPath: inst.ConfigPath(),
authDir: inst.AuthDir(),
ready: func() {
log.Infof("postgres-backed token store enabled, workspace path: %s", inst.WorkDir())
},
}, nil
}
}

func detectObjectBackend(env backendEnv) openBackendFunc {
endpoint, ok := env.lookup("OBJECTSTORE_ENDPOINT", "objectstore_endpoint")
if !ok {
return nil
}
access, _ := env.lookup("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key")
secret, _ := env.lookup("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key")
bucket, _ := env.lookup("OBJECTSTORE_BUCKET", "objectstore_bucket")
localPath, _ := env.lookup("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path")
return func() (*openedBackend, error) {
root := filepath.Join(env.spoolBase(localPath), "objectstore")
resolvedEndpoint := strings.TrimSpace(endpoint)
useSSL := true
if strings.Contains(resolvedEndpoint, "://") {
parsed, errParse := url.Parse(resolvedEndpoint)
if errParse != nil {
return nil, fmt.Errorf("failed to parse object store endpoint %q: %w", endpoint, errParse)
}
switch strings.ToLower(parsed.Scheme) {
case "http":
useSSL = false
case "https":
useSSL = true
default:
return nil, fmt.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
}
if parsed.Host == "" {
return nil, fmt.Errorf("object store endpoint %q is missing host information", endpoint)
}
resolvedEndpoint = parsed.Host
if parsed.Path != "" && parsed.Path != "/" {
resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
}
}
resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
inst, err := store.NewObjectTokenStore(store.ObjectStoreConfig{
Endpoint: resolvedEndpoint,
Bucket: bucket,
AccessKey: access,
SecretKey: secret,
LocalRoot: root,
UseSSL: useSSL,
PathStyle: true,
})
if err != nil {
return nil, fmt.Errorf("failed to initialize object token store: %w", err)
}
examplePath := filepath.Join(env.workDir, "config.example.yaml")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if errBootstrap := inst.Bootstrap(ctx, examplePath); errBootstrap != nil {
return nil, fmt.Errorf("failed to bootstrap object-backed config: %w", errBootstrap)
}
return &openedBackend{
tokenStore: inst,
configPath: inst.ConfigPath(),
authDir: inst.AuthDir(),
ready: func() {
log.Infof("object-backed token store enabled, bucket: %s", bucket)
},
}, nil
}
}

func detectGitBackend(env backendEnv) openBackendFunc {
remoteURL, ok := env.lookup("GITSTORE_GIT_URL", "gitstore_git_url")
if !ok {
return nil
}
user, _ := env.lookup("GITSTORE_GIT_USERNAME", "gitstore_git_username")
password, _ := env.lookup("GITSTORE_GIT_TOKEN", "gitstore_git_token")
localPath, _ := env.lookup("GITSTORE_LOCAL_PATH", "gitstore_local_path")
branch, _ := env.lookup("GITSTORE_GIT_BRANCH", "gitstore_git_branch")
return func() (*openedBackend, error) {
root := filepath.Join(env.spoolBase(localPath), "gitstore")
inst := store.NewGitTokenStore(remoteURL, user, password, branch)
inst.SetBaseDir(filepath.Join(root, "auths"))
if errRepo := inst.EnsureRepository(); errRepo != nil {
return nil, fmt.Errorf("failed to prepare git token store: %w", errRepo)
}
configPath := inst.ConfigPath()
if configPath == "" {
configPath = filepath.Join(root, "config", "config.yaml")
}
if _, statErr := os.Stat(configPath); errors.Is(statErr, fs.ErrNotExist) {
examplePath := filepath.Join(env.workDir, "config.example.yaml")
if _, errExample := os.Stat(examplePath); errExample != nil {
return nil, fmt.Errorf("failed to find template config file: %w", errExample)
}
if errCopy := misc.CopyConfigTemplate(examplePath, configPath); errCopy != nil {
return nil, fmt.Errorf("failed to bootstrap git-backed config: %w", errCopy)
}
if errCommit := persistSeededConfig(context.Background(), configPath, inst.PersistConfig); errCommit != nil {
return nil, fmt.Errorf("failed to commit initial git-backed config: %w", errCommit)
}
log.Infof("git-backed config initialized from template: %s", configPath)
} else if statErr != nil {
return nil, fmt.Errorf("failed to inspect git-backed config: %w", statErr)
}
return &openedBackend{
tokenStore: inst,
configPath: configPath,
authDir: inst.AuthDir(),
ready: func() {
log.Infof("git-backed token store enabled, repository path: %s", root)
},
}, nil
}
}

// loadBackendConfig loads the config a backend placed on disk and points it at the backend's auth directory.
func loadBackendConfig(opened *openedBackend, isCloudDeploy bool) (*config.Config, error) {
cfg, err := config.LoadConfigOptional(opened.configPath, isCloudDeploy)
if err != nil {
return cfg, err
}
if cfg == nil {
cfg = &config.Config{}
}
cfg.AuthDir = opened.authDir
opened.ready()
return cfg, nil
}
107 changes: 107 additions & 0 deletions cmd/server/backend_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package main

import (
"strings"
"testing"
)

func envLookup(values map[string]string) func(keys ...string) (string, bool) {
return func(keys ...string) (string, bool) {
for _, key := range keys {
if value := strings.TrimSpace(values[key]); value != "" {
return value, true
}
}
return "", false
}
}

func TestSelectBackendReturnsNilWithoutSettings(t *testing.T) {
if open := selectBackend(backendEnv{lookup: envLookup(nil), workDir: t.TempDir()}); open != nil {
t.Fatal("selected a backend without any settings")
}
}

func TestSelectBackendPriority(t *testing.T) {
all := map[string]string{
"PGSTORE_DSN": "postgres://example.invalid/db",
"OBJECTSTORE_ENDPOINT": "http://127.0.0.1:1",
"GITSTORE_GIT_URL": "https://example.invalid/repo.git",
"gitstore_git_username": "ignored",
}
tests := []struct {
name string
drop []string
want string
}{
{"postgres first", nil, "postgres"},
{"object next", []string{"PGSTORE_DSN"}, "object"},
{"git last", []string{"PGSTORE_DSN", "OBJECTSTORE_ENDPOINT"}, "git"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := map[string]string{}
for k, v := range all {
env[k] = v
}
for _, k := range tt.drop {
delete(env, k)
}
be := backendEnv{lookup: envLookup(env), workDir: t.TempDir()}
var got string
for _, backend := range storeBackends {
if backend.detect(be) != nil {
got = backend.name
break
}
}
if got != tt.want {
t.Fatalf("first selected backend = %q, want %q", got, tt.want)
}
if selectBackend(be) == nil {
t.Fatal("selectBackend returned nil")
}
})
}
}

func TestSelectBackendAcceptsLowercaseKeys(t *testing.T) {
be := backendEnv{lookup: envLookup(map[string]string{"pgstore_dsn": "postgres://example.invalid/db"}), workDir: t.TempDir()}
if selectBackend(be) == nil {
t.Fatal("lowercase key not honored")
}
}

func TestObjectBackendRejectsBadEndpoints(t *testing.T) {
tests := map[string]string{
"ftp://host/bucket": "unsupported object store scheme",
"http://": "missing host",
}
for endpoint, want := range tests {
be := backendEnv{
lookup: envLookup(map[string]string{"OBJECTSTORE_ENDPOINT": endpoint}),
workDir: t.TempDir(),
}
open := selectBackend(be)
if open == nil {
t.Fatalf("%q: backend not selected", endpoint)
}
if _, err := open(); err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("%q: err = %v, want %q", endpoint, err, want)
}
}
}

func TestSpoolBaseFallbacks(t *testing.T) {
be := backendEnv{workDir: "/work", writableBase: "/writable"}
if got := be.spoolBase("/configured"); got != "/configured" {
t.Fatalf("configured = %q", got)
}
if got := be.spoolBase(""); got != "/writable" {
t.Fatalf("writable = %q", got)
}
be.writableBase = ""
if got := be.spoolBase(""); got != "/work" {
t.Fatalf("work dir = %q", got)
}
}
Loading
Loading