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:
2026-06-28 16:57:34 -05:00
commit 981587e83d
83 changed files with 12812 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
// Package api defines the request and response DTOs that form the urapt REST
// API contract. The server's restapi package produces these and the CLI's
// apiclient package consumes them; keeping them in one place prevents drift.
package api
import "urapt/shared/models"
// --- server / setup ---
// ServerInfo is the response from GET /server/info.
type ServerInfo struct {
Version string `json:"version"`
NeedsSetup bool `json:"needs_setup"`
DefaultKeyFingerprint string `json:"default_key_fingerprint"`
OpenRegistration bool `json:"open_registration"`
}
// --- auth ---
// RegisterRequest is the body for POST /auth/register.
type RegisterRequest struct {
Username string `json:"username" validate:"required,min=3,max=32,username"`
Password string `json:"password" validate:"required,min=8,max=256"`
}
// LoginRequest is the body for POST /auth/login.
type LoginRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
// AuthResponse is returned by register and login.
type AuthResponse struct {
User *models.User `json:"user"`
Token string `json:"token"`
}
// CreateTokenRequest is the body for POST /me/tokens.
type CreateTokenRequest struct {
Name string `json:"name" validate:"required,min=1,max=64"`
}
// ListResponse wraps a page of items.
type ListResponse[T any] struct {
Items []T `json:"items"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
}
// --- users (admin) ---
// UpdateUserRequest is the body for PATCH /users/:id.
type UpdateUserRequest struct {
IsAdmin *bool `json:"is_admin,omitempty"`
}
// --- repositories ---
// CreateRepoRequest is the body for POST /repositories.
type CreateRepoRequest struct {
Name string `json:"name"`
Visibility string `json:"visibility"`
Description string `json:"description,omitempty"`
}
// UpdateRepoRequest is the body for PATCH /repositories/:repo.
type UpdateRepoRequest struct {
Name *string `json:"name,omitempty"`
Visibility *string `json:"visibility,omitempty"`
Description *string `json:"description,omitempty"`
}
// AddMemberRequest is the body for POST /repositories/:repo/members.
type AddMemberRequest struct {
Username string `json:"username"`
Access string `json:"access"`
}
// UpdateMemberRequest is the body for PATCH /repositories/:repo/members/:username.
type UpdateMemberRequest struct {
Access string `json:"access"`
}
// --- structure ---
// CreateNamedRequest is the body for creating a distribution/component/arch.
type CreateNamedRequest struct {
Name string `json:"name"`
}
+145
View File
@@ -0,0 +1,145 @@
package apiclient_test
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"urapt/server/aptrepo"
"urapt/server/auth"
"urapt/server/cache"
"urapt/server/restapi"
"urapt/server/store"
"urapt/shared/apiclient"
"urapt/shared/config"
"urapt/shared/db"
"urapt/shared/gpg"
)
func newServer(t *testing.T) (baseURL string, cleanup func()) {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db open: %v", err)
}
st := store.New(database)
authSvc := auth.NewService(st)
key, err := gpg.GenerateKey("urapt-test <t>", 2048)
if err != nil {
t.Fatalf("gpg: %v", err)
}
sp := &signer{key: key}
cfg := config.Defaults
cfg.StoreDir = dir
cfg.PackagesDir = filepath.Join(dir, "packages")
cfg.DBPath = filepath.Join(dir, "test.db")
_ = os.MkdirAll(cfg.PackagesDir, 0o755)
idxCache := cache.New()
root := chi.NewRouter()
root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache))
root.Mount("/apt", aptrepo.New(st, authSvc, key, idxCache))
srv := newHTTPServer(root)
return srv.URL, func() { srv.Close(); database.Close() }
}
type signer struct{ key *gpg.Key }
func (s *signer) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() }
func (s *signer) Fingerprint() string { return s.key.Fingerprint }
func TestClientEndToEnd(t *testing.T) {
baseURL, cleanup := newServer(t)
defer cleanup()
unauth := apiclient.New(baseURL, "")
user, token, err := unauth.Register("alice", "supersecret")
if err != nil {
t.Fatalf("Register: %v", err)
}
if user.Username != "alice" || token == "" {
t.Fatalf("bad register response: %+v", user)
}
c := apiclient.New(baseURL, token)
if _, err := c.Me(); err != nil {
t.Fatalf("Me: %v", err)
}
repo, err := c.CreateRepository("myrepo", "public", "test")
if err != nil {
t.Fatalf("CreateRepository: %v", err)
}
if repo.Name != "myrepo" {
t.Fatalf("bad repo: %+v", repo)
}
if _, err := c.CreateDistribution("myrepo", "stable"); err != nil {
t.Fatalf("CreateDistribution: %v", err)
}
if _, err := c.CreateComponent("myrepo", "stable", "main"); err != nil {
t.Fatalf("CreateComponent: %v", err)
}
if _, err := c.CreateArchitecture("myrepo", "stable", "amd64"); err != nil {
t.Fatalf("CreateArchitecture: %v", err)
}
// Build a fixture deb and push it.
debBytes := buildDeb("hello", "1.0", "amd64")
debPath := filepath.Join(t.TempDir(), "hello_1.0_amd64.deb")
if err := os.WriteFile(debPath, debBytes, 0o644); err != nil {
t.Fatalf("write deb: %v", err)
}
pkg, err := c.PushPackage("myrepo", "stable", "main", debPath)
if err != nil {
t.Fatalf("PushPackage: %v", err)
}
if pkg.Name != "hello" || pkg.SHA256 == "" {
t.Fatalf("bad package: %+v", pkg)
}
list, err := c.ListPackages("myrepo", "stable", map[string]string{"name": "hello"})
if err != nil {
t.Fatalf("ListPackages: %v", err)
}
if len(list.Items) != 1 {
t.Fatalf("expected 1 package, got %d", len(list.Items))
}
got, err := c.GetPackage("myrepo", pkg.ID)
if err != nil {
t.Fatalf("GetPackage: %v", err)
}
if got.ID != pkg.ID {
t.Fatalf("GetPackage mismatch")
}
// Download and compare bytes.
dlPath := filepath.Join(t.TempDir(), "pulled.deb")
if err := c.DownloadPackage("myrepo", pkg.ID, dlPath); err != nil {
t.Fatalf("DownloadPackage: %v", err)
}
dl, _ := os.ReadFile(dlPath)
if !bytes.Equal(dl, debBytes) {
t.Fatalf("downloaded bytes mismatch (%d vs %d)", len(dl), len(debBytes))
}
// apt-config helpers.
if _, err := c.RepositoryPubkey("myrepo"); err != nil {
t.Fatalf("RepositoryPubkey: %v", err)
}
// Delete the package.
if err := c.DeletePackage("myrepo", pkg.ID); err != nil {
t.Fatalf("DeletePackage: %v", err)
}
list, _ = c.ListPackages("myrepo", "stable", nil)
if len(list.Items) != 0 {
t.Fatalf("expected 0 packages after delete, got %d", len(list.Items))
}
}
+213
View File
@@ -0,0 +1,213 @@
// Package apiclient is the typed HTTP client used by the urapt CLI to talk to
// the urapt-server REST API. It wraps net/http with the shared API DTOs.
package apiclient
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
apitypes "urapt/shared/api"
"urapt/shared/httputil"
"urapt/shared/models"
)
// Client is an authenticated HTTP client for the urapt REST API.
type Client struct {
BaseURL string
Token string
HTTP *http.Client
}
// New constructs a client. baseURL must not have a trailing slash.
func New(baseURL, token string) *Client {
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: http.DefaultClient}
}
// APIError is an error returned by the server (the error envelope).
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s (HTTP %d)", e.Message, e.Status)
}
// IsAPIError reports whether err is an *APIError and returns it.
func IsAPIError(err error) (*APIError, bool) {
if e, ok := err.(*APIError); ok {
return e, true
}
return nil, false
}
// do performs a JSON request and unmarshals the response into out (if non-nil
// and status is 2xx). On non-2xx it returns an *APIError.
func (c *Client) do(method, path string, body any, out any) error {
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
r = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.BaseURL+path, r)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
var env struct {
Error httputil.APIError `json:"error"`
}
_ = json.Unmarshal(data, &env)
return &APIError{Status: resp.StatusCode, Code: env.Error.Code, Message: env.Error.Message}
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("unmarshal: %w", err)
}
}
return nil
}
// getJSON, postJSON, patchJSON, deleteJSON are convenience wrappers.
func (c *Client) getJSON(path string, out any) error { return c.do("GET", path, nil, out) }
func (c *Client) postJSON(path string, body, out any) error {
return c.do("POST", path, body, out)
}
func (c *Client) patchJSON(path string, body, out any) error {
return c.do("PATCH", path, body, out)
}
func (c *Client) deleteJSON(path string) error { return c.do("DELETE", path, nil, nil) }
// --- server ---
// ServerInfo fetches /server/info.
func (c *Client) ServerInfo() (*apitypes.ServerInfo, error) {
var info apitypes.ServerInfo
if err := c.getJSON("/api/v1/server/info", &info); err != nil {
return nil, err
}
return &info, nil
}
// ServerPubkey fetches the armored default public key.
func (c *Client) ServerPubkey() (string, error) {
req, _ := http.NewRequest("GET", c.BaseURL+"/api/v1/server/pubkey", nil)
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return "", parseErrorBody(resp.StatusCode, data)
}
return string(data), nil
}
// parseErrorBody builds an *APIError from a non-2xx response body, decoding the
// {"error": {...}} envelope when present.
func parseErrorBody(status int, data []byte) error {
var env struct {
Error httputil.APIError `json:"error"`
}
if err := json.Unmarshal(data, &env); err == nil && env.Error.Message != "" {
return &APIError{Status: status, Code: env.Error.Code, Message: env.Error.Message}
}
return &APIError{Status: status, Message: strings.TrimSpace(string(data))}
}
// --- auth ---
// Register creates an account and returns the user + token.
func (c *Client) Register(username, password string) (*models.User, string, error) {
var resp apitypes.AuthResponse
if err := c.postJSON("/api/v1/auth/register", apitypes.RegisterRequest{Username: username, Password: password}, &resp); err != nil {
return nil, "", err
}
return resp.User, resp.Token, nil
}
// Login authenticates and returns the user + token.
func (c *Client) Login(username, password string) (*models.User, string, error) {
var resp apitypes.AuthResponse
if err := c.postJSON("/api/v1/auth/login", apitypes.LoginRequest{Username: username, Password: password}, &resp); err != nil {
return nil, "", err
}
return resp.User, resp.Token, nil
}
// Logout revokes the current token.
func (c *Client) Logout() error { return c.postJSON("/api/v1/auth/logout", nil, nil) }
// Me returns the current user.
func (c *Client) Me() (*models.User, error) {
var u models.User
if err := c.getJSON("/api/v1/me", &u); err != nil {
return nil, err
}
return &u, nil
}
// ListTokens returns the caller's tokens.
func (c *Client) ListTokens() ([]*models.APIToken, error) {
var resp apitypes.ListResponse[*models.APIToken]
if err := c.getJSON("/api/v1/me/tokens", &resp); err != nil {
return nil, err
}
return resp.Items, nil
}
// CreateToken issues a new named token.
func (c *Client) CreateToken(name string) (*models.APIToken, error) {
var t models.APIToken
if err := c.postJSON("/api/v1/me/tokens", apitypes.CreateTokenRequest{Name: name}, &t); err != nil {
return nil, err
}
return &t, nil
}
// RevokeToken revokes a token by id.
func (c *Client) RevokeToken(id string) error { return c.deleteJSON("/api/v1/me/tokens/" + id) }
// addQuery attaches query parameters to path.
func addQuery(path string, params map[string]string) string {
if len(params) == 0 {
return path
}
v := url.Values{}
for k, val := range params {
if val != "" {
v.Set(k, val)
}
}
q := v.Encode()
if q == "" {
return path
}
return path + "?" + q
}
// keep multipart referenced (used by PushPackage in Phase 9).
var _ = multipart.NewWriter
+81
View File
@@ -0,0 +1,81 @@
package apiclient_test
import (
"archive/tar"
"bytes"
"compress/gzip"
"net/http"
"net/http/httptest"
)
func newHTTPServer(h http.Handler) *httptest.Server {
return httptest.NewServer(h)
}
// buildDeb constructs a minimal valid .deb with the given package/version/arch.
func buildDeb(name, version, arch string) []byte {
control := "Package: " + name + "\nVersion: " + version + "\nArchitecture: " + arch + "\nMaintainer: t <t@e>\nDescription: short\n extended\n"
var ctrlBuf bytes.Buffer
gz, _ := gzip.NewWriterLevel(&ctrlBuf, 9)
tw := tar.NewWriter(gz)
writeTw(tw, "control", control)
tw.Close()
gz.Close()
var dataBuf bytes.Buffer
gz2, _ := gzip.NewWriterLevel(&dataBuf, 9)
tw2 := tar.NewWriter(gz2)
writeTw(tw2, "usr/share/"+name, "x")
tw2.Close()
gz2.Close()
var out bytes.Buffer
out.WriteString("!<arch>\n")
writeAr(&out, "debian-binary", []byte("2.0\n"))
writeAr(&out, "control.tar.gz", ctrlBuf.Bytes())
writeAr(&out, "data.tar.gz", dataBuf.Bytes())
return out.Bytes()
}
func writeTw(tw *tar.Writer, name, body string) {
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
_, _ = tw.Write([]byte(body))
}
func writeAr(buf *bytes.Buffer, name string, data []byte) {
header := make([]byte, 60)
for i := range header {
header[i] = ' '
}
copy(header[0:], name+"/")
copy(header[48:], []byte(padNum(len(data), 10)))
header[58] = '`'
header[59] = '\n'
buf.Write(header)
buf.Write(data)
if len(data)%2 == 1 {
buf.WriteByte('\n')
}
}
func padNum(n, width int) string {
s := make([]byte, width)
for i := range s {
s[i] = ' '
}
digits := []byte(itoa(n))
copy(s[len(s)-len(digits):], digits)
return string(s)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
+160
View File
@@ -0,0 +1,160 @@
package apiclient
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"urapt/shared/models"
)
// PackageListResponse is the shape returned by the packages list endpoint.
type PackageListResponse struct {
Items []*models.Package `json:"items"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
}
// ListPackages lists packages in a (repo, distribution) with optional filters.
func (c *Client) ListPackages(repo, dist string, filters map[string]string) (*PackageListResponse, error) {
var resp PackageListResponse
path := addQuery("/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", filters)
if err := c.getJSON(path, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetPackage returns a single package by id.
func (c *Client) GetPackage(repo, id string) (*models.Package, error) {
var p models.Package
if err := c.getJSON("/api/v1/repositories/"+repo+"/packages/"+id, &p); err != nil {
return nil, err
}
return &p, nil
}
// PushPackage uploads a .deb file to a repository/distribution/component. The
// upload is streamed via multipart/form-data.
func (c *Client) PushPackage(repo, dist, component, filePath string) (*models.Package, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
pr, pw := io.Pipe()
writer := multipart.NewWriter(pw)
go func() {
defer pw.Close()
_ = writer.WriteField("component", component)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
pw.CloseWithError(err)
return
}
if _, err := io.Copy(part, f); err != nil {
pw.CloseWithError(err)
return
}
_ = writer.Close()
}()
req, err := http.NewRequest("POST", c.BaseURL+"/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", pr)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, parseErrorBody(resp.StatusCode, data)
}
var p models.Package
if err := json.Unmarshal(data, &p); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
return &p, nil
}
// DownloadPackage fetches a package's .deb file and writes it to outFile. If
// outFile is empty, the bytes are written to stdout.
func (c *Client) DownloadPackage(repo, id, outFile string) error {
req, err := http.NewRequest("GET", c.BaseURL+"/api/v1/repositories/"+repo+"/packages/"+id+"/file", nil)
if err != nil {
return err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return parseErrorBody(resp.StatusCode, data)
}
var w io.Writer
if outFile == "" || outFile == "-" {
w = os.Stdout
} else {
f, err := os.Create(outFile)
if err != nil {
return err
}
defer f.Close()
w = f
}
_, err = io.Copy(w, resp.Body)
return err
}
// DeletePackage removes a package by id.
func (c *Client) DeletePackage(repo, id string) error {
return c.deleteJSON("/api/v1/repositories/" + repo + "/packages/" + id)
}
// ParsePackageSpec splits a "name[@version][:arch]" specifier into its parts.
// A string that looks like a UUID is treated as an id.
func ParsePackageSpec(spec string) (id, name, version, arch string) {
spec = strings.TrimSpace(spec)
if isUUID(spec) {
return spec, "", "", ""
}
// split :arch
if i := strings.IndexByte(spec, ':'); i >= 0 {
arch = spec[i+1:]
spec = spec[:i]
}
// split @version
if i := strings.IndexByte(spec, '@'); i >= 0 {
version = spec[i+1:]
spec = spec[:i]
}
name = spec
return "", name, version, arch
}
// isUUID reports whether s looks like a UUIDv4.
func isUUID(s string) bool {
if len(s) != 36 {
return false
}
return s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-'
}
+205
View File
@@ -0,0 +1,205 @@
package apiclient
import (
"io"
"net/http"
apitypes "urapt/shared/api"
"urapt/shared/models"
)
// newGetReq builds a GET request to url.
func newGetReq(url string) (*http.Request, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
return req, nil
}
// readBody fully reads a response body.
func readBody(resp *http.Response) ([]byte, error) {
return io.ReadAll(resp.Body)
}
// --- repositories ---
// ListRepositories returns repositories visible to the caller.
func (c *Client) ListRepositories() ([]*models.Repository, error) {
var resp apitypes.ListResponse[*models.Repository]
if err := c.getJSON("/api/v1/repositories", &resp); err != nil {
return nil, err
}
return resp.Items, nil
}
// CreateRepository creates a new repository.
func (c *Client) CreateRepository(name, visibility, description string) (*models.Repository, error) {
var repo models.Repository
if err := c.postJSON("/api/v1/repositories", apitypes.CreateRepoRequest{
Name: name, Visibility: visibility, Description: description,
}, &repo); err != nil {
return nil, err
}
return &repo, nil
}
// GetRepository returns a single repository by name.
func (c *Client) GetRepository(name string) (*models.Repository, error) {
var repo models.Repository
if err := c.getJSON("/api/v1/repositories/"+name, &repo); err != nil {
return nil, err
}
return &repo, nil
}
// UpdateRepository mutates a repository. nil arguments leave fields unchanged.
func (c *Client) UpdateRepository(name string, newName *string, visibility *string, description *string) (*models.Repository, error) {
var repo models.Repository
if err := c.patchJSON("/api/v1/repositories/"+name, apitypes.UpdateRepoRequest{
Name: newName, Visibility: visibility, Description: description,
}, &repo); err != nil {
return nil, err
}
return &repo, nil
}
// DeleteRepository removes a repository.
func (c *Client) DeleteRepository(name string) error {
return c.deleteJSON("/api/v1/repositories/" + name)
}
// RepositoryPubkey returns the server's armored public key (repo-scoped path).
func (c *Client) RepositoryPubkey(name string) (string, error) {
req, err := newGetReq(c.BaseURL + "/api/v1/repositories/" + name + "/pubkey")
if err != nil {
return "", err
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := readBody(resp)
if resp.StatusCode >= 400 {
return "", parseErrorBody(resp.StatusCode, data)
}
return string(data), nil
}
// --- members ---
// ListMembers returns the members of a repository.
func (c *Client) ListMembers(repo string) ([]*models.RepositoryMember, error) {
var out []*models.RepositoryMember
if err := c.getJSON("/api/v1/repositories/"+repo+"/members", &out); err != nil {
return nil, err
}
return out, nil
}
// AddMember grants a user access on a repository.
func (c *Client) AddMember(repo, username, access string) (*models.RepositoryMember, error) {
var m models.RepositoryMember
if err := c.postJSON("/api/v1/repositories/"+repo+"/members",
apitypes.AddMemberRequest{Username: username, Access: access}, &m); err != nil {
return nil, err
}
return &m, nil
}
// UpdateMember changes a member's access.
func (c *Client) UpdateMember(repo, username, access string) (*models.RepositoryMember, error) {
var m models.RepositoryMember
if err := c.patchJSON("/api/v1/repositories/"+repo+"/members/"+username,
apitypes.UpdateMemberRequest{Access: access}, &m); err != nil {
return nil, err
}
return &m, nil
}
// RemoveMember revokes a user's access.
func (c *Client) RemoveMember(repo, username string) error {
return c.deleteJSON("/api/v1/repositories/" + repo + "/members/" + username)
}
// --- distributions ---
// ListDistributions returns the distributions in a repository.
func (c *Client) ListDistributions(repo string) ([]*models.Distribution, error) {
var out []*models.Distribution
if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions", &out); err != nil {
return nil, err
}
return out, nil
}
// CreateDistribution adds a distribution.
func (c *Client) CreateDistribution(repo, name string) (*models.Distribution, error) {
var d models.Distribution
if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions",
apitypes.CreateNamedRequest{Name: name}, &d); err != nil {
return nil, err
}
return &d, nil
}
// DeleteDistribution removes a distribution.
func (c *Client) DeleteDistribution(repo, name string) error {
return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + name)
}
// --- components ---
// ListComponents returns the components in a distribution.
func (c *Client) ListComponents(repo, dist string) ([]*models.Component, error) {
var out []*models.Component
if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/components", &out); err != nil {
return nil, err
}
return out, nil
}
// CreateComponent adds a component.
func (c *Client) CreateComponent(repo, dist, name string) (*models.Component, error) {
var comp models.Component
if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/components",
apitypes.CreateNamedRequest{Name: name}, &comp); err != nil {
return nil, err
}
return &comp, nil
}
// DeleteComponent removes a component.
func (c *Client) DeleteComponent(repo, dist, name string) error {
return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + dist + "/components/" + name)
}
// --- architectures ---
// ListArchitectures returns the architectures in a distribution.
func (c *Client) ListArchitectures(repo, dist string) ([]*models.Architecture, error) {
var out []*models.Architecture
if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/architectures", &out); err != nil {
return nil, err
}
return out, nil
}
// CreateArchitecture adds an architecture.
func (c *Client) CreateArchitecture(repo, dist, name string) (*models.Architecture, error) {
var a models.Architecture
if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/architectures",
apitypes.CreateNamedRequest{Name: name}, &a); err != nil {
return nil, err
}
return &a, nil
}
// DeleteArchitecture removes an architecture.
func (c *Client) DeleteArchitecture(repo, dist, name string) error {
return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + dist + "/architectures/" + name)
}
+267
View File
@@ -0,0 +1,267 @@
// Package apt generates APT repository indices (Packages, Release, InRelease,
// Release.gpg) entirely from in-memory package data. Indices are never written
// to disk by this package; the caller caches and serves them.
package apt
import (
"bytes"
"compress/gzip"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
"github.com/ulikunitz/xz"
)
// Signer is implemented by anything able to clearsign and detached-sign the
// Release file (e.g. *gpg.Key).
type Signer interface {
ClearSign(data []byte) ([]byte, error)
DetachedSign(data []byte) ([]byte, error)
}
// PackageRow is a single package's data needed to emit its index entry.
type PackageRow struct {
Component string
Name string
Version string
Architecture string
PoolPath string
Size int64
MD5sum string
SHA1 string
SHA256 string
DescriptionMD5 string
RawControl string
}
// Suite describes a (repository, distribution) for which to generate indices.
type Suite struct {
Origin string
Label string
Suite string
Codename string
Description string
Components []string
Architectures []string
Packages []PackageRow
}
// Indices holds all generated index artifacts for a suite, keyed by their path
// relative to the suite directory.
type Indices struct {
Packages map[string][]byte
PackagesGz map[string][]byte
PackagesXz map[string][]byte
Release []byte
InRelease []byte
ReleaseGpg []byte
}
// Generate builds all indices for the suite and signs the Release file using
// signer. If signer is nil, InRelease/ReleaseGpg are left empty.
func Generate(s *Suite, signer Signer) (*Indices, error) {
components := dedupSorted(s.Components)
arches := dedupSorted(s.Architectures)
idx := &Indices{
Packages: map[string][]byte{},
PackagesGz: map[string][]byte{},
PackagesXz: map[string][]byte{},
}
// Group packages by (component, arch), including arch="all" in every arch.
type key struct{ comp, arch string }
groups := map[key][]PackageRow{}
for _, p := range s.Packages {
for _, arch := range arches {
if p.Architecture == arch || p.Architecture == "all" {
k := key{p.Component, arch}
groups[k] = append(groups[k], p)
}
}
}
for _, comp := range components {
for _, arch := range arches {
rows := groups[key{comp, arch}]
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Name != rows[j].Name {
return rows[i].Name < rows[j].Name
}
return rows[i].Version < rows[j].Version
})
pkgBytes := generatePackagesIndex(rows)
relPath := fmt.Sprintf("%s/binary-%s/Packages", comp, arch)
idx.Packages[relPath] = pkgBytes
idx.PackagesGz[relPath+".gz"] = gzipBytes(pkgBytes)
xzBytes, err := xzBytes(pkgBytes)
if err != nil {
return nil, fmt.Errorf("xz compress %s: %w", relPath, err)
}
idx.PackagesXz[relPath+".xz"] = xzBytes
}
}
release, err := generateRelease(s, components, arches, idx)
if err != nil {
return nil, err
}
idx.Release = release
if signer != nil {
clear, err := signer.ClearSign(release)
if err != nil {
return nil, fmt.Errorf("clearsign: %w", err)
}
idx.InRelease = clear
det, err := signer.DetachedSign(release)
if err != nil {
return nil, fmt.Errorf("detach sign: %w", err)
}
idx.ReleaseGpg = det
}
return idx, nil
}
// generatePackagesIndex emits the Packages file body for one (component, arch).
// The result is always non-nil (an empty byte slice if there are no rows).
func generatePackagesIndex(rows []PackageRow) []byte {
var buf bytes.Buffer
for _, r := range rows {
stanza := strings.TrimRight(r.RawControl, "\n")
buf.WriteString(stanza)
buf.WriteByte('\n')
writeField(&buf, "Filename", r.PoolPath)
writeFieldInt(&buf, "Size", r.Size)
writeField(&buf, "MD5sum", r.MD5sum)
writeField(&buf, "SHA1", r.SHA1)
writeField(&buf, "SHA256", r.SHA256)
if r.DescriptionMD5 != "" {
writeField(&buf, "Description-md5", r.DescriptionMD5)
}
buf.WriteByte('\n')
}
out := buf.Bytes()
if out == nil {
out = []byte{}
}
return out
}
// fileEntry is one index file's path and bytes, used for Release checksums.
type fileEntry struct {
path string
data []byte
}
func (e fileEntry) md5() string { sum := md5.Sum(e.data); return hex.EncodeToString(sum[:]) }
func (e fileEntry) sha1() string { sum := sha1.Sum(e.data); return hex.EncodeToString(sum[:]) }
func (e fileEntry) sha256() string {
sum := sha256.Sum256(e.data)
return hex.EncodeToString(sum[:])
}
func generateRelease(s *Suite, components, arches []string, idx *Indices) ([]byte, error) {
var entries []fileEntry
for path, data := range idx.Packages {
entries = append(entries, fileEntry{path: path, data: data})
}
for path, data := range idx.PackagesGz {
entries = append(entries, fileEntry{path: path, data: data})
}
for path, data := range idx.PackagesXz {
entries = append(entries, fileEntry{path: path, data: data})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path })
var buf bytes.Buffer
if s.Origin != "" {
writeField(&buf, "Origin", s.Origin)
}
if s.Label != "" {
writeField(&buf, "Label", s.Label)
}
writeField(&buf, "Suite", s.Suite)
codename := s.Codename
if codename == "" {
codename = s.Suite
}
writeField(&buf, "Codename", codename)
writeField(&buf, "Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 MST"))
if s.Description != "" {
writeField(&buf, "Description", s.Description)
}
if len(arches) > 0 {
writeField(&buf, "Architectures", strings.Join(arches, " "))
}
if len(components) > 0 {
writeField(&buf, "Components", strings.Join(components, " "))
}
writeChecksumBlock(&buf, "MD5Sum", entries, func(e fileEntry) string { return e.md5() })
writeChecksumBlock(&buf, "SHA1", entries, func(e fileEntry) string { return e.sha1() })
writeChecksumBlock(&buf, "SHA256", entries, func(e fileEntry) string { return e.sha256() })
return buf.Bytes(), nil
}
func writeChecksumBlock(buf *bytes.Buffer, name string, entries []fileEntry, hashFn func(fileEntry) string) {
buf.WriteString(name + ":\n")
for _, e := range entries {
fmt.Fprintf(buf, " %s %16d %s\n", hashFn(e), len(e.data), e.path)
}
}
func writeField(buf *bytes.Buffer, key, val string) {
if val == "" {
return
}
fmt.Fprintf(buf, "%s: %s\n", key, val)
}
func writeFieldInt(buf *bytes.Buffer, key string, val int64) {
fmt.Fprintf(buf, "%s: %d\n", key, val)
}
func gzipBytes(data []byte) []byte {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
_, _ = gz.Write(data)
_ = gz.Close()
return buf.Bytes()
}
func xzBytes(data []byte) ([]byte, error) {
var buf bytes.Buffer
xw, err := xz.NewWriter(&buf)
if err != nil {
return nil, err
}
if _, err := xw.Write(data); err != nil {
_ = xw.Close()
return nil, err
}
if err := xw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func dedupSorted(in []string) []string {
seen := map[string]bool{}
var out []string
for _, v := range in {
if v == "" || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
sort.Strings(out)
return out
}
+130
View File
@@ -0,0 +1,130 @@
package apt
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"urapt/shared/gpg"
)
func TestGenerateIndices(t *testing.T) {
key, err := gpg.GenerateKey("urapt-test <test.example.com>", 2048)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
suite := &Suite{
Origin: "urapt myrepo",
Label: "urapt myrepo",
Suite: "stable",
Description: "my repo",
Components: []string{"main", "contrib"},
Architectures: []string{"amd64", "arm64"},
Packages: []PackageRow{
{
Component: "main", Name: "foo", Version: "1.0", Architecture: "amd64",
PoolPath: "pool/main/f/foo/foo_1.0_amd64.deb", Size: 1234,
MD5sum: "aa", SHA1: "bb", SHA256: "cc", DescriptionMD5: "dd",
RawControl: "Package: foo\nVersion: 1.0\nArchitecture: amd64\nDescription: short\n",
},
{
Component: "main", Name: "bar", Version: "2.0", Architecture: "all",
PoolPath: "pool/main/b/bar/bar_2.0_all.deb", Size: 5678,
MD5sum: "ee", SHA1: "ff", SHA256: "11", DescriptionMD5: "22",
RawControl: "Package: bar\nVersion: 2.0\nArchitecture: all\nDescription: bar short\n",
},
},
}
idx, err := Generate(suite, key)
if err != nil {
t.Fatalf("Generate: %v", err)
}
// amd64 index should contain both foo (amd64) and bar (all).
pkg := idx.Packages["main/binary-amd64/Packages"]
if pkg == nil {
t.Fatal("missing amd64 Packages")
}
if !bytes.Contains(pkg, []byte("Package: foo")) || !bytes.Contains(pkg, []byte("Package: bar")) {
t.Fatalf("amd64 index missing entries:\n%s", pkg)
}
// bar should appear in arm64 too (arch=all).
arm := idx.Packages["main/binary-arm64/Packages"]
if !bytes.Contains(arm, []byte("Package: bar")) {
t.Fatalf("arm64 index missing all-arch bar:\n%s", arm)
}
if bytes.Contains(arm, []byte("Package: foo")) {
t.Fatalf("arm64 index should not contain amd64 foo:\n%s", arm)
}
// contrib indices should be empty bodies but present.
if idx.Packages["contrib/binary-amd64/Packages"] == nil {
t.Fatal("missing contrib amd64 index")
}
// Verify file fields appended.
if !bytes.Contains(pkg, []byte("Filename: pool/main/f/foo/foo_1.0_amd64.deb")) {
t.Fatalf("Packages missing Filename:\n%s", pkg)
}
if !bytes.Contains(pkg, []byte("SHA256: cc")) {
t.Fatalf("Packages missing SHA256:\n%s", pkg)
}
if !bytes.Contains(pkg, []byte("Description-md5: dd")) {
t.Fatalf("Packages missing Description-md5:\n%s", pkg)
}
// Release file should list components, architectures, and checksums.
rel := idx.Release
if !bytes.Contains(rel, []byte("Suite: stable")) {
t.Fatalf("Release missing Suite:\n%s", rel)
}
if !bytes.Contains(rel, []byte("Components: contrib main")) {
t.Fatalf("Release missing Components:\n%s", rel)
}
if !bytes.Contains(rel, []byte("Architectures: amd64 arm64")) {
t.Fatalf("Release missing Architectures:\n%s", rel)
}
if !bytes.Contains(rel, []byte("SHA256:")) {
t.Fatalf("Release missing SHA256 block:\n%s", rel)
}
if !bytes.Contains(rel, []byte("main/binary-amd64/Packages")) {
t.Fatalf("Release missing index path:\n%s", rel)
}
// InRelease must be a clearsigned block.
if !bytes.Contains(idx.InRelease, []byte("BEGIN PGP SIGNED MESSAGE")) {
t.Fatalf("InRelease not clearsigned:\n%s", idx.InRelease)
}
// Release.gpg must be an armored detached signature.
if !bytes.Contains(idx.ReleaseGpg, []byte("BEGIN PGP SIGNATURE")) {
t.Fatalf("Release.gpg not armored sig:\n%s", idx.ReleaseGpg)
}
// Verify the clearsign and detached signatures with the public key.
pub, err := key.ArmoredPublic()
if err != nil {
t.Fatalf("ArmoredPublic: %v", err)
}
if _, err := gpg.VerifyClearSign(pub, idx.InRelease); err != nil {
t.Fatalf("verify InRelease: %v", err)
}
if err := gpg.VerifyDetached(pub, idx.Release, idx.ReleaseGpg); err != nil {
t.Fatalf("verify Release.gpg: %v", err)
}
// Checksum correctness: the Release SHA256 entry for the Packages file must
// match the bytes we generated.
want := sha256hex(pkg)
if !bytes.Contains(rel, []byte(" "+want)) {
t.Fatalf("Release missing correct Packages sha256 %s:\n%s", want, rel)
}
_ = strings.Repeat
}
func sha256hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
+28
View File
@@ -0,0 +1,28 @@
package apt
import "strings"
// PoolPath computes the conventional Debian pool path for a package:
// pool/<component>/<letter>/<src>/<filename>, where <src> is the source package
// name (or the binary package name if absent) and <letter> follows the Debian
// "libX" convention.
func PoolPath(component, source, packageName, filename string) string {
src := source
if src == "" {
src = packageName
}
letter := poolLetter(src)
return strings.Join([]string{"pool", component, letter, src, filename}, "/")
}
// poolLetter returns the pool subdirectory prefix for a source name: "lib" +
// next char for names starting with "lib", otherwise the first character.
func poolLetter(src string) string {
if len(src) >= 4 && strings.HasPrefix(src, "lib") {
return "lib" + string(src[3])
}
if src == "" {
return "0"
}
return string(src[0])
}
+221
View File
@@ -0,0 +1,221 @@
// Package config defines the urapt-server configuration and its loading from
// defaults, a TOML file, environment variables, and command-line flags, with
// later sources overriding earlier ones.
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
// Defaults applied before any other source.
var Defaults = Config{
Bind: "0.0.0.0:8080",
BaseURL: "http://localhost:8080",
StoreDir: "./store",
LogLevel: "info",
SigningKeyType: "rsa",
SigningKeyBits: 4096,
MaxPackageSize: 1024 * 1024 * 1024,
OpenRegistration: true,
ConfigPath: "./urapt-server.toml",
}
// Config is the urapt-server runtime configuration.
type Config struct {
Bind string `toml:"bind"`
BaseURL string `toml:"base_url"`
StoreDir string `toml:"store_dir"`
DBPath string `toml:"db_path"`
PackagesDir string `toml:"packages_dir"`
LogLevel string `toml:"log_level"`
SigningKeyType string `toml:"signing_key_type"`
SigningKeyBits int `toml:"signing_key_bits"`
SigningKeyUserID string `toml:"signing_key_user_id"`
MaxPackageSize int64 `toml:"max_package_size"`
OpenRegistration bool `toml:"open_registration"`
TLSEnabled bool `toml:"tls_enabled"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
ConfigPath string `toml:"-"`
}
// Load builds the effective Config from Defaults -> file -> env -> flags.
// Flags is a map of flag name to string value (already parsed by the caller).
func Load(configPath string, flags map[string]string) (Config, error) {
c := Defaults
c.ConfigPath = configPath
if err := applyFile(&c, configPath); err != nil {
return Config{}, err
}
applyEnv(&c)
if err := applyFlags(&c, flags); err != nil {
return Config{}, err
}
c.finalize()
return c, nil
}
func applyFile(c *Config, path string) error {
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read config %s: %w", path, err)
}
if err := toml.Unmarshal(data, c); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}
func applyEnv(c *Config) {
set(c, "URAPT_BIND", &c.Bind)
set(c, "URAPT_BASE_URL", &c.BaseURL)
set(c, "URAPT_STORE_DIR", &c.StoreDir)
set(c, "URAPT_DB_PATH", &c.DBPath)
set(c, "URAPT_PACKAGES_DIR", &c.PackagesDir)
set(c, "URAPT_LOG_LEVEL", &c.LogLevel)
set(c, "URAPT_SIGNING_KEY_TYPE", &c.SigningKeyType)
set(c, "URAPT_SIGNING_KEY_USER_ID", &c.SigningKeyUserID)
set(c, "URAPT_TLS_CERT", &c.TLSCert)
set(c, "URAPT_TLS_KEY", &c.TLSKey)
setInt(c, "URAPT_SIGNING_KEY_BITS", &c.SigningKeyBits)
setInt64(c, "URAPT_MAX_PACKAGE_SIZE", &c.MaxPackageSize)
setBool(c, "URAPT_OPEN_REGISTRATION", &c.OpenRegistration)
setBool(c, "URAPT_TLS_ENABLED", &c.TLSEnabled)
}
func applyFlags(c *Config, flags map[string]string) error {
for k, v := range flags {
switch k {
case "bind":
c.Bind = v
case "base-url":
c.BaseURL = v
case "store-dir":
c.StoreDir = v
case "db-path":
c.DBPath = v
case "packages-dir":
c.PackagesDir = v
case "log-level":
c.LogLevel = v
case "signing-key-type":
c.SigningKeyType = v
case "signing-key-bits":
n, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid --signing-key-bits %q: %w", v, err)
}
c.SigningKeyBits = n
case "signing-key-user-id":
c.SigningKeyUserID = v
case "max-package-size":
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("invalid --max-package-size %q: %w", v, err)
}
c.MaxPackageSize = n
case "open-registration":
b, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("invalid --open-registration %q: %w", v, err)
}
c.OpenRegistration = b
case "tls-enabled":
b, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("invalid --tls-enabled %q: %w", v, err)
}
c.TLSEnabled = b
case "tls-cert":
c.TLSCert = v
case "tls-key":
c.TLSKey = v
case "config":
// already handled
}
}
return nil
}
// finalize fills derived defaults: DBPath and PackagesDir default under
// StoreDir, and SigningKeyUserID gets a hostname-based default.
func (c *Config) finalize() {
if c.DBPath == "" {
c.DBPath = filepath.Join(c.StoreDir, "database", "sqlite.db")
}
if c.PackagesDir == "" {
c.PackagesDir = filepath.Join(c.StoreDir, "packages")
}
if c.SigningKeyUserID == "" {
host, err := os.Hostname()
if err != nil || host == "" {
host = "localhost"
}
c.SigningKeyUserID = "urapt-server <" + host + ">"
}
if c.MaxPackageSize <= 0 {
c.MaxPackageSize = Defaults.MaxPackageSize
}
}
// Validate checks the config for obvious errors before startup.
func (c *Config) Validate() error {
if c.Bind == "" {
return fmt.Errorf("bind address is required")
}
if c.BaseURL == "" {
return fmt.Errorf("base_url is required")
}
c.BaseURL = strings.TrimRight(c.BaseURL, "/")
if c.SigningKeyBits <= 0 {
return fmt.Errorf("signing_key_bits must be positive")
}
if c.TLSEnabled && (c.TLSCert == "" || c.TLSKey == "") {
return fmt.Errorf("tls_enabled requires tls_cert and tls_key")
}
return nil
}
func set(c *Config, env string, dst *string) {
if v, ok := os.LookupEnv(env); ok && v != "" {
*dst = v
}
}
func setInt(c *Config, env string, dst *int) {
if v, ok := os.LookupEnv(env); ok && v != "" {
if n, err := strconv.Atoi(v); err == nil {
*dst = n
}
}
}
func setInt64(c *Config, env string, dst *int64) {
if v, ok := os.LookupEnv(env); ok && v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
*dst = n
}
}
}
func setBool(c *Config, env string, dst *bool) {
if v, ok := os.LookupEnv(env); ok && v != "" {
if b, err := strconv.ParseBool(v); err == nil {
*dst = b
}
}
}
+51
View File
@@ -0,0 +1,51 @@
// Package crypto provides password hashing and API token generation utilities
// shared by the server and (for verification symmetry) tests.
package crypto
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"golang.org/x/crypto/bcrypt"
)
// TokenPrefix is the textual prefix attached to all urapt API tokens so they
// are easy to identify and not confused with other secrets.
const TokenPrefix = "urapt_"
// HashPassword returns a bcrypt hash of the given plaintext password.
func HashPassword(password string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return "", fmt.Errorf("bcrypt: %w", err)
}
return string(h), nil
}
// VerifyPassword reports whether password matches the stored bcrypt hash.
func VerifyPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// GenerateToken creates a new random API token (TokenPrefix + base64url of 32
// random bytes) and returns it together with its SHA-256 hash (for storage)
// and an 8-char display prefix.
func GenerateToken() (token, hash, prefix string, err error) {
raw := make([]byte, 32)
if _, err = rand.Read(raw); err != nil {
return "", "", "", fmt.Errorf("rand: %w", err)
}
body := base64.RawURLEncoding.EncodeToString(raw)
token = TokenPrefix + body
hash = HashToken(token)
prefix = token[:len(TokenPrefix)+8]
return token, hash, prefix, nil
}
// HashToken returns the SHA-256 hex digest of a token, for storage/lookup.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return fmt.Sprintf("%x", sum)
}
+43
View File
@@ -0,0 +1,43 @@
package crypto
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
h, err := HashPassword("hunter2")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !VerifyPassword(h, "hunter2") {
t.Fatal("expected verify to pass for correct password")
}
if VerifyPassword(h, "wrong") {
t.Fatal("expected verify to fail for wrong password")
}
}
func TestGenerateToken(t *testing.T) {
tok, hash, prefix, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if tok == "" || hash == "" || prefix == "" {
t.Fatal("empty token fields")
}
if len(tok) <= len(TokenPrefix) {
t.Fatal("token too short")
}
if tok[:len(TokenPrefix)] != TokenPrefix {
t.Fatalf("token missing prefix: %q", tok)
}
if HashToken(tok) != hash {
t.Fatal("HashToken does not match returned hash")
}
if prefix != tok[:len(TokenPrefix)+8] {
t.Fatalf("prefix %q != expected", prefix)
}
tok2, _, _, _ := GenerateToken()
if tok == tok2 {
t.Fatal("expected distinct tokens")
}
}
+128
View File
@@ -0,0 +1,128 @@
// Package db opens the urapt SQLite database (pure-Go modernc driver, no CGO),
// enables WAL mode and foreign keys, and applies embedded SQL migrations.
package db
import (
"context"
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
)
//go:embed all:migrations
var migrationsFS embed.FS
// Open opens (or creates) the SQLite database at path, applies pragmas and
// pending migrations, and returns the *sql.DB. The parent directory is created
// if missing.
func Open(path string) (*sql.DB, error) {
dir := filepath.Dir(path)
if err := mkdirAll(dir); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1) // SQLite serial writers; reads still concurrent under WAL via SetMaxOpenConns handling
if err := db.PingContext(context.Background()); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := Migrate(context.Background(), db); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
// Migrate applies any embedded SQL migrations not yet recorded in
// schema_migrations.
func Migrate(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("ensure migrations table: %w", err)
}
names, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
var files []string
for _, e := range names {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, f := range files {
version, err := migrationVersion(f)
if err != nil {
return fmt.Errorf("parse migration name %s: %w", f, err)
}
var applied int
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if applied > 0 {
continue
}
data, err := migrationsFS.ReadFile("migrations/" + f)
if err != nil {
return fmt.Errorf("read migration %s: %w", f, err)
}
if _, err := db.ExecContext(ctx, string(data)); err != nil {
return fmt.Errorf("apply migration %s: %w", f, err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, version, nowISO()); err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
}
return nil
}
// nowISO returns the current UTC time in RFC3339 form.
func nowISO() string {
return time.Now().UTC().Format(time.RFC3339Nano)
}
// migrationVersion extracts the leading numeric component of a migration
// filename such as "0001_init.sql" -> 1.
func migrationVersion(name string) (int, error) {
name = strings.TrimSuffix(name, ".sql")
var num string
for _, r := range name {
if r >= '0' && r <= '9' {
num += string(r)
continue
}
break
}
if num == "" {
return 0, fmt.Errorf("no leading digits in %q", name)
}
return strconv.Atoi(num)
}
func mkdirAll(dir string) error {
if dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
+51
View File
@@ -0,0 +1,51 @@
package db
import (
"context"
"path/filepath"
"testing"
)
func TestOpenAndMigrate(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
var n int
err = db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM sqlite_master WHERE type='table'`).Scan(&n)
if err != nil {
t.Fatalf("query tables: %v", err)
}
if n < 10 {
t.Fatalf("expected at least 10 tables, got %d", n)
}
var v int
err = db.QueryRowContext(context.Background(),
`SELECT version FROM schema_migrations WHERE version=1`).Scan(&v)
if err != nil {
t.Fatalf("migration not recorded: %v", err)
}
if v != 1 {
t.Fatalf("expected version 1, got %d", v)
}
}
func TestMigrateIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("Open first: %v", err)
}
db.Close()
db2, err := Open(path)
if err != nil {
t.Fatalf("Open second: %v", err)
}
defer db2.Close()
}
+144
View File
@@ -0,0 +1,144 @@
-- 0001_init.sql: initial urapt schema.
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
username_lc TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
prefix TEXT NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
CREATE TABLE repositories (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
visibility TEXT NOT NULL CHECK (visibility IN ('public','private')),
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE repository_members (
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access TEXT NOT NULL CHECK (access IN ('read','write','read-write','admin')),
created_at TEXT NOT NULL,
PRIMARY KEY (repository_id, user_id)
);
CREATE TABLE distributions (
id TEXT PRIMARY KEY,
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (repository_id, name)
);
CREATE TABLE components (
id TEXT PRIMARY KEY,
distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (distribution_id, name)
);
CREATE TABLE architectures (
id TEXT PRIMARY KEY,
distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (distribution_id, name)
);
CREATE TABLE packages (
id TEXT PRIMARY KEY,
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT,
distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE,
component_id TEXT NOT NULL REFERENCES components(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
version TEXT NOT NULL,
architecture TEXT NOT NULL,
source TEXT,
maintainer TEXT,
priority TEXT,
section TEXT,
origin TEXT,
homepage TEXT,
description TEXT,
description_md5 TEXT,
depends TEXT,
pre_depends TEXT,
recommends TEXT,
suggests TEXT,
conflicts TEXT,
breaks TEXT,
provides TEXT,
replaces TEXT,
enhances TEXT,
installed_size INTEGER,
essential TEXT,
built_using TEXT,
tag TEXT,
raw_control TEXT NOT NULL,
filename TEXT NOT NULL,
pool_path TEXT NOT NULL,
size INTEGER NOT NULL,
md5sum TEXT NOT NULL,
sha1 TEXT NOT NULL,
sha256 TEXT NOT NULL,
uploaded_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL,
UNIQUE (repository_id, distribution_id, component_id, name, version, architecture)
);
CREATE INDEX idx_packages_lookup ON packages(repository_id, distribution_id, component_id, name);
CREATE INDEX idx_packages_distro ON packages(repository_id, distribution_id);
CREATE INDEX idx_packages_arch ON packages(distribution_id, architecture);
CREATE INDEX idx_packages_sha256 ON packages(sha256);
CREATE TABLE blobs (
sha256 TEXT PRIMARY KEY,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
ref_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE gpg_keys (
id TEXT PRIMARY KEY,
fingerprint TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL,
public_key_armored TEXT NOT NULL,
private_key_armored TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
repository_id TEXT REFERENCES repositories(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target TEXT,
details TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_audit_repo ON audit_log(repository_id, created_at);
CREATE INDEX idx_audit_user ON audit_log(user_id, created_at);
+368
View File
@@ -0,0 +1,368 @@
// Package deb parses Debian .deb archives: it reads the ar container, locates
// and decompresses the control.tar.* member, parses the control stanza, and
// computes whole-file hashes/sizes. It performs no execution of package
// contents.
package deb
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
"unicode"
"github.com/klauspost/compress/zstd"
"github.com/ulikunitz/xz"
)
// Deb holds the parsed metadata of a .deb file.
type Deb struct {
Control Control
Size int64
MD5sum string
SHA1 string
SHA256 string
}
// Field is a single control header field, preserving original key casing.
type Field struct {
Key string
Value string
}
// Control is a parsed control stanza. Fields preserves insertion order;
// Lookup gives case-insensitive access by key. Raw is the original text.
type Control struct {
Fields []Field
Lookup map[string]string
Raw string
}
// Get returns the value of a field (case-insensitive), or "" if absent.
func (c Control) Get(key string) string {
if c.Lookup == nil {
return ""
}
return c.Lookup[strings.ToLower(key)]
}
// Inspect opens the .deb at path, computes whole-file hashes/size, and parses
// its control stanza.
func Inspect(path string) (*Deb, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open deb: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("stat deb: %w", err)
}
hMD5 := md5.New()
hSHA1 := sha1.New()
hSHA256 := sha256.New()
size := stat.Size()
if _, err := io.Copy(io.MultiWriter(hMD5, hSHA1, hSHA256), f); err != nil {
return nil, fmt.Errorf("hash deb: %w", err)
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("seek deb: %w", err)
}
control, err := readControlFromAR(f)
if err != nil {
return nil, err
}
return &Deb{
Control: control,
Size: size,
MD5sum: hex.EncodeToString(hMD5.Sum(nil)),
SHA1: hex.EncodeToString(hSHA1.Sum(nil)),
SHA256: hex.EncodeToString(hSHA256.Sum(nil)),
}, nil
}
// readControlFromAR reads an ar stream and returns the parsed control stanza.
func readControlFromAR(r io.Reader) (Control, error) {
members, err := readAR(r)
if err != nil {
return Control{}, err
}
var debianBinary []byte
var controlTar []byte
var controlTarName string
for _, m := range members {
switch {
case m.Name == "debian-binary":
debianBinary = m.Data
case strings.HasPrefix(m.Name, "control.tar"):
controlTar = m.Data
controlTarName = m.Name
}
}
if debianBinary == nil {
return Control{}, fmt.Errorf("missing debian-binary member")
}
if !bytes.HasPrefix(bytes.TrimSpace(debianBinary), []byte("2.0")) {
return Control{}, fmt.Errorf("unsupported deb format (expected 2.0)")
}
if controlTar == nil {
return Control{}, fmt.Errorf("missing control.tar member")
}
decompressed, err := decompressMember(controlTarName, controlTar)
if err != nil {
return Control{}, err
}
return parseControlTar(decompressed)
}
// arMember is a single file within an ar archive.
type arMember struct {
Name string
Data []byte
}
// readAR parses a Unix ar archive (the variant used by .deb: GNU/SysV style,
// with member names terminated by '/' and no long-name index needed for the
// short standard member names).
func readAR(r io.Reader) ([]arMember, error) {
br := newBlockReader(r)
magic := make([]byte, 8)
if _, err := io.ReadFull(br, magic); err != nil {
return nil, fmt.Errorf("read ar magic: %w", err)
}
if string(magic) != "!<arch>\n" {
return nil, fmt.Errorf("not an ar archive (bad magic)")
}
var members []arMember
for {
header := make([]byte, 60)
n, err := io.ReadFull(br, header)
if err == io.EOF || (err == io.ErrUnexpectedEOF && n == 0) {
break
}
if err != nil {
return nil, fmt.Errorf("read ar header: %w", err)
}
if string(header[58:60]) != "`\n" {
return nil, fmt.Errorf("bad ar header terminator")
}
name := strings.TrimSpace(strings.TrimRight(string(header[0:16]), " "))
name = strings.TrimSuffix(name, "/")
sizeStr := strings.TrimSpace(string(header[48:58]))
var size int64
fmt.Sscanf(sizeStr, "%d", &size)
if size < 0 {
return nil, fmt.Errorf("negative ar member size")
}
data := make([]byte, size)
if _, err := io.ReadFull(br, data); err != nil {
return nil, fmt.Errorf("read ar member %q: %w", name, err)
}
members = append(members, arMember{Name: name, Data: data})
if size%2 == 1 {
pad := make([]byte, 1)
if _, err := io.ReadFull(br, pad); err != nil {
return nil, fmt.Errorf("read ar padding: %w", err)
}
}
}
return members, nil
}
// blockReader is a thin wrapper that ensures io.ReadFull semantics work on any
// io.Reader (it just forwards reads).
type blockReader struct {
r io.Reader
}
func newBlockReader(r io.Reader) *blockReader { return &blockReader{r: r} }
func (b *blockReader) Read(p []byte) (int, error) { return b.r.Read(p) }
// decompressMember decompresses a control.tar.* member based on its name
// extension.
func decompressMember(name string, data []byte) ([]byte, error) {
switch {
case strings.HasSuffix(name, ".gz"):
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
return io.ReadAll(gz)
case strings.HasSuffix(name, ".xz"):
xr, err := xz.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("xz: %w", err)
}
return io.ReadAll(xr)
case strings.HasSuffix(name, ".zst"):
zr, err := zstd.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("zstd: %w", err)
}
defer zr.Close()
return io.ReadAll(zr)
case strings.HasSuffix(name, ".tar"):
return data, nil
default:
return nil, fmt.Errorf("unknown control.tar compression: %s", name)
}
}
// parseControlTar reads a tar stream and returns the first control stanza
// found in a file named "control" or "./control".
func parseControlTar(tarData []byte) (Control, error) {
tr := tar.NewReader(bytes.NewReader(tarData))
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return Control{}, fmt.Errorf("read tar: %w", err)
}
name := strings.TrimPrefix(hdr.Name, "./")
if name == "control" {
body, err := io.ReadAll(tr)
if err != nil {
return Control{}, fmt.Errorf("read control: %w", err)
}
return ParseControl(bytes.NewReader(body))
}
}
return Control{}, fmt.Errorf("control file not found in control.tar")
}
// ParseControl parses a single RFC822-style control stanza. Continuation
// lines (starting with a space or tab) are appended to the previous field's
// value with the leading whitespace preserved as a single space.
func ParseControl(r io.Reader) (Control, error) {
data, err := io.ReadAll(r)
if err != nil {
return Control{}, fmt.Errorf("read control: %w", err)
}
raw := strings.TrimRight(string(data), "\n")
c := Control{Lookup: map[string]string{}, Raw: raw}
var curKey, curVal string
flush := func() {
if curKey == "" {
return
}
c.Fields = append(c.Fields, Field{Key: curKey, Value: curVal})
c.Lookup[strings.ToLower(curKey)] = curVal
curKey, curVal = "", ""
}
for _, line := range strings.Split(raw, "\n") {
if line == "" {
flush()
continue
}
if line[0] == ' ' || line[0] == '\t' {
if curKey == "" {
return Control{}, fmt.Errorf("continuation line with no field")
}
// Debian control continuation: strip exactly one leading space,
// and a line that is just "." represents a blank line.
body := line
if body[0] == ' ' {
body = body[1:]
} else {
body = strings.TrimLeft(body, " \t")
}
if body == "." {
curVal += "\n"
} else {
curVal += "\n" + strings.TrimRight(body, " \t\r")
}
continue
}
colon := strings.IndexByte(line, ':')
if colon < 0 {
return Control{}, fmt.Errorf("malformed control line: %q", line)
}
flush()
curKey = strings.TrimSpace(line[:colon])
curVal = strings.TrimSpace(line[colon+1:])
}
flush()
if len(c.Fields) == 0 {
return Control{}, fmt.Errorf("empty control stanza")
}
return c, nil
}
// ShortDescription returns the short summary (the first line of Description).
func (c Control) ShortDescription() string {
desc := c.Get("Description")
if desc == "" {
return ""
}
if i := strings.IndexByte(desc, '\n'); i >= 0 {
return desc[:i]
}
return desc
}
// LongDescription returns the extended description (everything after the short
// summary line).
func (c Control) LongDescription() string {
desc := c.Get("Description")
if desc == "" {
return ""
}
if i := strings.IndexByte(desc, '\n'); i >= 0 {
return strings.TrimLeft(desc[i+1:], "\n")
}
return ""
}
// DescriptionMD5 returns the MD5 hex digest of the long description, matching
// Debian's Description-md5 Packages field.
func (c Control) DescriptionMD5() string {
sum := md5.Sum([]byte(c.LongDescription()))
return hex.EncodeToString(sum[:])
}
// IsControlFieldName reports whether s looks like a valid control field name
// (non-empty, colon-free, starts with a non-space, ASCII letters/digits/-).
func IsControlFieldName(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r >= 'a' && r <= 'z' {
continue
}
if r >= 'A' && r <= 'Z' {
continue
}
if r >= '0' && r <= '9' {
continue
}
if r == '-' {
continue
}
return false
}
return true
}
// ensure unicode is referenced (reserved for stricter validation later).
var _ = unicode.IsLetter
+173
View File
@@ -0,0 +1,173 @@
package deb
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"strings"
"testing"
)
// makeTestDeb builds a minimal valid .deb at path with the given control
// stanza text.
func makeTestDeb(t *testing.T, path, controlText string) {
t.Helper()
controlTar := buildControlTar(t, controlText)
dataTar := buildDataTar(t)
deb := buildAr(t, controlTar, dataTar)
if err := os.WriteFile(path, deb, 0o644); err != nil {
t.Fatalf("write deb: %v", err)
}
}
func buildControlTar(t *testing.T, controlText string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
addFile(t, tw, "control", controlText)
if err := tw.Close(); err != nil {
t.Fatalf("close control tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close control gz: %v", err)
}
return buf.Bytes()
}
func buildDataTar(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
addFile(t, tw, "usr/share/doc/foo/README", "readme\n")
if err := tw.Close(); err != nil {
t.Fatalf("close data tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close data gz: %v", err)
}
return buf.Bytes()
}
func addFile(t *testing.T, tw *tar.Writer, name, body string) {
t.Helper()
if err := tw.WriteHeader(&tar.Header{
Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
}); err != nil {
t.Fatalf("write tar header %s: %v", name, err)
}
if _, err := tw.Write([]byte(body)); err != nil {
t.Fatalf("write tar body %s: %v", name, err)
}
}
func buildAr(t *testing.T, controlTar, dataTar []byte) []byte {
t.Helper()
var buf bytes.Buffer
buf.WriteString("!<arch>\n")
writeArMember(&buf, "debian-binary", []byte("2.0\n"))
writeArMember(&buf, "control.tar.gz", controlTar)
writeArMember(&buf, "data.tar.gz", dataTar)
return buf.Bytes()
}
func writeArMember(buf *bytes.Buffer, name string, data []byte) {
header := make([]byte, 60)
for i := range header {
header[i] = ' '
}
copy(header[0:], name+"/")
copy(header[48:], []byte(padLeft(len(data), 10)))
header[58] = '`'
header[59] = '\n'
buf.Write(header)
buf.Write(data)
if len(data)%2 == 1 {
buf.WriteByte('\n')
}
}
func padLeft(n, width int) string {
s := []byte(strings.Repeat(" ", width))
v := []byte(itoa(n))
copy(s[len(s)-len(v):], v)
return string(s)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func TestInspect(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "foo_1.0_amd64.deb")
controlText := `Package: foo
Version: 1.0
Architecture: amd64
Maintainer: Test <test@example.com>
Installed-Size: 42
Depends: libc6 (>= 2.31), bash | dash
Section: utils
Priority: optional
Homepage: https://example.com
Description: short summary
extended description line one
.
second paragraph.
`
makeTestDeb(t, path, controlText)
d, err := Inspect(path)
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if d.Control.Get("Package") != "foo" {
t.Fatalf("Package = %q", d.Control.Get("Package"))
}
if d.Control.Get("Version") != "1.0" {
t.Fatalf("Version = %q", d.Control.Get("Version"))
}
if d.Control.Get("Architecture") != "amd64" {
t.Fatalf("Architecture = %q", d.Control.Get("Architecture"))
}
if d.Control.Get("Depends") != "libc6 (>= 2.31), bash | dash" {
t.Fatalf("Depends = %q", d.Control.Get("Depends"))
}
if d.Size <= 0 {
t.Fatalf("Size = %d", d.Size)
}
if d.MD5sum == "" || d.SHA1 == "" || d.SHA256 == "" {
t.Fatalf("hashes empty: md5=%s sha1=%s sha256=%s", d.MD5sum, d.SHA1, d.SHA256)
}
if d.Control.ShortDescription() != "short summary" {
t.Fatalf("short = %q", d.Control.ShortDescription())
}
long := d.Control.LongDescription()
if !strings.Contains(long, "extended description line one") || !strings.Contains(long, "second paragraph.") {
t.Fatalf("long = %q", long)
}
if d.Control.DescriptionMD5() == "" {
t.Fatal("empty description md5")
}
}
func TestParseControlContinuation(t *testing.T) {
c, err := ParseControl(strings.NewReader("Package: bar\nVersion: 1\nDescription: short\n long\n .\n more\n"))
if err != nil {
t.Fatalf("ParseControl: %v", err)
}
if c.Get("Description") != "short\nlong\n\nmore" {
t.Fatalf("Description = %q", c.Get("Description"))
}
}
+214
View File
@@ -0,0 +1,214 @@
// Package gpg provides server-managed OpenPGP signing: key generation,
// armored export/import, clearsigning (for InRelease), and detached signing
// (for Release.gpg). It uses the pure-Go ProtonMail/go-crypto library so the
// server has no runtime dependency on the gpg binary.
package gpg
import (
"bytes"
"crypto"
"fmt"
"io"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
"github.com/ProtonMail/go-crypto/openpgp/packet"
)
// Key wraps an OpenPGP entity together with metadata urapt uses.
type Key struct {
Entity *openpgp.Entity
Fingerprint string
UserID string
}
// GenerateKey creates a new RSA signing key with the given user-id (in the form
// "Name <email>" or a plain name) and key size in bits.
func GenerateKey(userID string, bits int) (*Key, error) {
if bits <= 0 {
bits = 4096
}
name, email := splitUserID(userID)
cfg := &packet.Config{
RSABits: bits,
DefaultHash: crypto.SHA256,
V6Keys: false,
}
entity, err := openpgp.NewEntity(name, "", email, cfg)
if err != nil {
return nil, fmt.Errorf("new entity: %w", err)
}
return &Key{
Entity: entity,
Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint),
UserID: userID,
}, nil
}
// ParseArmoredPrivate decodes an ASCII-armored private key produced by
// ArmoredPrivate.
func ParseArmoredPrivate(armored string) (*Key, error) {
block, err := armor.Decode(strings.NewReader(armored))
if err != nil {
return nil, fmt.Errorf("decode armor: %w", err)
}
if block.Type != "PGP PRIVATE KEY BLOCK" {
return nil, fmt.Errorf("unexpected armor type %q", block.Type)
}
entity, err := openpgp.ReadEntity(packet.NewReader(block.Body))
if err != nil {
return nil, fmt.Errorf("read entity: %w", err)
}
uid := ""
if id := entity.PrimaryIdentity(); id != nil {
uid = id.Name
}
return &Key{
Entity: entity,
Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint),
UserID: uid,
}, nil
}
// ArmoredPublic returns the ASCII-armored public key.
func (k *Key) ArmoredPublic() (string, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
if err != nil {
return "", fmt.Errorf("armor encode: %w", err)
}
if err := k.Entity.Serialize(w); err != nil {
_ = w.Close()
return "", fmt.Errorf("serialize public: %w", err)
}
if err := w.Close(); err != nil {
return "", fmt.Errorf("close armor: %w", err)
}
return buf.String(), nil
}
// ArmoredPrivate returns the ASCII-armored private key (unencrypted).
func (k *Key) ArmoredPrivate() (string, error) {
var buf bytes.Buffer
w, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil)
if err != nil {
return "", fmt.Errorf("armor encode: %w", err)
}
if err := k.Entity.SerializePrivate(w, nil); err != nil {
_ = w.Close()
return "", fmt.Errorf("serialize private: %w", err)
}
if err := w.Close(); err != nil {
return "", fmt.Errorf("close armor: %w", err)
}
return buf.String(), nil
}
// ClearSign produces a clearsigned message (used for the InRelease file).
func (k *Key) ClearSign(data []byte) ([]byte, error) {
sk, ok := k.Entity.SigningKey(time.Now())
if !ok {
return nil, fmt.Errorf("no signing key available")
}
var out bytes.Buffer
cfg := &packet.Config{DefaultHash: crypto.SHA256}
plaintext, err := clearsign.Encode(&out, sk.PrivateKey, cfg)
if err != nil {
return nil, fmt.Errorf("clearsign encode: %w", err)
}
if _, err := plaintext.Write(data); err != nil {
_ = plaintext.Close()
return nil, fmt.Errorf("write clearsign: %w", err)
}
if err := plaintext.Close(); err != nil {
return nil, fmt.Errorf("close clearsign: %w", err)
}
return out.Bytes(), nil
}
// DetachedSign produces an ASCII-armored detached signature of data (used for
// Release.gpg).
func (k *Key) DetachedSign(data []byte) ([]byte, error) {
var out bytes.Buffer
cfg := &packet.Config{DefaultHash: crypto.SHA256}
if err := openpgp.ArmoredDetachSign(&out, k.Entity, bytes.NewReader(data), cfg); err != nil {
return nil, fmt.Errorf("detach sign: %w", err)
}
return out.Bytes(), nil
}
// VerifyClearSign is a test helper that verifies a clearsigned block and
// returns the plaintext.
func VerifyClearSign(armoredPublic string, clearsigned []byte) (plaintext []byte, err error) {
key, err := ParseArmoredPublic(armoredPublic)
if err != nil {
return nil, err
}
block, rest := clearsign.Decode(clearsigned)
if block == nil {
return nil, fmt.Errorf("no clearsign block (rest=%d bytes)", len(rest))
}
keyring := openpgp.EntityList{key.Entity}
if _, err := block.VerifySignature(keyring, nil); err != nil {
return nil, fmt.Errorf("verify: %w", err)
}
return block.Bytes, nil
}
// VerifyDetached verifies an armored detached signature of data using the
// given armored public key. Test helper.
func VerifyDetached(armoredPublic string, data, armoredSig []byte) error {
key, err := ParseArmoredPublic(armoredPublic)
if err != nil {
return err
}
keyring := openpgp.EntityList{key.Entity}
if _, err := openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(armoredSig), nil); err != nil {
return fmt.Errorf("verify: %w", err)
}
return nil
}
// ParseArmoredPublic decodes an ASCII-armored public key.
func ParseArmoredPublic(armored string) (*Key, error) {
block, err := armor.Decode(strings.NewReader(armored))
if err != nil {
return nil, fmt.Errorf("decode armor: %w", err)
}
if block.Type != "PGP PUBLIC KEY BLOCK" {
return nil, fmt.Errorf("unexpected armor type %q", block.Type)
}
entity, err := openpgp.ReadEntity(packet.NewReader(block.Body))
if err != nil {
return nil, fmt.Errorf("read entity: %w", err)
}
uid := ""
if id := entity.PrimaryIdentity(); id != nil {
uid = id.Name
}
return &Key{
Entity: entity,
Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint),
UserID: uid,
}, nil
}
// splitUserID parses a user-id of the form "Name <email>" into name and email.
// If no email brackets are present, the whole string is treated as the name.
func splitUserID(userID string) (name, email string) {
userID = strings.TrimSpace(userID)
i := strings.LastIndexByte(userID, '<')
j := strings.LastIndexByte(userID, '>')
if i >= 0 && j > i {
name = strings.TrimSpace(userID[:i])
email = strings.TrimSpace(userID[i+1 : j])
return name, email
}
return userID, ""
}
// ensure io is referenced (used implicitly by armor/clearsign APIs).
var _ = io.EOF
+68
View File
@@ -0,0 +1,68 @@
package gpg
import (
"bytes"
"strings"
"testing"
)
func TestGenerateAndSign(t *testing.T) {
k, err := GenerateKey("urapt-server <test.example.com>", 2048)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if k.Fingerprint == "" {
t.Fatal("empty fingerprint")
}
pub, err := k.ArmoredPublic()
if err != nil {
t.Fatalf("ArmoredPublic: %v", err)
}
if !bytes.Contains([]byte(pub), []byte("BEGIN PGP PUBLIC KEY BLOCK")) {
t.Fatal("bad armored public")
}
priv, err := k.ArmoredPrivate()
if err != nil {
t.Fatalf("ArmoredPrivate: %v", err)
}
if !bytes.Contains([]byte(priv), []byte("BEGIN PGP PRIVATE KEY BLOCK")) {
t.Fatal("bad armored private")
}
data := []byte("Origin: urapt\nSuite: stable\n\nContents here.\n")
clear, err := k.ClearSign(data)
if err != nil {
t.Fatalf("ClearSign: %v", err)
}
if !bytes.Contains(clear, []byte("BEGIN PGP SIGNED MESSAGE")) {
t.Fatal("bad clearsign output")
}
pt, err := VerifyClearSign(pub, clear)
if err != nil {
t.Fatalf("VerifyClearSign: %v", err)
}
if strings.ReplaceAll(string(pt), "\r\n", "\n") != string(data) {
t.Fatalf("plaintext mismatch: got %q want %q", pt, data)
}
det, err := k.DetachedSign(data)
if err != nil {
t.Fatalf("DetachedSign: %v", err)
}
if !bytes.Contains(det, []byte("BEGIN PGP SIGNATURE")) {
t.Fatal("bad detached output")
}
if err := VerifyDetached(pub, data, det); err != nil {
t.Fatalf("VerifyDetached: %v", err)
}
k2, err := ParseArmoredPrivate(priv)
if err != nil {
t.Fatalf("ParseArmoredPrivate: %v", err)
}
if k2.Fingerprint != k.Fingerprint {
t.Fatalf("fingerprint mismatch after round-trip: %s != %s", k2.Fingerprint, k.Fingerprint)
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package httputil provides small helpers for JSON I/O, uniform error
// rendering, and parsing of Authorization headers shared across the REST API
// and the APT endpoint.
package httputil
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// APIError is the uniform JSON error body: {"error": {...}}.
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
// ErrorEnvelope wraps an APIError.
type ErrorEnvelope struct {
Error APIError `json:"error"`
}
// WriteJSON writes v as JSON with the given status code.
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if v == nil {
return
}
_ = json.NewEncoder(w).Encode(v)
}
// ReadJSON decodes r.Body into v. It limits the body to maxBytes.
func ReadJSON(r *http.Request, v any, maxBytes int64) error {
if maxBytes > 0 {
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
// WriteError renders a uniform error response.
func WriteError(w http.ResponseWriter, status int, code, message string, details ...any) {
e := APIError{Code: code, Message: message}
if len(details) > 0 {
e.Details = details[0]
}
WriteJSON(w, status, ErrorEnvelope{Error: e})
}
// WriteErrorf is WriteError with printf-style message formatting.
func WriteErrorf(w http.ResponseWriter, status int, code, format string, args ...any) {
WriteError(w, status, code, fmt.Sprintf(format, args...))
}
// Common error code constants.
const (
CodeBadRequest = "bad_request"
CodeUnauthorized = "unauthorized"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeConflict = "conflict"
CodePayloadTooLarge = "payload_too_large"
CodeInternal = "internal"
)
// ParseBearer extracts the token from an "Authorization: Bearer <token>"
// header. ok is false if the header is absent or malformed.
func ParseBearer(h http.Header) (token string, ok bool) {
v := h.Get("Authorization")
if v == "" {
return "", false
}
parts := strings.SplitN(v, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", false
}
t := strings.TrimSpace(parts[1])
if t == "" {
return "", false
}
return t, true
}
// ParseBasic extracts username/password from an "Authorization: Basic"
// header. ok is false if absent or malformed.
func ParseBasic(h http.Header) (username, password string, ok bool) {
v := h.Get("Authorization")
if v == "" {
return "", "", false
}
parts := strings.SplitN(v, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Basic") {
return "", "", false
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(parts[1]))
if err != nil {
return "", "", false
}
idx := strings.IndexByte(string(raw), ':')
if idx < 0 {
return "", "", false
}
return string(raw[:idx]), string(raw[idx+1:]), true
}
// ChallengeBasic writes a 401 with a WWW-Authenticate Basic challenge.
func ChallengeBasic(w http.ResponseWriter, realm string) {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q, charset="UTF-8"`, realm))
WriteError(w, http.StatusUnauthorized, CodeUnauthorized, "authentication required")
}
+35
View File
@@ -0,0 +1,35 @@
// Package log provides a thin structured logging wrapper over the standard
// library's log/slog package, with a configurable level.
package log
import (
"log/slog"
"os"
"strings"
)
// Level names accepted by ParseLevel.
const (
LevelDebug = "debug"
LevelInfo = "info"
LevelWarn = "warn"
LevelError = "error"
)
// New constructs a slog.Logger writing to stderr at the given level. Unknown
// levels fall back to info.
func New(level string) *slog.Logger {
var lv slog.Level
switch strings.ToLower(strings.TrimSpace(level)) {
case LevelDebug:
lv = slog.LevelDebug
case LevelWarn:
lv = slog.LevelWarn
case LevelError:
lv = slog.LevelError
default:
lv = slog.LevelInfo
}
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lv})
return slog.New(h)
}
+172
View File
@@ -0,0 +1,172 @@
// Package models defines the domain types used across urapt's database and API
// boundaries. These structs mirror the SQLite schema and are serialized into
// API DTOs by the restapi and apiclient packages.
package models
// User is an urapt account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// APIToken is a revocable authentication token. The plaintext token is only
// returned at creation/login time; only its hash and a short display prefix
// are persisted.
type APIToken struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Token string `json:"token,omitempty"` // plaintext, only on creation
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at,omitempty"`
RevokedAt *string `json:"revoked_at,omitempty"`
}
// Visibility is whether a repository is world-readable.
type Visibility string
const (
VisibilityPublic Visibility = "public"
VisibilityPrivate Visibility = "private"
)
// Access is a user's role on a repository.
type Access string
const (
AccessRead Access = "read"
AccessWrite Access = "write"
AccessReadWrite Access = "read-write"
AccessAdmin Access = "admin"
)
// ValidAccess reports whether s is a recognized access level.
func ValidAccess(s string) bool {
switch Access(s) {
case AccessRead, AccessWrite, AccessReadWrite, AccessAdmin:
return true
}
return false
}
// Repository is a named APT repository owned by a user.
type Repository struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerUserID string `json:"owner_user_id"`
Owner *User `json:"owner,omitempty"`
Visibility Visibility `json:"visibility"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// RepositoryMember is a user's access grant on a repository.
type RepositoryMember struct {
RepositoryID string `json:"repository_id"`
UserID string `json:"user_id"`
User *User `json:"user,omitempty"`
Access Access `json:"access"`
CreatedAt string `json:"created_at"`
}
// Distribution (suite) within a repository.
type Distribution struct {
ID string `json:"id"`
RepositoryID string `json:"repository_id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
// Component within a distribution (e.g. main, contrib).
type Component struct {
ID string `json:"id"`
DistributionID string `json:"distribution_id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
// Architecture configured for a distribution (e.g. amd64). The special
// architecture "all" is implicit and never stored.
type Architecture struct {
ID string `json:"id"`
DistributionID string `json:"distribution_id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
// Package is one uploaded .deb version and its extracted control metadata.
type Package struct {
ID string `json:"id"`
RepositoryID string `json:"repository_id"`
DistributionID string `json:"distribution_id"`
ComponentID string `json:"component_id"`
Name string `json:"name"`
Version string `json:"version"`
Architecture string `json:"architecture"`
Source string `json:"source,omitempty"`
Maintainer string `json:"maintainer,omitempty"`
Priority string `json:"priority,omitempty"`
Section string `json:"section,omitempty"`
Origin string `json:"origin,omitempty"`
Homepage string `json:"homepage,omitempty"`
Description string `json:"description,omitempty"`
DescriptionMD5 string `json:"description_md5,omitempty"`
Depends string `json:"depends,omitempty"`
PreDepends string `json:"pre_depends,omitempty"`
Recommends string `json:"recommends,omitempty"`
Suggests string `json:"suggests,omitempty"`
Conflicts string `json:"conflicts,omitempty"`
Breaks string `json:"breaks,omitempty"`
Provides string `json:"provides,omitempty"`
Replaces string `json:"replaces,omitempty"`
Enhances string `json:"enhances,omitempty"`
InstalledSize int64 `json:"installed_size,omitempty"`
Essential string `json:"essential,omitempty"`
BuiltUsing string `json:"built_using,omitempty"`
Tag string `json:"tag,omitempty"`
RawControl string `json:"raw_control"`
Filename string `json:"filename"`
PoolPath string `json:"pool_path"`
Size int64 `json:"size"`
MD5sum string `json:"md5sum"`
SHA1 string `json:"sha1"`
SHA256 string `json:"sha256"`
UploadedByUserID string `json:"uploaded_by_user_id"`
CreatedAt string `json:"created_at"`
}
// Blob is a content-addressed .deb file on disk, reference-counted for dedup.
type Blob struct {
SHA256 string `json:"sha256"`
Filename string `json:"filename"`
Size int64 `json:"size"`
RefCount int64 `json:"ref_count"`
CreatedAt string `json:"created_at"`
}
// GPGKey is a server-managed signing key.
type GPGKey struct {
ID string `json:"id"`
Fingerprint string `json:"fingerprint"`
UserID string `json:"user_id"`
PublicKeyArmored string `json:"public_key_armored"`
PrivateKeyArmored string `json:"-"` // never serialized in API responses
IsDefault bool `json:"is_default"`
CreatedAt string `json:"created_at"`
}
// AuditLogEntry is a best-effort record of a mutating action.
type AuditLogEntry struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
RepositoryID *string `json:"repository_id,omitempty"`
Action string `json:"action"`
Target string `json:"target"`
Details string `json:"details,omitempty"`
CreatedAt string `json:"created_at"`
}
+19
View File
@@ -0,0 +1,19 @@
// Package version holds build-time version information for urapt.
package version
import "runtime/debug"
// Version is the urapt build version. It is populated from VCS info when
// available, otherwise it falls back to "dev".
var Version = "dev"
func init() {
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
Version = s.Value
return
}
}
}
}