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.
161 lines
4.1 KiB
Go
161 lines
4.1 KiB
Go
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] == '-'
|
|
}
|