Files
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

256 lines
8.5 KiB
Go

package restapi
import (
"errors"
"net/http"
"urapt/server/store"
apitypes "urapt/shared/api"
"urapt/shared/httputil"
"urapt/shared/models"
)
// validateName checks a distribution/component/architecture name.
func validateName(s string) bool { return nameRE.MatchString(s) }
// loadDistro fetches the {repo}/{dist} distribution, rendering errors.
func (api *API) loadDistro(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.Distribution) {
repo := api.requireRead(w, r)
if repo == nil {
return nil, nil
}
dist, err := api.Store.GetDistributionByName(r.Context(), repo.ID, r.PathValue("dist"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found")
return repo, nil
}
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read distribution")
return repo, nil
}
return repo, dist
}
// requireDistroWrite loads repo (write) + distribution.
func (api *API) requireDistroWrite(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.Distribution) {
repo := api.requireWrite(w, r)
if repo == nil {
return nil, nil
}
dist, err := api.Store.GetDistributionByName(r.Context(), repo.ID, r.PathValue("dist"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found")
return repo, nil
}
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read distribution")
return repo, nil
}
return repo, dist
}
// --- distributions ---
// ListDistributions returns the distributions in a repository.
func (api *API) ListDistributions(w http.ResponseWriter, r *http.Request) {
repo := api.requireRead(w, r)
if repo == nil {
return
}
dists, err := api.Store.ListDistributions(r.Context(), repo.ID)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list distributions")
return
}
if dists == nil {
dists = []*models.Distribution{}
}
httputil.WriteJSON(w, http.StatusOK, dists)
}
// CreateDistribution adds a distribution to a repository.
func (api *API) CreateDistribution(w http.ResponseWriter, r *http.Request) {
repo := api.requireWrite(w, r)
if repo == nil {
return
}
var req apitypes.CreateNamedRequest
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
bad(w, "invalid JSON body")
return
}
if !validateName(req.Name) {
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
return
}
if _, err := api.Store.GetDistributionByName(r.Context(), repo.ID, req.Name); err == nil {
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "distribution already exists")
return
} else if !errors.Is(err, store.ErrNotFound) {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check distribution")
return
}
dist, err := api.Store.CreateDistribution(r.Context(), repo.ID, req.Name)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create distribution")
return
}
api.Cache.Invalidate(repo.ID, dist.Name)
httputil.WriteJSON(w, http.StatusCreated, dist)
}
// DeleteDistribution removes a distribution (cascades to packages).
func (api *API) DeleteDistribution(w http.ResponseWriter, r *http.Request) {
repo, dist := api.requireDistroWrite(w, r)
if dist == nil {
return
}
if err := api.Store.DeleteDistribution(r.Context(), repo.ID, dist.Name); err != nil {
if errors.Is(err, store.ErrNotFound) {
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found")
return
}
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete distribution")
return
}
// Note: cascaded package rows are gone; their blob ref counts are now
// stale. Best-effort cleanup of orphan blobs is handled by package delete
// in normal operation; bulk distribution deletion leaves blobs for now.
api.Cache.Invalidate(repo.ID, dist.Name)
w.WriteHeader(http.StatusNoContent)
}
// --- components ---
// ListComponents returns the components in a distribution.
func (api *API) ListComponents(w http.ResponseWriter, r *http.Request) {
_, dist := api.loadDistro(w, r)
if dist == nil {
return
}
comps, err := api.Store.ListComponents(r.Context(), dist.ID)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list components")
return
}
if comps == nil {
comps = []*models.Component{}
}
httputil.WriteJSON(w, http.StatusOK, comps)
}
// CreateComponent adds a component to a distribution.
func (api *API) CreateComponent(w http.ResponseWriter, r *http.Request) {
repo, dist := api.requireDistroWrite(w, r)
if dist == nil {
return
}
var req apitypes.CreateNamedRequest
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
bad(w, "invalid JSON body")
return
}
if !validateName(req.Name) {
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
return
}
if _, err := api.Store.GetComponentByName(r.Context(), dist.ID, req.Name); err == nil {
httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "component already exists")
return
} else if !errors.Is(err, store.ErrNotFound) {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check component")
return
}
comp, err := api.Store.CreateComponent(r.Context(), dist.ID, req.Name)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create component")
return
}
api.Cache.Invalidate(repo.ID, dist.Name)
httputil.WriteJSON(w, http.StatusCreated, comp)
}
// DeleteComponent removes a component (blocked if packages reference it).
func (api *API) DeleteComponent(w http.ResponseWriter, r *http.Request) {
repo, dist := api.requireDistroWrite(w, r)
if dist == nil {
return
}
comp, err := api.Store.GetComponentByName(r.Context(), dist.ID, r.PathValue("comp"))
if err != nil {
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "component not found")
return
}
if err := api.Store.DeleteComponent(r.Context(), dist.ID, comp.Name); err != nil {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete component (packages may still reference it)")
return
}
api.Cache.Invalidate(repo.ID, dist.Name)
w.WriteHeader(http.StatusNoContent)
}
// --- architectures ---
// ListArchitectures returns the architectures in a distribution.
func (api *API) ListArchitectures(w http.ResponseWriter, r *http.Request) {
_, dist := api.loadDistro(w, r)
if dist == nil {
return
}
arches, err := api.Store.ListArchitectures(r.Context(), dist.ID)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list architectures")
return
}
if arches == nil {
arches = []*models.Architecture{}
}
httputil.WriteJSON(w, http.StatusOK, arches)
}
// CreateArchitecture adds an architecture to a distribution.
func (api *API) CreateArchitecture(w http.ResponseWriter, r *http.Request) {
repo, dist := api.requireDistroWrite(w, r)
if dist == nil {
return
}
var req apitypes.CreateNamedRequest
if err := httputil.ReadJSON(r, &req, 1<<20); err != nil {
bad(w, "invalid JSON body")
return
}
if !validateName(req.Name) {
bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]")
return
}
if req.Name == "all" {
bad(w, "architecture 'all' is implicit and cannot be added")
return
}
arch, err := api.Store.CreateArchitecture(r.Context(), dist.ID, req.Name)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create architecture (already exists?)")
return
}
api.Cache.Invalidate(repo.ID, dist.Name)
httputil.WriteJSON(w, http.StatusCreated, arch)
}
// DeleteArchitecture removes an architecture from a distribution.
func (api *API) DeleteArchitecture(w http.ResponseWriter, r *http.Request) {
repo, dist := api.requireDistroWrite(w, r)
if dist == nil {
return
}
if err := api.Store.DeleteArchitecture(r.Context(), dist.ID, r.PathValue("arch")); err != nil {
if errors.Is(err, store.ErrNotFound) {
httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "architecture not found")
return
}
httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete architecture")
return
}
api.Cache.Invalidate(repo.ID, dist.Name)
w.WriteHeader(http.StatusNoContent)
}