Initial release: self-hostable APT repository server and CLI
urapt is a self-hostable APT repository server with a companion CLI for pushing and managing Debian .deb packages. Server (urapt-server): - REST API + APT endpoint, SQLite storage (pure-Go modernc driver, no CGO) - .deb files stored content-addressed on disk, reference-counted for dedup - Server-managed RSA-4096 OpenPGP signing key (ProtonMail/go-crypto) - APT indices (Release/InRelease/Packages[.gz/.xz]) generated on demand from the DB, cached in memory, signed with the server key - Full APT model: repositories -> distributions -> components -> architectures - Bearer-token auth for REST; HTTP Basic auth for private-repo APT reads - First registrant becomes admin; repo-scoped permissions (read/write/read-write/admin) plus owner and server-admin roles - Multipart package push with control-field extraction, list/show/delete, pool serving, blob ref-count cleanup - Audit log CLI (urapt): - register/login/logout/whoami, token management - repo/distro/component/arch CRUD, member management - push/pull/ls/show/rm for packages - apt-config helper that emits apt setup commands (key, sources.list, auth.conf for private repos) Packaging & docs: - Dockerfile (multi-stage distroless), docker-compose.yml, sample config - README quick start, architecture overview, config reference, security notes - PLAN.md design blueprint, CHANGELOG.md, GPL-3.0 LICENSE - GitHub Actions CI (test, lint, cross-build for linux/darwin amd64/arm64) - Makefile release target producing static binaries + tarballs + checksums Tests cover the data-access layer, auth/permission checks, APT index generation, .deb parsing, GPG signing, the REST API, and the typed API client. Verified end-to-end on a Raspberry Pi (arm64) pushing and installing a real package.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"urapt/server/auth"
|
||||
"urapt/server/cache"
|
||||
"urapt/server/store"
|
||||
"urapt/shared/config"
|
||||
"urapt/shared/db"
|
||||
"urapt/shared/gpg"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
store *store.Store
|
||||
token string
|
||||
pkgDir string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("db open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
st := store.New(database)
|
||||
authSvc := auth.NewService(st)
|
||||
|
||||
key, err := gpg.GenerateKey("urapt-test <test>", 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gpg key: %v", err)
|
||||
}
|
||||
sp := &testSigner{key: key}
|
||||
cfg := config.Defaults
|
||||
cfg.StoreDir = dir
|
||||
cfg.PackagesDir = filepath.Join(dir, "packages")
|
||||
cfg.DBPath = filepath.Join(dir, "test.db")
|
||||
if err := os.MkdirAll(cfg.PackagesDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir pkg dir: %v", err)
|
||||
}
|
||||
|
||||
h := &harness{t: t, store: st, pkgDir: cfg.PackagesDir}
|
||||
mux := New(st, authSvc, sp, &cfg, cache.New())
|
||||
root := chi.NewRouter()
|
||||
root.Mount("/api/v1", mux)
|
||||
h.srv = httptest.NewServer(root)
|
||||
t.Cleanup(h.srv.Close)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *harness) do(method, path, token string, body any) (int, []byte) {
|
||||
h.t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
h.t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, h.srv.URL+path, r)
|
||||
if err != nil {
|
||||
h.t.Fatalf("new req: %v", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatalf("do %s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, data
|
||||
}
|
||||
|
||||
func (h *harness) serverInfo() (int, map[string]any) {
|
||||
code, body := h.do("GET", "/api/v1/server/info", "", nil)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(body, &m)
|
||||
return code, m
|
||||
}
|
||||
|
||||
func TestServerInfoNeedsSetup(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
code, m := h.serverInfo()
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if m["needs_setup"] != true {
|
||||
t.Fatalf("expected needs_setup=true, got %v", m["needs_setup"])
|
||||
}
|
||||
if m["default_key_fingerprint"] == "" {
|
||||
t.Fatal("expected fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterFirstUserIsAdmin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
code, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{
|
||||
"username": "alice", "password": "supersecret",
|
||||
})
|
||||
if code != 201 {
|
||||
t.Fatalf("register status %d body %s", code, body)
|
||||
}
|
||||
var resp struct {
|
||||
User map[string]any `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Token == "" {
|
||||
t.Fatal("empty token")
|
||||
}
|
||||
if resp.User["is_admin"] != true {
|
||||
t.Fatalf("first user should be admin, got %v", resp.User["is_admin"])
|
||||
}
|
||||
h.token = resp.Token
|
||||
|
||||
// /me with token
|
||||
code, body = h.do("GET", "/api/v1/me", h.token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("me status %d", code)
|
||||
}
|
||||
|
||||
// needs_setup should now be false
|
||||
_, m := h.serverInfo()
|
||||
if m["needs_setup"] != false {
|
||||
t.Fatalf("expected needs_setup=false after register")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsBadUsername(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
code, _ := h.do("POST", "/api/v1/auth/register", "", map[string]string{
|
||||
"username": "A", "password": "supersecret",
|
||||
})
|
||||
if code != 400 {
|
||||
t.Fatalf("expected 400 for short username, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndAuthFlow(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.do("POST", "/api/v1/auth/register", "", map[string]string{
|
||||
"username": "bob", "password": "supersecret",
|
||||
})
|
||||
|
||||
code, body := h.do("POST", "/api/v1/auth/login", "", map[string]string{
|
||||
"username": "bob", "password": "supersecret",
|
||||
})
|
||||
if code != 200 {
|
||||
t.Fatalf("login status %d", code)
|
||||
}
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.Unmarshal(body, &resp)
|
||||
if resp.Token == "" {
|
||||
t.Fatal("empty token")
|
||||
}
|
||||
|
||||
// wrong password
|
||||
code, _ = h.do("POST", "/api/v1/auth/login", "", map[string]string{
|
||||
"username": "bob", "password": "wrongpassword",
|
||||
})
|
||||
if code != 401 {
|
||||
t.Fatalf("expected 401 for wrong password, got %d", code)
|
||||
}
|
||||
|
||||
// me without token
|
||||
code, _ = h.do("GET", "/api/v1/me", "", nil)
|
||||
if code != 401 {
|
||||
t.Fatalf("expected 401 without token, got %d", code)
|
||||
}
|
||||
|
||||
// tokens list
|
||||
code, _ = h.do("GET", "/api/v1/me/tokens", resp.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("tokens list status %d", code)
|
||||
}
|
||||
|
||||
// create token
|
||||
code, body = h.do("POST", "/api/v1/me/tokens", resp.Token, map[string]string{"name": "laptop"})
|
||||
if code != 201 {
|
||||
t.Fatalf("create token status %d", code)
|
||||
}
|
||||
|
||||
// logout
|
||||
code, _ = h.do("POST", "/api/v1/auth/logout", resp.Token, nil)
|
||||
if code != 204 {
|
||||
t.Fatalf("logout status %d", code)
|
||||
}
|
||||
// token now revoked
|
||||
code, _ = h.do("GET", "/api/v1/me", resp.Token, nil)
|
||||
if code != 401 {
|
||||
t.Fatalf("expected 401 after logout, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAdminOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// register two users; first is admin
|
||||
_, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "admin", "password": "supersecret"})
|
||||
var admin struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.Unmarshal(body, &admin)
|
||||
|
||||
_, body = h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "carol", "password": "supersecret"})
|
||||
var carol struct {
|
||||
User map[string]any `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.Unmarshal(body, &carol)
|
||||
if carol.User["is_admin"] == true {
|
||||
t.Fatal("second user should not be admin")
|
||||
}
|
||||
|
||||
// carol cannot list users
|
||||
code, _ := h.do("GET", "/api/v1/users", carol.Token, nil)
|
||||
if code != 403 {
|
||||
t.Fatalf("non-admin list users should be 403, got %d", code)
|
||||
}
|
||||
// admin can list users
|
||||
code, body = h.do("GET", "/api/v1/users", admin.Token, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("admin list users should be 200, got %d", code)
|
||||
}
|
||||
|
||||
// admin cannot delete self
|
||||
code, _ = h.do("DELETE", "/api/v1/users/"+carol.User["id"].(string), admin.Token, nil)
|
||||
if code != 204 {
|
||||
t.Fatalf("admin delete carol should be 204, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// keep context import used in case of future expansion
|
||||
var _ = context.Background
|
||||
|
||||
// testSigner implements restapi.SignerProvider for tests.
|
||||
type testSigner struct{ key *gpg.Key }
|
||||
|
||||
func (s *testSigner) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() }
|
||||
func (s *testSigner) Fingerprint() string { return s.key.Fingerprint }
|
||||
Reference in New Issue
Block a user