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,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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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] == '-'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user