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.
41 KiB
urapt — Implementation Plan
A self-hostable APT repository server with a companion CLI for pushing and
managing Debian/Ubuntu .deb packages under your logged-in user.
Status: Planning. This document is the authoritative blueprint for implementation. Decisions captured here are final unless explicitly marked "open" or "future".
1. Goals & scope
In scope (v1)
- A self-hostable server that:
- Exposes a REST API for the CLI to manage repositories, users, distributions, components, architectures, and packages.
- Exposes a special APT endpoint that the standard
aptclient speaks to (servesdists/.../{Release,InRelease,Release.gpg},Packagesindices, and thepool/.debfiles). - Stores only uploaded
.debfiles on the filesystem (store/packages/). Everything else lives in a SQLite database (store/database/sqlite.db). - Generates APT indices on demand from the database (indices are never persisted to disk).
- Signs
Release/InReleasewith a server-managed GPG key.
- A CLI (
urapt) that:- Logs you in (username/password → API token stored locally).
- Pushes
.debpackages to a repository/distribution/component under your identity. - Pulls, lists, and deletes packages.
- Manages repositories, members, distributions, components, architectures.
- Emits the
sources.listline + pubkey +auth.conffor client setup.
- A shared utilities component (
shared/) used by both server and CLI: config, DB, models, GPG,.debparsing, APT index generation, crypto, and the typed REST API client + DTOs.
Out of scope (v1, listed as future)
- Source package (
deb-src) hosting. - AppStream /
dep11metadata. Acquire-By-Hashindices.- Web UI.
- External OAuth/OIDC auth.
- Per-repository GPG keys (v1 uses one server-wide key).
- OS keychain credential storage (v1 stores token in a 0600 config file).
- Multi-arch
Contentsindexes.
2. Confirmed decisions
| Decision | Choice |
|---|---|
| Language / stack | Go — single static binary for both server and CLI |
| GPG signing | Server-managed key: server generates & stores its own key, signs Release/InRelease automatically; admin exports the pubkey for clients |
| Auth (CLI ↔ API) | API tokens: user logs in with username/password once, gets a token stored locally; CLI sends token in Authorization: Bearer |
| Bootstrap admin | First user to register becomes admin |
| Repo model | Full model: repositories contain suites/distributions (stable, testing…), each suite has components (main, contrib…) and architectures (amd64, arm64, all) |
| Access policy | Public repo: APT read is unauthenticated, REST write requires auth. Private repo: APT read and REST write require auth (APT read via HTTP Basic with token) |
| Permissions | Repository-scoped. Creator = owner with full read/write. Owner can grant read / write / read-write / admin to other users. Only users with access can push. Server admins can manage everything |
3. Architecture overview
Three components, one Go module (urapt):
+-------------------+ +-------------------+
| urapt (CLI) | | urapt-server |
| cmd/urapt | | cmd/urapt-server |
+---------+---------+ +---------+---------+
| |
| uses shared/ | uses shared/
v v
+---------------------------------------------+
| shared/ |
| config | db | models | gpg | deb | apt | |
| crypto | api(types) | apiclient(HTTP) | |
+---------------------------------------------+
| |
+------> SQLite <-----+ |
| |
store/database/sqlite.db
store/packages/*.deb (files only)
- The CLI never touches the DB or filesystem directly; it only talks to the
server's REST API via
shared/apiclient. - The server owns the DB and the
store/directory. - The shared component contains pure libraries and the API contract. No
component imports upward (no
shared→serverorshared→cli).
4. Project structure
urapt/
go.mod module path: urapt
go.sum
README.md
PLAN.md
.gitignore ignores store/, *.db, built binaries
Dockerfile multi-stage build for urapt-server
docker-compose.yml example self-hosted deployment
Makefile build/test/lint targets
cmd/
urapt-server/main.go server entrypoint: load config, wire app, run
urapt/ main.go CLI entrypoint: execute cobra root
shared/ ---- shared utilities component ----
config/ config structs + file/env/flag loading
db/ sqlite open (WAL), migrations runner, query helpers
models/ domain types (User, Repository, Package, …)
api/ REST DTOs + request/response shapes (the contract)
apiclient/ typed HTTP client used by the CLI
crypto/ password hashing (bcrypt), token gen + hashing
gpg/ key generation, clearsign, detached sign, export
deb/ .deb (ar) unpack, control.tar parse, control fields
apt/ index generation: Packages, Release, InRelease, Release.gpg
httputil/ JSON helpers, error rendering, bearer/basic parsing
log/ structured logging wrapper
version/ build version info
server/ ---- server component ----
app/ wiring: dependencies, store paths, startup, key init
restapi/ REST handlers + routes (chi)
aptrepo/ APT endpoint handlers + index cache
middleware/ auth (bearer), basic-auth (for private APT), logging, recover
auth/ token resolution, permission checks, session/identity
cli/ ---- CLI component ----
commands/ cobra commands (login, push, repo, …)
config/ local config + token store (~/.config/urapt)
output/ table/JSON formatting helpers
interact/ prompts (password, confirm)
migrations/ numbered *.sql files, embedded via go:embed
0001_init.sql
store/ runtime data (gitignored, created at runtime)
database/sqlite.db
packages/<sha256>.deb
Import rules
shared/**imports only stdlib + 3rd-party libs (neverserver/orcli/).server/**importsshared/**.cli/**importsshared/**.cmd/**are thinmainpackages that wire the relevant component.
5. Technology choices (libraries)
| Concern | Choice | Why |
|---|---|---|
| SQLite driver | modernc.org/sqlite |
Pure-Go, no CGO → easy static binaries & cross-compile |
| HTTP router | go-chi/chi/v5 |
Lightweight, middleware-friendly, stdlib-compatible |
| Migrations | embed + tiny runner in shared/db |
No extra tooling; runs on startup |
| Password hashing | golang.org/x/crypto/bcrypt |
Simple, well-understood |
| Token generation | crypto/rand 32B → base64url; store SHA-256 |
Standard, revocable |
| GPG / OpenPGP | github.com/ProtonMail/go-crypto/openpgp |
Maintained OpenPGP in pure Go; keygen, clearsign, detached sig |
.deb ar archive |
pault.ag/go/debian/deb (+ blakesmith/ar fallback) |
Debian-aware deb reader |
| Control parsing | pault.ag/go/debian/control |
RFC822 control field parsing |
| Compression (xz) | github.com/ulikunitz/xz |
For control.tar.xz / data.tar.xz |
| Compression (zstd) | github.com/klauspost/compress/zstd |
For control.tar.zst (newer debs) |
| CLI framework | github.com/spf13/cobra |
Widely known, subcommands, flags |
| Config | github.com/BurntSushi/toml + env + flags |
TOML file + env overrides + flag overrides |
| Logging | log/slog (stdlib) |
Structured logging, no dep |
| Validation | github.com/go-playground/validator/v10 |
DTO validation |
| Testing | testing + github.com/stretchr/testify |
Unit + integration |
Go version: 1.22+ (for log/slog, enhanced ServeMux if needed).
6. Data model (SQLite schema)
All IDs are text UUIDv4. Timestamps are ISO-8601 UTC text. Booleans are INTEGER
0/1. SQLite in WAL mode, busy_timeout=5000, foreign_keys=ON.
users
| col | type | notes |
|---|---|---|
| id | TEXT PK | uuid |
| username | TEXT UNIQUE NOT NULL | case-insensitive; store lowercased + original |
| password_hash | TEXT NOT NULL | bcrypt |
| is_admin | INTEGER NOT NULL DEFAULT 0 | 1 for admins |
| created_at | TEXT NOT NULL | |
| updated_at | TEXT NOT NULL |
api_tokens
| col | type | notes |
|---|---|---|
| id | TEXT PK | uuid |
| user_id | TEXT FK→users.id | |
| name | TEXT NOT NULL | user label e.g. "laptop" |
| prefix | TEXT NOT NULL | first 8 chars of token (for identification) |
| token_hash | TEXT UNIQUE NOT NULL | SHA-256 of full token |
| created_at | TEXT NOT NULL | |
| last_used_at | TEXT | nullable |
| revoked_at | TEXT | nullable; if set, invalid |
repositories
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| name | TEXT UNIQUE NOT NULL | URL-safe [a-z0-9-]+, lowercase |
| owner_user_id | TEXT FK→users.id | implicit full access |
| visibility | TEXT NOT NULL | public | private |
| description | TEXT | nullable |
| created_at | TEXT NOT NULL | |
| updated_at | TEXT NOT NULL |
v1 uses a single server-wide signing key, so no
signing_key_idcolumn. Agpg_keystable still holds that one key (see below).
repository_members
| col | type | notes |
|---|---|---|
| repository_id | TEXT FK→repositories.id | |
| user_id | TEXT FK→users.id | |
| access | TEXT NOT NULL | read | write | read-write | admin |
| created_at | TEXT NOT NULL | |
| PK | (repository_id, user_id) |
read: can read/download (private repos) and list.write: can push packages (no read).read-write: both.admin: both + manage members. Owner is implicitlyadmin.
distributions (suites)
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| repository_id | TEXT FK→repositories.id | |
| name | TEXT NOT NULL | e.g. stable, testing, jammy |
| created_at | TEXT NOT NULL | |
| UNIQUE | (repository_id, name) |
components
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| distribution_id | TEXT FK→distributions.id | |
| name | TEXT NOT NULL | e.g. main, contrib |
| created_at | TEXT NOT NULL | |
| UNIQUE | (distribution_id, name) |
architectures
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| distribution_id | TEXT FK→distributions.id | |
| name | TEXT NOT NULL | e.g. amd64, arm64 (not all — all is implicit) |
| created_at | TEXT NOT NULL | |
| UNIQUE | (distribution_id, name) |
allis not stored as an architecture row. Architecture-independent packages (arch=all) are listed in every binary-<arch>Packages index. The ReleaseArchitecturesfield lists the configured architectures.
packages (one row per uploaded .deb version)
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| repository_id | TEXT FK | |
| distribution_id | TEXT FK | |
| component_id | TEXT FK | |
| name | TEXT NOT NULL | from control Package |
| version | TEXT NOT NULL | from control Version |
| architecture | TEXT NOT NULL | from control Architecture (incl. all) |
| source | TEXT | from control Source (source pkg name) |
| maintainer | TEXT | |
| priority | TEXT | |
| section | TEXT | |
| origin | TEXT | |
| homepage | TEXT | |
| description | TEXT | full (extended) |
| description_md5 | TEXT | md5 of short description |
| depends | TEXT | |
| pre_depends | TEXT | |
| recommends | TEXT | |
| suggests | TEXT | |
| conflicts | TEXT | |
| breaks | TEXT | |
| provides | TEXT | |
| replaces | TEXT | |
| enhances | TEXT | |
| installed_size | INTEGER | kB |
| essential | TEXT | nullable |
| built_using | TEXT | nullable |
| tag | TEXT | nullable |
| raw_control | TEXT NOT NULL | full control stanza (re-emitted in index) |
| filename | TEXT NOT NULL | real file: store/packages/<sha256>.deb |
| pool_path | TEXT NOT NULL | virtual: pool/<component>/<letter>/<src>/<file>.deb |
| size | INTEGER NOT NULL | .deb file size in bytes |
| md5sum | TEXT NOT NULL | of .deb |
| sha1 | TEXT NOT NULL | of .deb |
| sha256 | TEXT NOT NULL | of .deb |
| uploaded_by_user_id | TEXT FK→users.id | |
| created_at | TEXT NOT NULL | |
| UNIQUE | (repository_id, distribution_id, component_id, name, version, architecture) |
blobs (content-addressed .deb files; reference counting + dedup)
| col | type | notes |
|---|---|---|
| sha256 | TEXT PK | |
| filename | TEXT NOT NULL | store/packages/<sha256>.deb |
| size | INTEGER NOT NULL | |
| ref_count | INTEGER NOT NULL DEFAULT 0 | |
| created_at | TEXT NOT NULL |
On push: compute sha256 → find-or-create blob (ref_count++) → insert package. On package delete: ref_count--; when 0, delete the file and the blob row.
gpg_keys (server-managed signing keys)
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| fingerprint | TEXT UNIQUE NOT NULL | |
| user_id | TEXT NOT NULL | OpenPGP user-id string, e.g. urapt-server <host> |
| public_key_armored | TEXT NOT NULL | exported ASCII pubkey (served to clients) |
| private_key_armored | TEXT NOT NULL | armored private key (see security note) |
| is_default | INTEGER NOT NULL DEFAULT 0 | the one default server key |
| created_at | TEXT NOT NULL |
Security note: the private key is stored in the SQLite DB. v1 stores it armored without passphrase (acceptable for a self-hosted single-binary where the operator controls the DB file). Future: encrypt at rest with a passphrase from config/env (AES-GCM), and/or support per-repo keys. Document the tradeoff in README.
audit_log (lightweight, best-effort)
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| user_id | TEXT | nullable (system events) |
| repository_id | TEXT | nullable |
| action | TEXT NOT NULL | e.g. package.push, repo.create, member.add |
| target | TEXT | human-readable subject |
| details | TEXT | JSON blob |
| created_at | TEXT NOT NULL |
schema_migrations
| col | type | notes |
|---|---|---|
| version | INTEGER PK | migration number |
| applied_at | TEXT NOT NULL |
7. APT repository layout & serving
The APT endpoint base path is /apt/<repository>/. apt clients use:
deb https://<host>/apt/<repo>/ <suite> <component>[ <component>...]
On-disk vs virtual
- Virtual paths (served, never on disk): everything under
dists/and thepool/tree. Generated from the DB on demand. - Real files on disk: only
store/packages/<sha256>.deb.
Routes served by the APT endpoint
| Route | Behavior |
|---|---|
GET /apt/:repo/dists/:suite/InRelease |
clearsigned Release (preferred by apt) |
GET /apt/:repo/dists/:suite/Release |
unsigned Release |
GET /apt/:repo/dists/:suite/Release.gpg |
detached signature of Release |
GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages |
package index (text) |
GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages.gz |
gzip |
GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages.xz |
xz (optional) |
GET /apt/:repo/pool/:component/:letter/:src/:filename |
the .deb file (streamed, Range support) |
:archexcludesall;all-arch packages are merged into each real arch's index.- Pool path resolution:
pool/<component>/<letter>/<src>/<filename>→ DB lookup bypool_pathwithin:repo→ servestore/packages/<sha256>.debviahttp.ServeContent(supports Range, ETag, Last-Modified). :letter/:srcfollow Debian convention: if source name starts withlib, prefix =lib+ first char afterlib(e.g.libaforlibapache2…); else prefix = first char of source/package name. This is cosmetic — only internal consistency matters.
Auth on the APT endpoint
-
Public repo: no auth for any GET.
-
Private repo: every GET requires HTTP Basic auth with
username = <username>,password = <api token>. Server resolves the token, checks the user has at leastread/write/read-write/adminaccess (or is owner/admin). On missing/invalid creds, respond401withWWW-Authenticate: Basic realm="urapt <repo>"so apt'sauth.conftriggers.Client
auth.confexample:machine <host> login <username> password <api token>
8. Index generation (shared/apt)
All indices are built in memory from the DB and may be cached (see §11). Never written to disk.
Packages index (per component + arch)
For (repo, suite, component, arch):
- Query all
packagesrows matchingdistribution_id,component_id, and(architecture = arch OR architecture = 'all'). - For each row, emit a stanza starting with the control fields (from
raw_control, filtered/normalized) plus the file fields:Package: <name> Version: <version> Architecture: <arch as stored> Filename: <pool_path> # e.g. pool/main/f/foo/foo_1.0_amd64.deb Size: <size> MD5sum: <md5sum> SHA1: <sha1> SHA256: <sha256> ...other control fields... Description: <description> - Entries separated by a blank line; file ends with a blank line.
- Serve as
Packages; also serve gzip (Packages.gz) and xz (Packages.xz).
Release file (per suite)
- Determine components (list of component names for the suite) and architectures (configured arch names).
- For each
(component, arch)generate thePackages,Packages.gz,Packages.xzbytes (reuse from the per-arch generation). - Compute checksums + sizes of each, keyed by their path relative to the
suite, e.g.
main/binary-amd64/Packages. - Emit:
Origin: urapt <repo> Label: urapt <repo> Suite: <suite> Codename: <suite> Date: <RFC2822 now> Architectures: amd64 arm64 Components: main contrib Description: <repo description> MD5Sum: <md5> <size> main/binary-amd64/Packages ... SHA1: <sha1> <size> main/binary-amd64/Packages ... SHA256: <sha256> <size> main/binary-amd64/Packages ...- No
Valid-Until(avoid expiry on quiet self-hosted repos). Dateis regenerated on cache invalidation.
- No
InRelease
InRelease= clearsignedRelease(inline OpenPGP signature) using the server's default GPG key. Preferred by modern apt.
Release.gpg
- Detached signature of
Releaseusing the same key. For older apt flows.
Caching
- A per-
(repo, suite)cache holds:{Packages map, Release bytes, InRelease bytes, Release.gpg bytes, generation int}. - Generation is bumped on any mutation affecting that
(repo, suite): package push/delete, component/arch add/remove, distribution rename/delete, repo visibility change. - Cache is in-memory only; rebuilt lazily on first request after a bump or after server restart. Mutex per key.
9. GPG signing (shared/gpg)
- Uses
github.com/ProtonMail/go-crypto/openpgp. - Key generation (on first server startup if no default key exists):
- Key type: RSA-4096 (broad apt/gpg compatibility; configurable later).
- User-id: configurable, default
urapt-server <hostname>. - No passphrase (v1; stored armored in DB).
- Persist armored public + private key to
gpg_keyswithis_default=1.
- Operations:
Clearsign(data) → InReleaseDetachedSign(data) → Release.gpgExportPublic() → ascii-armored pubkey(served at/api/v1/server/pubkeyand/apt/:repo/...is signed by it).
- The pubkey is also exposed via the CLI (
urapt repo pubkey) and theapt-confighelper prints instructions to install it (gpg --dearmor | tee /usr/share/keyrings/urapt.gpg+ signed-by line).
10. .deb parsing (shared/deb)
On push, the server must extract control metadata without executing anything from the package.
Steps
- Open the
.debas anararchive (pault.ag/go/debian/debor raw ar). - Read the
debian-binarymember (validate version2.0). - Locate the
control.tar.*member; decompress (gzip / xz / zstd by magic bytes). - From the tar, read the
controlfile (and optionalshlibs,symbols,triggers— ignored for v1). - Parse the control stanza (
pault.ag/go/debian/control): RFC822-style fields. - Extract all fields needed for the
packagesrow (see §6) and keep the fullraw_controlfor re-emission. - Compute file hashes (MD5, SHA1, SHA256) and size of the whole
.deb.
Validation on push
- Must be a valid
ararchive withdebian-binary+control.tar.*. Package,Version,Architecturemust be present and non-empty.Architecturemust be one of the distribution's configured arches orall.- Version must be a valid Debian version string (basic sanity).
- No duplicate of
(repo, distro, component, name, version, arch)— else 409. - Reject if
.debsize > configured max (default 1 GiB).
Filename / pool path
- Real filename:
store/packages/<sha256>.deb. - Pool path:
pool/<component>/<letter>/<src>/<original-filename>where<original-filename>is the client-supplied.debbasename (sanitized) and<src>is theSourcepackage name (orPackagename if absent), and<letter>per the Debian convention above.
11. Storage layout
store/
database/
sqlite.db SQLite (WAL: sqlite.db-wal, sqlite.db-shm)
packages/
<sha256>.deb content-addressed; one file per unique .deb content
- Only
store/packages/*.debare filesystem user data. - The server creates
store/,store/database/,store/packages/on startup if missing. - Paths are configurable (
store_dir,db_path,packages_dir). - Backups: snapshot
sqlite.db(with WAL checkpoint) +packages/dir.
Caching & invalidation (server)
- Index cache: in-memory, per
(repo, suite), generation-tagged (§8). - Mutation hooks: every package/component/arch/distro mutation calls
cache.Invalidate(repo, suite)which bumps generation; next read rebuilds. - No on-disk cache → restart simply rebuilds on first request.
12. Authentication & authorization (server/auth)
Identity resolution
- REST API:
Authorization: Bearer <token>→ SHA-256 → look upapi_tokens(non-revoked) → load user. Updatelast_used_at(throttled). Missing/invalid →401. - APT endpoint (private repos):
Authorization: Basic ...→ decode →passwordis the token → same resolution.
Permission helpers
CanRead(user, repo): repo public → true; else owner / member withread/write/read-write/admin/ server admin.CanWrite(user, repo): owner / member withwrite/read-write/admin/ server admin.CanManage(user, repo): owner / member withadmin/ server admin.- Server admin (
users.is_admin=1) bypasses all checks.
Endpoint-level enforcement
- All
/api/v1/**exceptauth/register,auth/login,server/info,server/pubkeyrequire a valid identity. - Write operations require
CanWrite; member management requiresCanManage; user-management requires server admin. - APT read:
CanRead(and for public repos, anonymous allowed).
13. REST API specification
Base: /api/v1. JSON in/out unless noted. All list endpoints support
?page=&per_page= (default 25, max 100) and return {items, page, per_page, total}.
Server / setup
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /server/info |
none | {version, needsSetup, defaultKeyFingerprint} |
| GET | /server/pubkey |
none | ASCII-armored default pubkey (Content-Type: application/pgp-keys) |
Auth
| Method | Path | Auth | Body / Notes |
|---|---|---|---|
| POST | /auth/register |
none | {username, password} → {user, token}. First registration → admin. Disabled once users non-empty? No — open registration configurable; default open. |
| POST | /auth/login |
none | {username, password} → {user, token} (creates a session token) |
| POST | /auth/logout |
bearer | revokes the current token |
| GET | /me |
bearer | current user |
| GET | /me/tokens |
bearer | list the user's tokens |
| POST | /me/tokens |
bearer | {name} → {id, prefix, token} (plain token returned once) |
| DELETE | /me/tokens/:id |
bearer | revoke |
register/loginboth return a plain token once; the CLI stores it.
Users (admin)
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /users |
admin | list users |
| GET | /users/:id |
admin | get user |
| PATCH | /users/:id |
admin | {is_admin?} |
| DELETE | /users/:id |
admin | delete user (cannot delete self) |
Repositories
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /repositories |
bearer | list repos visible to caller (owned + member + public) |
| POST | /repositories |
bearer | {name, visibility, description?} → owner=caller |
| GET | /repositories/:repo |
bearer* | repo detail + distros summary (*read check for private) |
| PATCH | /repositories/:repo |
manage | {visibility?, description?, name?} |
| DELETE | /repositories/:repo |
manage | delete repo + cascade |
| GET | /repositories/:repo/members |
read | list members |
| POST | /repositories/:repo/members |
manage | {username, access} |
| PATCH | /repositories/:repo/members/:username |
manage | {access} |
| DELETE | /repositories/:repo/members/:username |
manage | remove member |
| GET | /repositories/:repo/pubkey |
read | ASCII-armored server pubkey (convenience) |
Distributions / components / architectures
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /repositories/:repo/distributions |
read | list |
| POST | /repositories/:repo/distributions |
write | {name} |
| DELETE | /repositories/:repo/distributions/:dist |
write | cascade delete its packages |
| GET | /repositories/:repo/distributions/:dist/components |
read | |
| POST | /repositories/:repo/distributions/:dist/components |
write | {name} |
| DELETE | /repositories/:repo/distributions/:dist/components/:comp |
write | cascade |
| GET | /repositories/:repo/distributions/:dist/architectures |
read | |
| POST | /repositories/:repo/distributions/:dist/architectures |
write | {name} |
| DELETE | /repositories/:repo/distributions/:dist/architectures/:arch |
write | (reject if packages reference it and arch!=all) |
Packages
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /repositories/:repo/distributions/:dist/packages |
read | filters: ?component=&arch=&name=&q= |
| GET | /repositories/:repo/packages/:id |
read | package metadata |
| POST | /repositories/:repo/distributions/:dist/packages |
write | multipart/form-data: file = .deb, component = name. Parses, validates, stores. → {package} |
| GET | /repositories/:repo/packages/:id/file |
read | stream the .deb (CLI pull) |
| DELETE | /repositories/:repo/packages/:id |
write | delete version (decrements blob ref) |
Errors
Uniform {error: {code, message, details?}} with appropriate HTTP status
(400 validation, 401 unauth, 403 forbidden, 404 not found, 409
conflict/duplicate, 500 server).
14. APT endpoint specification
Routes (see §7). Behavior summary:
- All under
/apt/:repo/.... - Public repo: anonymous GETs.
- Private repo: HTTP Basic required (token as password);
401+ challenge otherwise. - Indices generated via
shared/aptwith the in-memory cache. - Pool files served via
http.ServeContent(Range, ETag by sha256). 404for unknown repo/suite/component/arch.- The APT endpoint is mounted on the same HTTP server as the REST API, just a different path prefix and a different auth mode (Basic vs Bearer).
15. CLI design (cmd/urapt)
Local config & token store
- File:
~/.config/urapt/config.toml(perm0600). - Contents:
[default] server = "https://apt.example.com" user = "alice" token = "urapt_..." # stored; future: OS keychain --server,--user,--tokenflags override; envURAPT_SERVER,URAPT_TOKENalso supported.- Future: integrate
go-keyringfor the token.
Commands (cobra)
urapt version
urapt login [<server>] [--username] # prompts password; stores token
urapt logout
urapt whoami
urapt register [<server>] [--username] # create account (first → admin)
urapt token create [--name]
urapt token list
urapt token revoke <id|prefix>
urapt repo create <name> [--public|--private] [--description]
urapt repo list
urapt repo info <name>
urapt repo set-visibility <name> --public|--private
urapt repo delete <name>
urapt repo pubkey <name> [-o file]
urapt repo members list <name>
urapt repo members add <name> <username> --access=read|write|read-write|admin
urapt repo members update <name> <username> --access=...
urapt repo members remove <name> <username>
urapt distro create <repo> <distro>
urapt distro list <repo>
urapt distro delete <repo> <distro>
urapt component create <repo> <distro> <component>
urapt component list <repo> <distro>
urapt component delete <repo> <distro> <component>
urapt arch add <repo> <distro> <arch>
urapt arch list <repo> <distro>
urapt arch remove <repo> <distro> <arch>
urapt push <repo> <distro> <component> <file.deb> [--arch=<override>]
urapt pull <repo> <name>[@<version>][:<arch>] [-o file] # or by id via --id
urapt ls <repo> <distro> [--component] [--arch] [--name] [-q]
urapt show <repo> <id|name[@version][:arch]>
urapt rm <repo> <id|name[@version][:arch]>
urapt apt-config <repo> <distro> [--component=main] [--signed-by=/usr/share/keyrings/urapt.gpg]
# prints: sources.list line, pubkey install steps, and (if private) auth.conf
Output
- Human-readable tables by default;
--jsonfor scripting. urapt apt-configis the key UX helper: it fetches the pubkey, prints the signed-by sources line, and (for private repos) theauth.confsnippet.
Push flow (detailed)
- Resolve token from config.
- Verify the
.deblocally (shared/deb) to give early, clear errors. POST /repositories/:repo/distributions/:dist/packages(multipart) withcomponentand the file.- Server parses/validates/stores, returns the package record.
- CLI prints the new package's
name_version_archand pool path.
16. Server configuration
Sources, precedence low → high: defaults → TOML file (--config, default
./urapt-server.toml) → env (URAPT_*) → CLI flags.
| Key | Env | Default | Notes |
|---|---|---|---|
bind |
URAPT_BIND |
0.0.0.0:8080 |
listen address |
base_url |
URAPT_BASE_URL |
http://localhost:8080 |
external URL for generated apt config / pubkey links |
store_dir |
URAPT_STORE_DIR |
./store |
|
db_path |
URAPT_DB_PATH |
<store_dir>/database/sqlite.db |
|
packages_dir |
URAPT_PACKAGES_DIR |
<store_dir>/packages |
|
log_level |
URAPT_LOG_LEVEL |
info |
debug/info/warn/error |
signing_key_type |
URAPT_SIGNING_KEY_TYPE |
rsa |
|
signing_key_bits |
URAPT_SIGNING_KEY_BITS |
4096 |
|
signing_key_user_id |
URAPT_SIGNING_KEY_USER_ID |
urapt-server <hostname> |
|
max_package_size |
URAPT_MAX_PACKAGE_SIZE |
1073741824 (1 GiB) |
|
open_registration |
URAPT_OPEN_REGISTRATION |
true |
allow new account registration |
tls_enabled |
URAPT_TLS_ENABLED |
false |
optional built-in TLS |
tls_cert / tls_key |
… | — | paths if TLS enabled |
For internet-facing deployments, recommend a TLS-terminating reverse proxy (Caddy/nginx) over built-in TLS.
Startup sequence (server/app)
- Load config.
- Ensure
store_dir,db_pathparent,packages_direxist. - Open SQLite (WAL, foreign_keys, busy_timeout); run migrations.
- Ensure a default GPG key exists (generate if missing).
- Build index cache; wire router (REST + APT); start HTTP server.
- Log
base_url,needsSetup(no users yet), and pubkey fingerprint.
17. Security considerations
- Passwords: bcrypt (cost 12).
- Tokens: 32-byte
crypto/rand, base64url, prefixedurapt_; store only SHA-256;prefixcolumn for display; revocable;last_used_attracking. - Private repo APT reads: HTTP Basic over TLS only (warn if plain HTTP + private repo).
- GPG private key in DB: documented tradeoff; future encryption-at-rest.
- File uploads: stream to a temp file, hash, then move to
store/packages/<sha256>.deb(no execution, no path traversal — pool paths are generated server-side, not from client input). - Input validation: repo/distro/component/arch names restricted to safe
charset (
[a-z0-9][a-z0-9-+.]*, lowercase); usernames[a-z0-9_-]{3,32}. - Rate limiting on
auth/loginandauth/register(future; note in README). - No CORS by default (API-only; future web UI would add it).
ServeContentfor pool files prevents directory traversal (DB lookup by exactpool_path).
18. Testing strategy
Unit (shared/**)
shared/crypto: hash/verify, token round-trip.shared/deb: parse a fixture.deb(gzip/xz/zstd control.tar), assert fields, hashes, sizes.shared/apt: given in-memory package rows, assertPackages,Release,InRelease,Release.gpgbyte content and checksum correctness.shared/gpg: generate key, clearsign + verify, detached sign + verify round-trip using the same library.
Integration (server/**, cli/**)
httptest.Server+ temp SQLite + tempstore/.- REST: register → login → create repo → add distro/component/arch → push
fixture
.deb→ list → pull → delete; permission matrix tests. - APT: push a fixture, then
GETInRelease/Release/Packages/pool and assert content + signature verification with the server pubkey. - Private repo: assert
401without Basic,200with token Basic. - CLI: thin tests using a fake
apiclient(interface-based) for command output; plus an end-to-end test spinning the server against a temp dir.
Full apt integration (CI, optional)
- A Debian-based Docker container test: install a built
urapt-server, push a real.deb, configure apt against it, runapt-get update && apt-get install <pkg>, assert the package installs. Runs in CI matrix (not required forgo test).
Lint / format
go vet ./...,gofmt -l,golangci-lint run(Makefile targets).
19. Deployment
Docker
- Multi-stage
Dockerfile:golang:1.22build →gcr.io/distroless/static(oralpine) runtime. Expose8080. Volume/datamapped tostore_dir. docker-compose.yml: one service, persistent volume, env forbase_url, reverse-proxy example.
Releases
Makefilebuildsurapt-serveranduraptforlinux/amd64,linux/arm64,darwin/amd64,darwin/arm64(CGO disabled viamodernc.org/sqlite).- GitHub Releases with archive + checksums (future CI).
First-run UX (documented in README)
- Run
urapt-server(or docker compose up). urapt register→ you become admin.urapt repo create myrepo --public.urapt distro create myrepo stable.urapt component create myrepo stable main.urapt arch add myrepo stable amd64.urapt push myrepo stable main ./foo_1.0_amd64.deb.urapt apt-config myrepo stable→ paste into client/etc/apt/sources.list.d/myrepo.list, install pubkey.sudo apt-get update && sudo apt-get install foo.
20. Implementation phases & tasks
Each phase ends with go build ./... + go test ./... green.
Phase 0 — Scaffold
go mod init urapt; Go 1.22.- Directory tree (§4); empty packages with doc comments.
Makefile(build,test,vet,fmt,lint,run-server,run-cli)..gitignore(store/,*.db*,urapt,urapt-server).shared/version,cmd/urapt-server/main.go+cmd/urapt/main.gostubs.
Phase 1 — Shared core
shared/config: struct + TOML + env + flag loading; defaults.shared/db: open sqlite (WAL/FK/busy),embedmigrations, runner, query helpers (*sql.DBwrapper).migrations/0001_init.sql(all tables in §6).shared/models: Go structs for every table.shared/crypto: bcrypt password, token gen + SHA-256, prefix.shared/log: slog wrapper.shared/httputil: JSON read/write, error rendering, bearer/basic parse.
Phase 2 — Shared domain utilities
shared/gpg: keygen, export pubkey, clearsign, detached sign (round-trip tests).shared/deb: ar open, control.tar extract (gz/xz/zstd), control parse, full-field extraction + hashes/size (fixture tests).shared/apt:Packages,Release,InRelease,Release.gpggeneration + cache struct (unit tests with fake rows).
Phase 3 — Server: auth & users
server/app: wiring, startup sequence, key init.server/middleware: recover, logging, bearer auth, basic auth.server/auth: identity resolution,CanRead/Write/Manage.server/restapi:/server/info,/server/pubkey,/auth/register,/auth/login,/auth/logout,/me,/me/tokens,/users*.- First-registration-is-admin logic.
Phase 4 — Server: repos & structure
- Repositories CRUD + members CRUD + visibility + pubkey.
- Distributions / components / architectures CRUD.
- Permission enforcement on all of the above.
- Cache invalidation hooks wired.
Phase 5 — Server: packages
POST .../packages: multipart receive →shared/debparse → validate → store blob → insert row → invalidate cache.GET .../packages(list) +GET .../packages/:id+GET .../file(stream) +DELETE .../packages/:id(blob refcount).- Blob dedup + cleanup on refcount 0.
Phase 6 — Server: APT endpoint
server/aptrepo: routes (§7), cache-backed handlers, pool serving viaServeContent.- Private-repo Basic auth +
401challenge. - Signature of Release/InRelease with default key.
- Integration tests: push → fetch indices → verify sigs → fetch pool.
Phase 7 — CLI: config & auth
cli/config: load/save TOML, token store, flag/env overrides.cli/commands:login,logout,whoami,register,version,token *.shared/apiclient: typed client for all endpoints used so far.
Phase 8 — CLI: repos & structure
repo *,repo members *,repo pubkey,distro *,component *,arch *commands.- Extend
shared/apiclient.
Phase 9 — CLI: packages & apt-config
push,pull,ls,show,rm.apt-confighelper (fetch pubkey, print sources + signed-by + auth.conf).- End-to-end CLI↔server test (temp dir).
Phase 10 — Hardening, docs, packaging
- README quickstart + architecture summary.
Dockerfile(multi-stage) +docker-compose.yml.- Cross-compile Makefile targets.
- golangci-lint config; fix findings.
- Optional: Debian-container apt integration test.
- Security review pass (input validation, path traversal, authz matrix).
21. Open / future
- Per-repo GPG keys (each repo its own trust root) — schema already
extensible; add
repositories.signing_key_id. - GPG private key encryption at rest (passphrase from config/env).
- OS keychain for CLI token (
go-keyring). - Source packages (
deb-src,source/indices). - AppStream /
dep11metadata;Contentsindexes. Acquire-By-Hashfor atomic updates.- Web UI (small SPA) + CORS.
- Rate limiting on auth endpoints.
- Built-in TLS (Let's Encrypt autocert option).
- Token scopes (per-repo, read-only tokens).
- Webhook on package push (for downstream CI).
- Open registration toggle / invite-only mode (config exists; add invites).
- Retention policies (keep last N versions per package).
22. Glossary
- Suite / Distribution — e.g.
stable,testing,jammy. The aptdeb ... <suite> <component>line. - Component — e.g.
main,contrib,non-free. A section within a suite. - Architecture — e.g.
amd64,arm64.all= architecture-independent. - Packages index —
dists/<suite>/<component>/binary-<arch>/Packages: lists every.debin that slice with metadata + download path. - Release / InRelease / Release.gpg — suite-level metadata + checksums of the indices, signed so apt can trust them.
- Pool — flat directory tree where the actual
.debfiles live (pool/<component>/<letter>/<src>/<file>.deb). - Blob — a content-addressed
.debfile instore/packages/<sha256>.deb, reference-counted so identical uploads deduplicate.