Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67cedf9bba | |||
| 8d9bc1c422 | |||
| 30b7cef026 | |||
| 603be5b989 | |||
| 9eba49500c | |||
| 0083d67272 | |||
| 8ec7de50e3 | |||
| 9c465cbd4c | |||
| ee6e581b9d | |||
| 2a8e544de3 | |||
| 847eeb839f |
@@ -8,6 +8,8 @@ steps:
|
||||
settings:
|
||||
registry: git.unkin.net
|
||||
repo: git.unkin.net/unkin/artifactapi
|
||||
build_args:
|
||||
VERSION: ${CI_COMMIT_TAG}
|
||||
username: droneci
|
||||
password:
|
||||
from_secret: DRONECI_PASSWORD
|
||||
@@ -22,6 +24,8 @@ steps:
|
||||
repo: git.unkin.net/unkin/artifactapi-ui
|
||||
dockerfile: ui/Dockerfile.ui
|
||||
context: ui
|
||||
build_args:
|
||||
BASE_PATH: /ui
|
||||
username: droneci
|
||||
password:
|
||||
from_secret: DRONECI_PASSWORD
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o artifactapi ./cmd/artifactapi
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o artifactapi ./cmd/artifactapi
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ check-go:
|
||||
fi
|
||||
|
||||
build: check-go tidy
|
||||
go build -ldflags="-s -w" -o $(BINARY) ./cmd/artifactapi
|
||||
go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) ./cmd/artifactapi
|
||||
|
||||
test: check-go
|
||||
go test -race -count=1 ./pkg/... ./internal/...
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"git.unkin.net/unkin/artifactapi/internal/tui"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "tui" {
|
||||
endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT")
|
||||
@@ -42,7 +44,7 @@ func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
srv, err := server.New(cfg)
|
||||
srv, err := server.New(cfg, version)
|
||||
if err != nil {
|
||||
slog.Error("failed to create server", "error", err)
|
||||
os.Exit(1)
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
# Authentication & Authorization — Design
|
||||
|
||||
Status: **proposed** (tracking issue #79)
|
||||
|
||||
Today ArtifactAPI has no authentication: every proxy and management request is
|
||||
served unconditionally. This document describes an auth/authz system that adds
|
||||
identity and path-scoped authorization **without changing behaviour until an
|
||||
operator turns enforcement on** — the default policy is fully open.
|
||||
|
||||
## Goals
|
||||
|
||||
- Identify callers as one of two principal kinds: **service accounts** and **users**.
|
||||
- Authorize each request against a **path + capability** ACL model.
|
||||
- Let **Vault/OpenBao** mint short-lived tokens so the Terraform provider can get
|
||||
just-in-time credentials to make config changes.
|
||||
- Ship **default-open**: an unconfigured deployment behaves exactly as today.
|
||||
|
||||
## Non-goals (initial phase)
|
||||
|
||||
- Per-object encryption, signing, or content trust.
|
||||
- Rate limiting / quotas (separate concern).
|
||||
- Multi-tenancy beyond what path ACLs express.
|
||||
|
||||
## Principals
|
||||
|
||||
| Kind | Authenticates with | Created by | Lifetime |
|
||||
|---|---|---|---|
|
||||
| Service account — static token | `Authorization: Bearer <token>` | admin via management API | until revoked |
|
||||
| Service account — dynamic token | `Authorization: Bearer <token>` | Vault secrets engine → mint endpoint | lease TTL (auto-revoked) |
|
||||
| User | UI session cookie (OIDC/LDAP login) | external IdP, first-seen on login | session TTL |
|
||||
|
||||
A **service account** is a named identity holding a set of ACL grants. It may
|
||||
have any number of associated tokens (static, or dynamic ones minted by Vault).
|
||||
A **user** is an identity resolved from an external IdP; group membership from
|
||||
the IdP maps to ACL grants.
|
||||
|
||||
## Tokens
|
||||
|
||||
- Format: `aapi_<base62(32 random bytes)>`. The `aapi_` prefix makes tokens
|
||||
greppable and lets us reject obviously-malformed values cheaply.
|
||||
- Storage: only the **SHA-256 of the token** is stored, never the plaintext.
|
||||
Lookup hashes the presented token and matches by hash.
|
||||
- Each token row carries: id, principal (service account) ref, sha256, optional
|
||||
label, `expires_at` (null = non-expiring), `created_at`, `last_used_at`.
|
||||
- Revocation: delete the row (static) or Vault lease revoke → mint endpoint
|
||||
revoke (dynamic).
|
||||
|
||||
## ACL model
|
||||
|
||||
A grant is `(path_pattern, capability)`. A principal is allowed an action iff at
|
||||
least one of its grants matches the request's resource path and capability.
|
||||
|
||||
### Resource paths
|
||||
|
||||
```
|
||||
remote/<remote-name>/<path-in-remote> # proxy + local repo objects
|
||||
virtual/<virtual-name>/<path> # virtual repo reads
|
||||
admin/remotes/<remote-name> # manage a remote definition
|
||||
admin/virtuals/<virtual-name> # manage a virtual definition
|
||||
admin/principals/<name> # manage service accounts / tokens
|
||||
```
|
||||
|
||||
Patterns support a trailing `*` wildcard and `<segment>/*` prefixes, e.g.
|
||||
`remote/dockerhub/*`, `remote/*`, `admin/*`. Matching is longest-prefix by
|
||||
segment; an exact match always wins over a wildcard.
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Meaning for `remote/...` | Meaning for `admin/...` |
|
||||
|---|---|---|
|
||||
| `read` | GET/HEAD an artifact | GET a definition |
|
||||
| `create` | first upload of a new local file | create a new definition |
|
||||
| `write` | overwrite / re-publish | update an existing definition |
|
||||
| `delete` | remove an object | delete a definition |
|
||||
|
||||
The HTTP layer maps each route to `(resource path, capability)`:
|
||||
|
||||
| Route | Resource | Capability |
|
||||
|---|---|---|
|
||||
| `GET /api/v1/remote/{r}/*`, `/v2/{r}/*` | `remote/{r}/{path}` | `read` |
|
||||
| `GET /api/v1/virtual/{v}/*` | `virtual/{v}/{path}` | `read` |
|
||||
| `PUT /api/v2/remotes/{r}/files/*` (new file) | `remote/{r}/{path}` | `create` |
|
||||
| `PUT ...` (existing file) | `remote/{r}/{path}` | `write` |
|
||||
| `DELETE /api/v2/remotes/{r}/files/*` | `remote/{r}/{path}` | `delete` |
|
||||
| `POST /api/v2/remotes` | `admin/remotes/{name}` | `create` |
|
||||
| `PUT /api/v2/remotes/{r}` | `admin/remotes/{r}` | `write` |
|
||||
| `DELETE /api/v2/remotes/{r}` | `admin/remotes/{r}` | `delete` |
|
||||
|
||||
## Enforcement middleware & default-open
|
||||
|
||||
A single middleware runs before the proxy/management handlers:
|
||||
|
||||
1. Resolve the principal from the request (bearer token → service account, or
|
||||
session cookie → user). No credential → the **anonymous** principal.
|
||||
2. Compute `(resource, capability)` for the route.
|
||||
3. If **enforcement is disabled** (default), allow. Otherwise, evaluate the
|
||||
principal's grants (including the anonymous principal's grants) and allow iff
|
||||
a grant matches; else 401 (no/invalid credential) or 403 (authenticated but
|
||||
unauthorized).
|
||||
|
||||
Enforcement is controlled by a single setting, `AUTH_ENFORCE` (default `false`).
|
||||
While `false`, the middleware still *resolves* the principal (so `last_used_at`
|
||||
and audit logging work) but never denies — making rollout observable before it
|
||||
is enforced. The **anonymous** principal is seeded with `*` → all capabilities,
|
||||
so even flipping `AUTH_ENFORCE=true` with no other config keeps the deployment
|
||||
open until an admin tightens the anonymous grants.
|
||||
|
||||
## Vault integration
|
||||
|
||||
### Mint endpoint (artifactapi side)
|
||||
|
||||
`POST /api/v2/auth/tokens:mint` — restricted to callers Vault trusts. It creates
|
||||
a dynamic token bound to a named service account with a caller-supplied TTL, and
|
||||
returns the plaintext once. `DELETE /api/v2/auth/tokens/{id}` revokes it.
|
||||
|
||||
Trust between Vault and artifactapi: a dedicated **bootstrap service account**
|
||||
whose static token is stored in Vault's engine `config`. The mint endpoint
|
||||
requires `admin/principals/*: write`. (mTLS is a future hardening option.)
|
||||
|
||||
### `vault-plugin-secrets-artifactapi` (new repo)
|
||||
|
||||
Mirrors [`vault-plugin-secrets-litellm`](https://git.unkin.net/unkin/vault-plugin-secrets-litellm):
|
||||
HashiCorp `vault/sdk`, OpenBao-compatible single binary. Paths:
|
||||
|
||||
- `config` — artifactapi base URL + bootstrap token.
|
||||
- `roles/<name>` — target service account + default/max TTL.
|
||||
- `creds/<name>` — mint a dynamic token (calls the mint endpoint); the Vault
|
||||
lease's revoke calls the revoke endpoint.
|
||||
|
||||
E2e (`make e2e`) spins Postgres + MinIO + Redis + artifactapi + Vault + OpenBao
|
||||
in Docker and exercises the full lease lifecycle against both engines. On the
|
||||
Fedora host all bind mounts need `:z` (SELinux).
|
||||
|
||||
## User login (OIDC/LDAP) & UI
|
||||
|
||||
- `GET /api/v2/auth/login` starts an OIDC auth-code flow (or LDAP bind form);
|
||||
`GET /api/v2/auth/callback` establishes a signed session cookie.
|
||||
- IdP groups map to service-account-style grants via configurable group→grant
|
||||
rules. Existing infra: `terraform-authentik`, `terraform-ldap`.
|
||||
- The React UI gains a login state and sends the session cookie; management
|
||||
screens hide actions the principal lacks.
|
||||
|
||||
## Terraform provider
|
||||
|
||||
`terraform-provider-artifactapi` gains a `token` attribute (and
|
||||
`ARTIFACTAPI_TOKEN` env var) sent as `Authorization: Bearer`. In CI the token is
|
||||
sourced from the Vault engine above, so config changes use short-lived creds.
|
||||
|
||||
## Data model (new tables, additive migration)
|
||||
|
||||
```sql
|
||||
service_accounts(name PK, description, disabled, created_at)
|
||||
auth_tokens(id PK, principal TEXT REFERENCES service_accounts(name) ON DELETE CASCADE,
|
||||
token_sha256 TEXT UNIQUE, label, expires_at, created_at, last_used_at)
|
||||
acl_grants(id PK, principal TEXT, path_pattern TEXT, capability TEXT,
|
||||
UNIQUE(principal, path_pattern, capability))
|
||||
-- principal = a service account name, the reserved 'anonymous', or 'user:<sub>'
|
||||
```
|
||||
|
||||
All tables are created with `CREATE TABLE IF NOT EXISTS` alongside the existing
|
||||
inline migrations; adding them changes no current behaviour.
|
||||
|
||||
## Rollout / phased delivery
|
||||
|
||||
Each phase is a separate PR; the system stays open until phase 6 is deliberately
|
||||
enabled.
|
||||
|
||||
1. **Data model + resolution** — tables, token hashing, principal resolution
|
||||
middleware in **observe-only** mode (never denies). Seed anonymous `*`.
|
||||
2. **ACL evaluation** — grant matching + `(resource, capability)` route mapping,
|
||||
still gated by `AUTH_ENFORCE=false`.
|
||||
3. **Management API** — CRUD for service accounts, tokens, grants.
|
||||
4. **Vault mint/revoke endpoints** + bootstrap trust.
|
||||
5. **`vault-plugin-secrets-artifactapi`** (new repo) + `terraform-vault` role,
|
||||
policies; `argocd-apps` deploy.
|
||||
6. **OIDC/LDAP user login + UI**, Terraform provider `token`, and the switch to
|
||||
enable enforcement in an environment.
|
||||
|
||||
## Cross-repo dependencies
|
||||
|
||||
- `terraform-vault` — mount the secrets engine, define `roles/*`, ACL policies,
|
||||
and the K8s auth role the Terraform CI uses.
|
||||
- `argocd-apps` — deploy the plugin sidecar/init and any ServiceAccount.
|
||||
- `terraform-provider-artifactapi` — `token` attribute.
|
||||
- `terraform-authentik` / `terraform-ldap` — IdP client + group mappings.
|
||||
@@ -37,6 +37,20 @@ func (h *ProxyHandler) Routes() chi.Router {
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) DockerV2Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.handleDockerPing)
|
||||
r.Head("/", h.handleDockerPing)
|
||||
r.Get("/{remoteName}/*", h.handleProxy)
|
||||
r.Head("/{remoteName}/*", h.handleProxy)
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
remoteName := chi.URLParam(r, "remoteName")
|
||||
path := chi.URLParam(r, "*")
|
||||
@@ -53,7 +67,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.engine.Fetch(r.Context(), *remote, path, prov)
|
||||
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
|
||||
if err != nil {
|
||||
var proxyErr *proxy.ProxyError
|
||||
if errors.As(err, &proxyErr) {
|
||||
|
||||
@@ -30,6 +30,15 @@ func (db *DB) GetOverviewStats(ctx context.Context) (*models.OverviewStats, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = db.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(SUM(size_bytes), 0)
|
||||
FROM access_log
|
||||
WHERE cache_hit = TRUE AND created_at > NOW() - INTERVAL '30 days'
|
||||
`).Scan(&stats.BandwidthSaved30d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
|
||||
+101
-4
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/cache"
|
||||
@@ -42,7 +44,7 @@ type FetchResult struct {
|
||||
Source string // "cache" or "remote"
|
||||
}
|
||||
|
||||
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*FetchResult, error) {
|
||||
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider, clientHeaders ...http.Header) (*FetchResult, error) {
|
||||
classifier := NewClassifier(prov)
|
||||
class := classifier.Classify(remote, path)
|
||||
|
||||
@@ -103,8 +105,13 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
||||
}
|
||||
}
|
||||
|
||||
var fwdHeaders http.Header
|
||||
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
|
||||
fwdHeaders = clientHeaders[0]
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl)
|
||||
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl, fwdHeaders)
|
||||
upstreamMS := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
if remote.StaleOnError && isNetworkError(err) {
|
||||
@@ -124,7 +131,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration) (*FetchResult, error) {
|
||||
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration, clientHeaders http.Header) (*FetchResult, error) {
|
||||
url := prov.UpstreamURL(remote, path)
|
||||
|
||||
authHeaders, err := prov.AuthHeaders(ctx, remote)
|
||||
@@ -141,12 +148,37 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
if clientHeaders != nil {
|
||||
if accept := clientHeaders.Get("Accept"); accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &UpstreamError{Err: err}
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
resp.Body.Close()
|
||||
token, err := fetchBearerToken(ctx, resp.Header.Get("Www-Authenticate"), remote)
|
||||
if err == nil && token != "" {
|
||||
req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+token)
|
||||
if clientHeaders != nil {
|
||||
if accept := clientHeaders.Get("Accept"); accept != "" {
|
||||
req2.Header.Set("Accept", accept)
|
||||
}
|
||||
}
|
||||
resp, err = http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
return nil, &UpstreamError{Err: err}
|
||||
}
|
||||
} else {
|
||||
return nil, &ProxyError{Status: http.StatusUnauthorized, Message: "upstream returned 401"}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
|
||||
@@ -167,7 +199,7 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
|
||||
}
|
||||
|
||||
contentType := prov.ContentType(path)
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" && contentType == "application/octet-stream" {
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
contentType = ct
|
||||
}
|
||||
|
||||
@@ -319,6 +351,71 @@ func (r readerAt) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote) (string, error) {
|
||||
if !strings.HasPrefix(wwwAuth, "Bearer ") {
|
||||
return "", fmt.Errorf("not a Bearer challenge")
|
||||
}
|
||||
|
||||
params := map[string]string{}
|
||||
for _, part := range strings.Split(wwwAuth[7:], ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
eq := strings.Index(part, "=")
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
key := part[:eq]
|
||||
val := strings.Trim(part[eq+1:], `"`)
|
||||
params[key] = val
|
||||
}
|
||||
|
||||
realm := params["realm"]
|
||||
if realm == "" {
|
||||
return "", fmt.Errorf("no realm in Bearer challenge")
|
||||
}
|
||||
|
||||
tokenURL := realm
|
||||
sep := "?"
|
||||
if s, ok := params["service"]; ok {
|
||||
tokenURL += sep + "service=" + s
|
||||
sep = "&"
|
||||
}
|
||||
if s, ok := params["scope"]; ok {
|
||||
tokenURL += sep + "scope=" + s
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if remote.Username != "" && remote.Password != "" {
|
||||
req.SetBasicAuth(remote.Username, remote.Password)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token endpoint returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if tokenResp.Token != "" {
|
||||
return tokenResp.Token, nil
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
type ProxyError struct {
|
||||
Status int
|
||||
Message string
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
version string
|
||||
router chi.Router
|
||||
db *database.DB
|
||||
cache *cache.Redis
|
||||
@@ -45,7 +46,7 @@ type Server struct {
|
||||
gc *gc.Collector
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*Server, error) {
|
||||
func New(cfg *config.Config, version string) (*Server, error) {
|
||||
db, err := database.New(cfg.DatabaseDSN())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database: %w", err)
|
||||
@@ -68,6 +69,7 @@ func New(cfg *config.Config) (*Server, error) {
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
version: version,
|
||||
db: db,
|
||||
cache: redis,
|
||||
store: s3,
|
||||
@@ -96,6 +98,7 @@ func (s *Server) routes() chi.Router {
|
||||
|
||||
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
||||
r.Mount("/api/v1", proxyHandler.Routes())
|
||||
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
||||
|
||||
remotesHandler := v2.NewRemotesHandler(s.db)
|
||||
virtualsHandler := v2.NewVirtualsHandler(s.db)
|
||||
@@ -137,7 +140,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"name":"artifactapi","version":"3.0.0-dev"}`)
|
||||
fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
|
||||
}
|
||||
|
||||
func (s *Server) newHTTPServer() *http.Server {
|
||||
|
||||
@@ -79,7 +79,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
|
||||
results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)}
|
||||
return
|
||||
}
|
||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
|
||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
|
||||
return
|
||||
}
|
||||
|
||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
|
||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
|
||||
}(i, memberName)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,15 +54,27 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
seen[chart][ver.Version] = true
|
||||
|
||||
if proxyBaseURL != "" {
|
||||
routePrefix := "remote"
|
||||
if member.RepoType == "local" {
|
||||
routePrefix = "local"
|
||||
}
|
||||
baseHost := extractHost(member.BaseURL)
|
||||
|
||||
for i, u := range ver.URLs {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s",
|
||||
if baseHost != "" && extractHost(u) != baseHost {
|
||||
continue
|
||||
}
|
||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
extractPath(u))
|
||||
relPath)
|
||||
} else {
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s",
|
||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
||||
strings.TrimRight(proxyBaseURL, "/"),
|
||||
routePrefix,
|
||||
member.RemoteName,
|
||||
u)
|
||||
}
|
||||
@@ -78,6 +90,31 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
||||
return yaml.Marshal(merged)
|
||||
}
|
||||
|
||||
func extractHost(rawURL string) string {
|
||||
idx := strings.Index(rawURL, "://")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
rest := rawURL[idx+3:]
|
||||
slashIdx := strings.Index(rest, "/")
|
||||
if slashIdx == -1 {
|
||||
return rest
|
||||
}
|
||||
return rest[:slashIdx]
|
||||
}
|
||||
|
||||
func extractPathRelativeToBase(rawURL, baseURL string) string {
|
||||
fullPath := extractPath(rawURL)
|
||||
basePath := extractPath(baseURL)
|
||||
if basePath != "" {
|
||||
basePath = strings.TrimRight(basePath, "/") + "/"
|
||||
if strings.HasPrefix(fullPath, basePath) {
|
||||
return fullPath[len(basePath):]
|
||||
}
|
||||
}
|
||||
return fullPath
|
||||
}
|
||||
|
||||
func extractPath(rawURL string) string {
|
||||
idx := strings.Index(rawURL, "://")
|
||||
if idx == -1 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type MemberIndex struct {
|
||||
RemoteName string
|
||||
RepoType models.RepoType
|
||||
BaseURL string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,20 @@ COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG BASE_PATH=/
|
||||
ENV BASE_PATH=${BASE_PATH}
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
ARG BASE_PATH=/
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
RUN sed -i "s|\${BASE_PATH}|${BASE_PATH}|g" /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
+6
-27
@@ -5,33 +5,12 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://artifactapi:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /v2/ {
|
||||
proxy_pass http://artifactapi:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://artifactapi:8000;
|
||||
}
|
||||
|
||||
location /metrics {
|
||||
proxy_pass http://artifactapi:8000;
|
||||
}
|
||||
|
||||
location / {
|
||||
location ${BASE_PATH}/ {
|
||||
rewrite ^${BASE_PATH}(/.*)$ $1 break;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = ${BASE_PATH} {
|
||||
return 301 ${BASE_PATH}/;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -4,9 +4,13 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
declare const __BASE_PATH__: string;
|
||||
|
||||
const basename = __BASE_PATH__.replace(/\/+$/, '') || '/';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename={basename}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -50,6 +50,11 @@ export function Dashboard() {
|
||||
value={formatNumber(stats.total_blobs_deduped)}
|
||||
sub="shared blobs"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Bandwidth Saved"
|
||||
value={formatBytes(stats.bandwidth_saved_30d)}
|
||||
sub="last 30 days"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{health && (
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const basePath = process.env.BASE_PATH || '/'
|
||||
|
||||
export default defineConfig({
|
||||
base: basePath,
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
@@ -11,4 +14,7 @@ export default defineConfig({
|
||||
'/metrics': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'__BASE_PATH__': JSON.stringify(basePath),
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user