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.
134 lines
4.7 KiB
Go
134 lines
4.7 KiB
Go
// Package restapi implements the urapt REST API: handlers, routing, and
|
|
// request validation. It is mounted under /api/v1 on the server.
|
|
package restapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"regexp"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"urapt/server/auth"
|
|
"urapt/server/cache"
|
|
"urapt/server/middleware"
|
|
"urapt/server/store"
|
|
"urapt/shared/config"
|
|
"urapt/shared/httputil"
|
|
)
|
|
|
|
// SignerProvider returns the server's current signing key. It is supplied by
|
|
// the app; the APT endpoint also uses it to sign Release files.
|
|
type SignerProvider interface {
|
|
PublicKeyArmored() (string, error)
|
|
Fingerprint() string
|
|
}
|
|
|
|
// API holds the dependencies shared by all REST handlers.
|
|
type API struct {
|
|
Store *store.Store
|
|
Auth *auth.Service
|
|
Signer SignerProvider
|
|
Config *config.Config
|
|
Cache *cache.IndexCache
|
|
}
|
|
|
|
// New constructs the API and returns its http.Handler (the /api/v1 router).
|
|
func New(st *store.Store, authSvc *auth.Service, signer SignerProvider, cfg *config.Config, c *cache.IndexCache) http.Handler {
|
|
api := &API{Store: st, Auth: authSvc, Signer: signer, Config: cfg, Cache: c}
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recover)
|
|
r.Use(middleware.Log)
|
|
|
|
r.Get("/server/info", api.ServerInfo)
|
|
r.Get("/server/pubkey", api.ServerPubkey)
|
|
|
|
r.Post("/auth/register", api.Register)
|
|
r.Post("/auth/login", api.Login)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireBearer(authSvc))
|
|
|
|
r.Post("/auth/logout", api.Logout)
|
|
r.Get("/me", api.Me)
|
|
r.Get("/me/tokens", api.ListTokens)
|
|
r.Post("/me/tokens", api.CreateToken)
|
|
r.Delete("/me/tokens/{id}", api.RevokeToken)
|
|
|
|
// repositories (read for visible, write for permitted)
|
|
r.Get("/repositories", api.ListRepositories)
|
|
r.Post("/repositories", api.CreateRepository)
|
|
r.Get("/repositories/{repo}", api.GetRepository)
|
|
r.Patch("/repositories/{repo}", api.UpdateRepository)
|
|
r.Delete("/repositories/{repo}", api.DeleteRepository)
|
|
r.Get("/repositories/{repo}/members", api.ListMembers)
|
|
r.Post("/repositories/{repo}/members", api.AddMember)
|
|
r.Patch("/repositories/{repo}/members/{username}", api.UpdateMember)
|
|
r.Delete("/repositories/{repo}/members/{username}", api.RemoveMember)
|
|
r.Get("/repositories/{repo}/pubkey", api.RepoPubkey)
|
|
|
|
// structure
|
|
r.Get("/repositories/{repo}/distributions", api.ListDistributions)
|
|
r.Post("/repositories/{repo}/distributions", api.CreateDistribution)
|
|
r.Delete("/repositories/{repo}/distributions/{dist}", api.DeleteDistribution)
|
|
r.Get("/repositories/{repo}/distributions/{dist}/components", api.ListComponents)
|
|
r.Post("/repositories/{repo}/distributions/{dist}/components", api.CreateComponent)
|
|
r.Delete("/repositories/{repo}/distributions/{dist}/components/{comp}", api.DeleteComponent)
|
|
r.Get("/repositories/{repo}/distributions/{dist}/architectures", api.ListArchitectures)
|
|
r.Post("/repositories/{repo}/distributions/{dist}/architectures", api.CreateArchitecture)
|
|
r.Delete("/repositories/{repo}/distributions/{dist}/architectures/{arch}", api.DeleteArchitecture)
|
|
|
|
// packages
|
|
r.Get("/repositories/{repo}/distributions/{dist}/packages", api.ListPackages)
|
|
r.Post("/repositories/{repo}/distributions/{dist}/packages", api.PushPackage)
|
|
r.Get("/repositories/{repo}/packages/{id}", api.GetPackage)
|
|
r.Get("/repositories/{repo}/packages/{id}/file", api.GetPackageFile)
|
|
r.Delete("/repositories/{repo}/packages/{id}", api.DeletePackage)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(api.RequireAdmin)
|
|
r.Get("/users", api.ListUsers)
|
|
r.Get("/users/{id}", api.GetUser)
|
|
r.Patch("/users/{id}", api.UpdateUser)
|
|
r.Delete("/users/{id}", api.DeleteUser)
|
|
})
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
// RequireAdmin is middleware that requires the caller to be a server admin.
|
|
func (api *API) RequireAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := middleware.IdentityFromContext(r.Context())
|
|
if id == nil || !id.User.IsAdmin {
|
|
httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "admin privileges required")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// validation patterns.
|
|
var (
|
|
usernameRE = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
|
passwordRE = regexp.MustCompile(`^.{8,256}$`)
|
|
tokenNameRE = regexp.MustCompile(`^.{1,64}$`)
|
|
)
|
|
|
|
// validateUsername returns true if s is an acceptable username.
|
|
func validateUsername(s string) bool { return usernameRE.MatchString(s) }
|
|
|
|
// validatePassword returns true if s is an acceptable password.
|
|
func validatePassword(s string) bool { return passwordRE.MatchString(s) }
|
|
|
|
// bad renders a 400 validation error.
|
|
func bad(w http.ResponseWriter, msg string) {
|
|
httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, msg)
|
|
}
|
|
|
|
// contextKey for request-scoped values is not needed beyond middleware; this
|
|
// var keeps context imported if future handlers need it.
|
|
var _ = context.Background
|