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

980 lines
41 KiB
Markdown

# 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 `apt` client speaks to
(serves `dists/.../{Release,InRelease,Release.gpg}`, `Packages` indices, and
the `pool/` `.deb` files).
- Stores **only uploaded `.deb` files 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`/`InRelease` with a **server-managed GPG key**.
- A **CLI** (`urapt`) that:
- Logs you in (username/password → API token stored locally).
- Pushes `.deb` packages to a repository/distribution/component under your
identity.
- Pulls, lists, and deletes packages.
- Manages repositories, members, distributions, components, architectures.
- Emits the `sources.list` line + pubkey + `auth.conf` for client setup.
- A **shared utilities component** (`shared/`) used by both server and CLI:
config, DB, models, GPG, `.deb` parsing, APT index generation, crypto, and
the typed REST API client + DTOs.
### Out of scope (v1, listed as future)
- Source package (`deb-src`) hosting.
- AppStream / `dep11` metadata.
- `Acquire-By-Hash` indices.
- 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 `Contents` indexes.
---
## 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` → `server` or `shared` → `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 (never `server/` or `cli/`).
- `server/**` imports `shared/**`.
- `cli/**` imports `shared/**`.
- `cmd/**` are thin `main` packages 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_id` column. A
> `gpg_keys` table 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 implicitly `admin`.
### `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) | |
> `all` is **not** stored as an architecture row. Architecture-independent
> packages (arch=`all`) are listed in **every** binary-`<arch>` Packages index.
> The Release `Architectures` field 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 the
`pool/` 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) |
- `:arch` excludes `all`; `all`-arch packages are merged into each real arch's
index.
- Pool path resolution: `pool/<component>/<letter>/<src>/<filename>` → DB lookup
by `pool_path` within `:repo` → serve `store/packages/<sha256>.deb` via
`http.ServeContent` (supports Range, ETag, Last-Modified).
- `:letter`/`:src` follow Debian convention: if source name starts with `lib`,
prefix = `lib` + first char after `lib` (e.g. `liba` for `libapache2…`);
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 least `read`/`write`/`read-write`/`admin` access (or is
owner/admin). On missing/invalid creds, respond `401` with
`WWW-Authenticate: Basic realm="urapt <repo>"` so apt's `auth.conf` triggers.
Client `auth.conf` example:
```
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)`:
1. Query all `packages` rows matching `distribution_id`, `component_id`, and
`(architecture = arch OR architecture = 'all')`.
2. 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>
```
3. Entries separated by a blank line; file ends with a blank line.
4. Serve as `Packages`; also serve gzip (`Packages.gz`) and xz (`Packages.xz`).
### Release file (per suite)
1. Determine components (list of component names for the suite) and
architectures (configured arch names).
2. For each `(component, arch)` generate the `Packages`, `Packages.gz`,
`Packages.xz` bytes (reuse from the per-arch generation).
3. Compute checksums + sizes of each, keyed by their **path relative to the
suite**, e.g. `main/binary-amd64/Packages`.
4. 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).
- `Date` is regenerated on cache invalidation.
### InRelease
- `InRelease` = **clearsigned** `Release` (inline OpenPGP signature) using the
server's default GPG key. Preferred by modern apt.
### Release.gpg
- **Detached** signature of `Release` using 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_keys` with `is_default=1`.
- **Operations:**
- `Clearsign(data) → InRelease`
- `DetachedSign(data) → Release.gpg`
- `ExportPublic() → ascii-armored pubkey` (served at `/api/v1/server/pubkey`
and `/apt/:repo/...` is signed by it).
- The pubkey is also exposed via the CLI (`urapt repo pubkey`) and the
`apt-config` helper 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
1. Open the `.deb` as an `ar` archive (`pault.ag/go/debian/deb` or raw ar).
2. Read the `debian-binary` member (validate version `2.0`).
3. Locate the `control.tar.*` member; decompress (gzip / xz / zstd by magic
bytes).
4. From the tar, read the `control` file (and optional `shlibs`, `symbols`,
`triggers` — ignored for v1).
5. Parse the control stanza (`pault.ag/go/debian/control`): RFC822-style fields.
6. Extract all fields needed for the `packages` row (see §6) and keep the full
`raw_control` for re-emission.
7. Compute file hashes (MD5, SHA1, SHA256) and size **of the whole `.deb`**.
### Validation on push
- Must be a valid `ar` archive with `debian-binary` + `control.tar.*`.
- `Package`, `Version`, `Architecture` must be present and non-empty.
- `Architecture` must be one of the distribution's configured arches **or**
`all`.
- Version must be a valid Debian version string (basic sanity).
- No duplicate of `(repo, distro, component, name, version, arch)` — else 409.
- Reject if `.deb` size > 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 `.deb` basename (sanitized) and
`<src>` is the `Source` package name (or `Package` name 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/*.deb` are 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 up
`api_tokens` (non-revoked) → load user. Update `last_used_at` (throttled).
Missing/invalid → `401`.
- **APT endpoint (private repos):** `Authorization: Basic ...` → decode →
`password` is the token → same resolution.
### Permission helpers
- `CanRead(user, repo)`: repo public → true; else owner / member with
`read`/`write`/`read-write`/`admin` / server admin.
- `CanWrite(user, repo)`: owner / member with `write`/`read-write`/`admin` /
server admin.
- `CanManage(user, repo)`: owner / member with `admin` / server admin.
- Server admin (`users.is_admin=1`) bypasses all checks.
### Endpoint-level enforcement
- All `/api/v1/**` except `auth/register`, `auth/login`, `server/info`,
`server/pubkey` require a valid identity.
- Write operations require `CanWrite`; member management requires `CanManage`;
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`/`login` both 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/apt` with the in-memory cache.
- Pool files served via `http.ServeContent` (Range, ETag by sha256).
- `404` for 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` (perm `0600`).
- Contents:
```toml
[default]
server = "https://apt.example.com"
user = "alice"
token = "urapt_..." # stored; future: OS keychain
```
- `--server`, `--user`, `--token` flags override; env `URAPT_SERVER`,
`URAPT_TOKEN` also supported.
- Future: integrate `go-keyring` for 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; `--json` for scripting.
- `urapt apt-config` is the key UX helper: it fetches the pubkey, prints the
signed-by sources line, and (for private repos) the `auth.conf` snippet.
### Push flow (detailed)
1. Resolve token from config.
2. Verify the `.deb` locally (`shared/deb`) to give early, clear errors.
3. `POST /repositories/:repo/distributions/:dist/packages` (multipart) with
`component` and the file.
4. Server parses/validates/stores, returns the package record.
5. CLI prints the new package's `name_version_arch` and 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`)
1. Load config.
2. Ensure `store_dir`, `db_path` parent, `packages_dir` exist.
3. Open SQLite (WAL, foreign_keys, busy_timeout); run migrations.
4. Ensure a default GPG key exists (generate if missing).
5. Build index cache; wire router (REST + APT); start HTTP server.
6. Log `base_url`, `needsSetup` (no users yet), and pubkey fingerprint.
---
## 17. Security considerations
- Passwords: bcrypt (cost 12).
- Tokens: 32-byte `crypto/rand`, base64url, prefixed `urapt_`; store only
SHA-256; `prefix` column for display; revocable; `last_used_at` tracking.
- 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/login` and `auth/register` (future; note in README).
- No CORS by default (API-only; future web UI would add it).
- `ServeContent` for pool files prevents directory traversal (DB lookup by
exact `pool_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, assert `Packages`, `Release`,
`InRelease`, `Release.gpg` byte 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 + temp `store/`.
- REST: register → login → create repo → add distro/component/arch → push
fixture `.deb` → list → pull → delete; permission matrix tests.
- APT: push a fixture, then `GET` `InRelease`/`Release`/`Packages`/pool and
assert content + signature verification with the server pubkey.
- Private repo: assert `401` without Basic, `200` with 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, run `apt-get update && apt-get install
<pkg>`, assert the package installs. Runs in CI matrix (not required for
`go test`).
### Lint / format
- `go vet ./...`, `gofmt -l`, `golangci-lint run` (Makefile targets).
---
## 19. Deployment
### Docker
- Multi-stage `Dockerfile`: `golang:1.22` build → `gcr.io/distroless/static`
(or `alpine`) runtime. Expose `8080`. Volume `/data` mapped to `store_dir`.
- `docker-compose.yml`: one service, persistent volume, env for `base_url`,
reverse-proxy example.
### Releases
- `Makefile` builds `urapt-server` and `urapt` for
`linux/amd64,linux/arm64,darwin/amd64,darwin/arm64` (CGO disabled via
`modernc.org/sqlite`).
- GitHub Releases with archive + checksums (future CI).
### First-run UX (documented in README)
1. Run `urapt-server` (or docker compose up).
2. `urapt register` → you become admin.
3. `urapt repo create myrepo --public`.
4. `urapt distro create myrepo stable`.
5. `urapt component create myrepo stable main`.
6. `urapt arch add myrepo stable amd64`.
7. `urapt push myrepo stable main ./foo_1.0_amd64.deb`.
8. `urapt apt-config myrepo stable` → paste into client `/etc/apt/sources.list.d/myrepo.list`, install pubkey.
9. `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.go` stubs.
### Phase 1 — Shared core
- [ ] `shared/config`: struct + TOML + env + flag loading; defaults.
- [ ] `shared/db`: open sqlite (WAL/FK/busy), `embed` migrations, runner,
query helpers (`*sql.DB` wrapper).
- [ ] `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.gpg`
generation + 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/deb` parse → 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 via
`ServeContent`.
- [ ] Private-repo Basic auth + `401` challenge.
- [ ] 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-config` helper (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 / `dep11`** metadata; **`Contents`** indexes.
- **`Acquire-By-Hash`** for 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 apt
`deb ... <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 `.deb` in 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 `.deb` files live
(`pool/<component>/<letter>/<src>/<file>.deb`).
- **Blob** — a content-addressed `.deb` file in `store/packages/<sha256>.deb`,
reference-counted so identical uploads deduplicate.