Files
urapt/server/app/app.go
T
owen 981587e83d 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.
2026-06-28 16:57:34 -05:00

212 lines
5.5 KiB
Go

// Package app wires the urapt-server dependencies and runs the HTTP server.
package app
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/go-chi/chi/v5"
"urapt/server/aptrepo"
"urapt/server/auth"
"urapt/server/cache"
"urapt/server/restapi"
"urapt/server/store"
"urapt/shared/config"
"urapt/shared/db"
"urapt/shared/gpg"
"urapt/shared/log"
)
// App holds the server configuration and runtime dependencies.
type App struct {
args []string
version string
}
// New constructs an App from the given command-line args and version string.
func New(args []string, version string) *App {
return &App{args: args, version: version}
}
// signerProvider implements restapi.SignerProvider backed by a *gpg.Key.
type signerProvider struct {
key *gpg.Key
}
func (s *signerProvider) PublicKeyArmored() (string, error) {
if s.key == nil {
return "", fmt.Errorf("no key")
}
return s.key.ArmoredPublic()
}
func (s *signerProvider) Fingerprint() string {
if s.key == nil {
return ""
}
return s.key.Fingerprint
}
// Key returns the signing key for the APT endpoint (Phase 6).
func (s *signerProvider) Key() *gpg.Key { return s.key }
// Run loads config, opens the database, ensures a signing key, and serves
// HTTP until the context is cancelled.
func (a *App) Run(ctx context.Context) error {
cfg, err := a.loadConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "config error:", err)
return err
}
logger := log.New(cfg.LogLevel)
logger.Info("starting urapt-server", "version", a.version, "bind", cfg.Bind, "base_url", cfg.BaseURL)
if err := cfg.Validate(); err != nil {
logger.Error("invalid config", "err", err)
return err
}
if err := ensureDirs(cfg); err != nil {
logger.Error("setup store dirs", "err", err)
return err
}
database, err := db.Open(cfg.DBPath)
if err != nil {
logger.Error("open database", "err", err)
return err
}
defer database.Close()
st := store.New(database)
authSvc := auth.NewService(st)
signer, err := ensureSigningKey(ctx, st, cfg)
if err != nil {
logger.Error("ensure signing key", "err", err)
return err
}
sp := &signerProvider{key: signer}
logger.Info("signing key ready", "fingerprint", sp.Fingerprint())
idxCache := cache.New()
root := chi.NewRouter()
root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache))
root.Mount("/apt", aptrepo.New(st, authSvc, sp.Key(), idxCache))
srv := &http.Server{
Addr: cfg.Bind,
Handler: root,
}
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", cfg.Bind)
if cfg.TLSEnabled {
errCh <- srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey)
} else {
errCh <- srv.ListenAndServe()
}
}()
select {
case <-ctx.Done():
logger.Info("shutting down")
shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return srv.Shutdown(shutCtx)
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server error", "err", err)
return err
}
}
return nil
}
// loadConfig parses flags and builds the effective Config.
func (a *App) loadConfig() (config.Config, error) {
fs := flag.NewFlagSet("urapt-server", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
configPath := fs.String("config", config.Defaults.ConfigPath, "path to config file")
fs.String("bind", "", "bind address")
fs.String("base-url", "", "external base URL")
fs.String("store-dir", "", "store directory")
fs.String("db-path", "", "sqlite db path")
fs.String("packages-dir", "", "packages directory")
fs.String("log-level", "", "log level")
fs.String("signing-key-type", "", "signing key type")
fs.Int("signing-key-bits", 0, "signing key bits")
fs.String("signing-key-user-id", "", "signing key user id")
fs.Int64("max-package-size", 0, "max package size in bytes")
fs.Bool("open-registration", true, "allow new account registration")
fs.Bool("tls-enabled", false, "enable TLS")
fs.String("tls-cert", "", "TLS cert path")
fs.String("tls-key", "", "TLS key path")
if err := fs.Parse(a.args); err != nil {
return config.Config{}, err
}
flags := map[string]string{}
fs.Visit(func(f *flag.Flag) {
flags[f.Name] = f.Value.String()
})
return config.Load(*configPath, flags)
}
// ensureDirs creates the store, database, and packages directories.
func ensureDirs(cfg config.Config) error {
for _, dir := range []string{cfg.StoreDir, filepath.Dir(cfg.DBPath), cfg.PackagesDir} {
if dir == "" {
continue
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
}
return nil
}
// ensureSigningKey loads the default key from the DB or generates one.
func ensureSigningKey(ctx context.Context, st *store.Store, cfg config.Config) (*gpg.Key, error) {
existing, err := st.GetDefaultGPGKey(ctx)
if err == nil {
key, err := gpg.ParseArmoredPrivate(existing.PrivateKeyArmored)
if err != nil {
return nil, fmt.Errorf("parse stored key: %w", err)
}
return key, nil
}
if !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("load key: %w", err)
}
key, err := gpg.GenerateKey(cfg.SigningKeyUserID, cfg.SigningKeyBits)
if err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
pub, err := key.ArmoredPublic()
if err != nil {
return nil, err
}
priv, err := key.ArmoredPrivate()
if err != nil {
return nil, err
}
if _, err := st.SaveGPGKey(ctx, key.Fingerprint, key.UserID, pub, priv, true); err != nil {
return nil, fmt.Errorf("save key: %w", err)
}
return key, nil
}
// shutdownTimeout for graceful HTTP shutdown.
const shutdownTimeout = 30 * time.Second