Initial release: self-hostable APT repository server and CLI
urapt is a self-hostable APT repository server with a companion CLI for pushing and managing Debian .deb packages. Server (urapt-server): - REST API + APT endpoint, SQLite storage (pure-Go modernc driver, no CGO) - .deb files stored content-addressed on disk, reference-counted for dedup - Server-managed RSA-4096 OpenPGP signing key (ProtonMail/go-crypto) - APT indices (Release/InRelease/Packages[.gz/.xz]) generated on demand from the DB, cached in memory, signed with the server key - Full APT model: repositories -> distributions -> components -> architectures - Bearer-token auth for REST; HTTP Basic auth for private-repo APT reads - First registrant becomes admin; repo-scoped permissions (read/write/read-write/admin) plus owner and server-admin roles - Multipart package push with control-field extraction, list/show/delete, pool serving, blob ref-count cleanup - Audit log CLI (urapt): - register/login/logout/whoami, token management - repo/distro/component/arch CRUD, member management - push/pull/ls/show/rm for packages - apt-config helper that emits apt setup commands (key, sources.list, auth.conf for private repos) Packaging & docs: - Dockerfile (multi-stage distroless), docker-compose.yml, sample config - README quick start, architecture overview, config reference, security notes - PLAN.md design blueprint, CHANGELOG.md, GPL-3.0 LICENSE - GitHub Actions CI (test, lint, cross-build for linux/darwin amd64/arm64) - Makefile release target producing static binaries + tarballs + checksums Tests cover the data-access layer, auth/permission checks, APT index generation, .deb parsing, GPG signing, the REST API, and the typed API client. Verified end-to-end on a Raspberry Pi (arm64) pushing and installing a real package.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// Package config defines the urapt-server configuration and its loading from
|
||||
// defaults, a TOML file, environment variables, and command-line flags, with
|
||||
// later sources overriding earlier ones.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// Defaults applied before any other source.
|
||||
var Defaults = Config{
|
||||
Bind: "0.0.0.0:8080",
|
||||
BaseURL: "http://localhost:8080",
|
||||
StoreDir: "./store",
|
||||
LogLevel: "info",
|
||||
SigningKeyType: "rsa",
|
||||
SigningKeyBits: 4096,
|
||||
MaxPackageSize: 1024 * 1024 * 1024,
|
||||
OpenRegistration: true,
|
||||
ConfigPath: "./urapt-server.toml",
|
||||
}
|
||||
|
||||
// Config is the urapt-server runtime configuration.
|
||||
type Config struct {
|
||||
Bind string `toml:"bind"`
|
||||
BaseURL string `toml:"base_url"`
|
||||
StoreDir string `toml:"store_dir"`
|
||||
DBPath string `toml:"db_path"`
|
||||
PackagesDir string `toml:"packages_dir"`
|
||||
LogLevel string `toml:"log_level"`
|
||||
SigningKeyType string `toml:"signing_key_type"`
|
||||
SigningKeyBits int `toml:"signing_key_bits"`
|
||||
SigningKeyUserID string `toml:"signing_key_user_id"`
|
||||
MaxPackageSize int64 `toml:"max_package_size"`
|
||||
OpenRegistration bool `toml:"open_registration"`
|
||||
TLSEnabled bool `toml:"tls_enabled"`
|
||||
TLSCert string `toml:"tls_cert"`
|
||||
TLSKey string `toml:"tls_key"`
|
||||
|
||||
ConfigPath string `toml:"-"`
|
||||
}
|
||||
|
||||
// Load builds the effective Config from Defaults -> file -> env -> flags.
|
||||
// Flags is a map of flag name to string value (already parsed by the caller).
|
||||
func Load(configPath string, flags map[string]string) (Config, error) {
|
||||
c := Defaults
|
||||
c.ConfigPath = configPath
|
||||
|
||||
if err := applyFile(&c, configPath); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
applyEnv(&c)
|
||||
if err := applyFlags(&c, flags); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.finalize()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func applyFile(c *Config, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
if err := toml.Unmarshal(data, c); err != nil {
|
||||
return fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyEnv(c *Config) {
|
||||
set(c, "URAPT_BIND", &c.Bind)
|
||||
set(c, "URAPT_BASE_URL", &c.BaseURL)
|
||||
set(c, "URAPT_STORE_DIR", &c.StoreDir)
|
||||
set(c, "URAPT_DB_PATH", &c.DBPath)
|
||||
set(c, "URAPT_PACKAGES_DIR", &c.PackagesDir)
|
||||
set(c, "URAPT_LOG_LEVEL", &c.LogLevel)
|
||||
set(c, "URAPT_SIGNING_KEY_TYPE", &c.SigningKeyType)
|
||||
set(c, "URAPT_SIGNING_KEY_USER_ID", &c.SigningKeyUserID)
|
||||
set(c, "URAPT_TLS_CERT", &c.TLSCert)
|
||||
set(c, "URAPT_TLS_KEY", &c.TLSKey)
|
||||
setInt(c, "URAPT_SIGNING_KEY_BITS", &c.SigningKeyBits)
|
||||
setInt64(c, "URAPT_MAX_PACKAGE_SIZE", &c.MaxPackageSize)
|
||||
setBool(c, "URAPT_OPEN_REGISTRATION", &c.OpenRegistration)
|
||||
setBool(c, "URAPT_TLS_ENABLED", &c.TLSEnabled)
|
||||
}
|
||||
|
||||
func applyFlags(c *Config, flags map[string]string) error {
|
||||
for k, v := range flags {
|
||||
switch k {
|
||||
case "bind":
|
||||
c.Bind = v
|
||||
case "base-url":
|
||||
c.BaseURL = v
|
||||
case "store-dir":
|
||||
c.StoreDir = v
|
||||
case "db-path":
|
||||
c.DBPath = v
|
||||
case "packages-dir":
|
||||
c.PackagesDir = v
|
||||
case "log-level":
|
||||
c.LogLevel = v
|
||||
case "signing-key-type":
|
||||
c.SigningKeyType = v
|
||||
case "signing-key-bits":
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --signing-key-bits %q: %w", v, err)
|
||||
}
|
||||
c.SigningKeyBits = n
|
||||
case "signing-key-user-id":
|
||||
c.SigningKeyUserID = v
|
||||
case "max-package-size":
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --max-package-size %q: %w", v, err)
|
||||
}
|
||||
c.MaxPackageSize = n
|
||||
case "open-registration":
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --open-registration %q: %w", v, err)
|
||||
}
|
||||
c.OpenRegistration = b
|
||||
case "tls-enabled":
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --tls-enabled %q: %w", v, err)
|
||||
}
|
||||
c.TLSEnabled = b
|
||||
case "tls-cert":
|
||||
c.TLSCert = v
|
||||
case "tls-key":
|
||||
c.TLSKey = v
|
||||
case "config":
|
||||
// already handled
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalize fills derived defaults: DBPath and PackagesDir default under
|
||||
// StoreDir, and SigningKeyUserID gets a hostname-based default.
|
||||
func (c *Config) finalize() {
|
||||
if c.DBPath == "" {
|
||||
c.DBPath = filepath.Join(c.StoreDir, "database", "sqlite.db")
|
||||
}
|
||||
if c.PackagesDir == "" {
|
||||
c.PackagesDir = filepath.Join(c.StoreDir, "packages")
|
||||
}
|
||||
if c.SigningKeyUserID == "" {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
c.SigningKeyUserID = "urapt-server <" + host + ">"
|
||||
}
|
||||
if c.MaxPackageSize <= 0 {
|
||||
c.MaxPackageSize = Defaults.MaxPackageSize
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks the config for obvious errors before startup.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Bind == "" {
|
||||
return fmt.Errorf("bind address is required")
|
||||
}
|
||||
if c.BaseURL == "" {
|
||||
return fmt.Errorf("base_url is required")
|
||||
}
|
||||
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
|
||||
if c.SigningKeyBits <= 0 {
|
||||
return fmt.Errorf("signing_key_bits must be positive")
|
||||
}
|
||||
if c.TLSEnabled && (c.TLSCert == "" || c.TLSKey == "") {
|
||||
return fmt.Errorf("tls_enabled requires tls_cert and tls_key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func set(c *Config, env string, dst *string) {
|
||||
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
|
||||
func setInt(c *Config, env string, dst *int) {
|
||||
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
*dst = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setInt64(c *Config, env string, dst *int64) {
|
||||
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
*dst = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setBool(c *Config, env string, dst *bool) {
|
||||
if v, ok := os.LookupEnv(env); ok && v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
*dst = b
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user