2026-01-20 21:52:44 +00:00
// Command proxy runs the git-pkgs package registry proxy server.
//
2026-03-11 17:18:29 +00:00
// @title git-pkgs proxy API
// @version 0.1.0
// @description HTTP API for package enrichment, vulnerability lookup, cache stats, and source browsing.
// @BasePath /
//
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description "Bearer " followed by hosted.token
//
2026-01-20 21:52:44 +00:00
// The proxy caches package artifacts from upstream registries (npm, cargo, etc.)
// providing faster, more reliable access for development teams.
//
// Usage:
//
// proxy [command] [flags]
//
// Commands:
//
// serve Start the proxy server (default if no command given)
// stats Show cache statistics
2026-03-19 21:06:02 +00:00
// mirror Pre-populate cache from PURLs, SBOMs, or registries
2026-10-13 15:20:57 +02:00
// hosted Push, list or delete packages on a running proxy (needs hosted.enabled)
2026-01-20 21:52:44 +00:00
//
// Serve Flags:
//
// -config string
// Path to configuration file (YAML or JSON)
// -listen string
// Address to listen on (default ":8080")
// -base-url string
// Public URL of this proxy (default "http://localhost:8080")
2026-01-29 16:13:16 +00:00
// -storage-url string
// Storage URL (file:// or s3://)
// -storage-path string
// Path to artifact storage directory (deprecated, use -storage-url)
2026-01-29 16:06:56 +00:00
// -database-driver string
// Database driver: sqlite or postgres (default "sqlite")
// -database-path string
2026-01-20 21:52:44 +00:00
// Path to SQLite database file (default "./cache/proxy.db")
2026-01-29 16:06:56 +00:00
// -database-url string
// PostgreSQL connection URL
2026-01-20 21:52:44 +00:00
// -log-level string
// Log level: debug, info, warn, error (default "info")
// -log-format string
// Log format: text, json (default "text")
2026-08-16 18:07:39 +01:00
// -access-log string
// Path to the JSONL access log (disabled by default)
2026-01-20 21:52:44 +00:00
//
// Stats Flags:
//
2026-01-29 16:06:56 +00:00
// -database-driver string
// Database driver: sqlite or postgres (default "sqlite")
// -database-path string
2026-01-20 21:52:44 +00:00
// Path to SQLite database file (default "./cache/proxy.db")
2026-01-29 16:06:56 +00:00
// -database-url string
// PostgreSQL connection URL
2026-01-20 21:52:44 +00:00
// -json
// Output as JSON
// -popular int
// Show top N most popular packages (default 10)
// -recent int
// Show N recently cached packages (default 10)
//
// Global Flags:
//
// -version
// Print version and exit
//
// Environment Variables:
//
2026-01-29 16:06:56 +00:00
// PROXY_LISTEN - Listen address
// PROXY_BASE_URL - Public URL
2026-01-29 16:13:16 +00:00
// PROXY_STORAGE_URL - Storage URL (file:// or s3://)
// PROXY_STORAGE_PATH - Storage directory (deprecated)
2026-01-29 16:06:56 +00:00
// PROXY_DATABASE_DRIVER - Database driver (sqlite or postgres)
// PROXY_DATABASE_PATH - SQLite database file path
// PROXY_DATABASE_URL - PostgreSQL connection URL
// PROXY_LOG_LEVEL - Log level
// PROXY_LOG_FORMAT - Log format
2026-08-16 18:07:39 +01:00
// PROXY_ACCESS_LOG_PATH - JSONL access log path
2026-08-29 11:19:54 +01:00
// PROXY_UPSTREAM_* - Upstream URLs and network access controls
2026-05-04 12:15:16 +02:00
// PROXY_GRADLE_BUILD_CACHE_READ_ONLY - Disable Gradle PUT uploads
// PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE - Max Gradle PUT request body size
// PROXY_GRADLE_BUILD_CACHE_MAX_AGE - Gradle cache max age eviction
// PROXY_GRADLE_BUILD_CACHE_MAX_SIZE - Gradle cache max total size
// PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL - Gradle cache eviction sweep interval
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
// PROXY_HEALTH_STORAGE_PROBE_INTERVAL - Storage health probe cache interval (default "30s")
2026-10-13 13:11:49 +02:00
// PROXY_HOSTED_ENABLED - Enable pushing packages to /api/hosted
// PROXY_HOSTED_TOKEN - Bearer token for pushing and deleting
// PROXY_HOSTED_MAX_UPLOAD_SIZE - Max size of one pushed archive (default "100MB")
2026-10-13 13:42:34 +02:00
// PROXY_HOSTED_NPM_SCOPES - npm scopes served from pushed packages only
// PROXY_HOSTED_COMPOSER_VENDORS - Composer vendors served from pushed packages only
2026-10-13 15:20:57 +02:00
// PROXY_URL - Proxy URL for proxy hosted
2026-01-20 21:52:44 +00:00
//
// Example:
//
// # Start with defaults
// proxy
//
// # Start with custom settings
// proxy serve -listen :3000 -base-url https://proxy.example.com
//
// # Show cache statistics
// proxy stats
//
// # Show stats as JSON
// proxy stats -json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
2026-09-16 09:25:19 +01:00
"runtime/debug"
2026-01-20 21:52:44 +00:00
"strings"
"syscall"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
2026-09-20 14:42:30 +05:30
"github.com/git-pkgs/proxy/internal/denylist"
2026-03-19 21:06:02 +00:00
"github.com/git-pkgs/proxy/internal/mirror"
2026-01-20 21:52:44 +00:00
"github.com/git-pkgs/proxy/internal/server"
2026-03-19 21:06:02 +00:00
"github.com/git-pkgs/proxy/internal/storage"
2026-01-20 21:52:44 +00:00
)
2026-03-18 10:59:29 +00:00
const defaultTopN = 10
2026-01-20 21:52:44 +00:00
var (
// Version is set at build time.
Version = "dev"
// Commit is set at build time.
Commit = "unknown"
)
2026-09-16 09:25:19 +01:00
func init ( ) {
if Version != "dev" {
return
}
if bi , ok := debug . ReadBuildInfo ( ) ; ok && bi . Main . Version != "" && bi . Main . Version != "(devel)" {
Version = bi . Main . Version
}
}
2026-01-20 21:52:44 +00:00
func main ( ) {
if len ( os . Args ) > 1 {
switch os . Args [ 1 ] {
case "serve" :
os . Args = append ( os . Args [ : 1 ] , os . Args [ 2 : ] ... )
runServe ( )
return
case "stats" :
os . Args = append ( os . Args [ : 1 ] , os . Args [ 2 : ] ... )
runStats ( )
return
2026-03-19 21:06:02 +00:00
case "mirror" :
os . Args = append ( os . Args [ : 1 ] , os . Args [ 2 : ] ... )
runMirror ( )
return
2026-10-13 15:20:57 +02:00
case "hosted" :
2026-10-13 13:11:49 +02:00
os . Args = append ( os . Args [ : 1 ] , os . Args [ 2 : ] ... )
2026-10-13 15:20:57 +02:00
runHostedCommand ( )
2026-10-13 13:11:49 +02:00
return
2026-01-20 21:52:44 +00:00
case "-version" , "--version" :
fmt . Printf ( "proxy %s (%s)\n" , Version , Commit )
os . Exit ( 0 )
case "-h" , "-help" , "--help" :
printUsage ( )
os . Exit ( 0 )
}
}
// Default to serve
runServe ( )
}
func printUsage ( ) {
fmt . Fprintf ( os . Stderr , ` git - pkgs proxy - Package registry caching proxy
Usage : proxy [ command ] [ flags ]
Commands :
serve Start the proxy server ( default )
stats Show cache statistics
2026-03-19 21:06:02 +00:00
mirror Pre - populate cache from PURLs , SBOMs , or registries
2026-10-13 15:20:57 +02:00
hosted Push , list or delete packages on a running proxy
2026-01-20 21:52:44 +00:00
Run ' proxy < command > - help ' for more information on a command .
Global Flags :
- version Print version and exit
- help Show this help message
` )
}
func runServe ( ) {
fs := flag . NewFlagSet ( "serve" , flag . ExitOnError )
configPath := fs . String ( "config" , "" , "Path to configuration file (YAML or JSON)" )
listen := fs . String ( "listen" , "" , "Address to listen on" )
baseURL := fs . String ( "base-url" , "" , "Public URL of this proxy" )
2026-01-29 16:13:16 +00:00
storageURL := fs . String ( "storage-url" , "" , "Storage URL (file:// or s3://)" )
storagePath := fs . String ( "storage-path" , "" , "Path to artifact storage directory (deprecated, use -storage-url)" )
2026-01-29 16:06:56 +00:00
databaseDriver := fs . String ( "database-driver" , "" , "Database driver: sqlite or postgres" )
databasePath := fs . String ( "database-path" , "" , "Path to SQLite database file" )
databaseURL := fs . String ( "database-url" , "" , "PostgreSQL connection URL" )
2026-01-20 21:52:44 +00:00
logLevel := fs . String ( "log-level" , "" , "Log level: debug, info, warn, error" )
logFormat := fs . String ( "log-format" , "" , "Log format: text, json" )
2026-08-16 18:07:39 +01:00
accessLogPath := fs . String ( "access-log" , "" , "Path to the JSONL access log" )
2026-01-20 21:52:44 +00:00
version := fs . Bool ( "version" , false , "Print version and exit" )
fs . Usage = func ( ) {
fmt . Fprintf ( os . Stderr , "git-pkgs proxy - Package registry caching proxy\n\n" )
fmt . Fprintf ( os . Stderr , "Usage: proxy serve [flags]\n\n" )
fmt . Fprintf ( os . Stderr , "Flags:\n" )
fs . PrintDefaults ( )
fmt . Fprintf ( os . Stderr , "\nEnvironment Variables:\n" )
2026-01-29 16:06:56 +00:00
fmt . Fprintf ( os . Stderr , " PROXY_LISTEN Listen address\n" )
fmt . Fprintf ( os . Stderr , " PROXY_BASE_URL Public URL\n" )
2026-01-29 16:13:16 +00:00
fmt . Fprintf ( os . Stderr , " PROXY_STORAGE_URL Storage URL (file:// or s3://)\n" )
fmt . Fprintf ( os . Stderr , " PROXY_STORAGE_PATH Storage directory (deprecated)\n" )
2026-01-29 16:06:56 +00:00
fmt . Fprintf ( os . Stderr , " PROXY_DATABASE_DRIVER Database driver (sqlite or postgres)\n" )
fmt . Fprintf ( os . Stderr , " PROXY_DATABASE_PATH SQLite database file\n" )
fmt . Fprintf ( os . Stderr , " PROXY_DATABASE_URL PostgreSQL connection URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_LOG_LEVEL Log level\n" )
fmt . Fprintf ( os . Stderr , " PROXY_LOG_FORMAT Log format\n" )
2026-08-16 18:07:39 +01:00
fmt . Fprintf ( os . Stderr , " PROXY_ACCESS_LOG_PATH JSONL access log path\n" )
2026-08-29 11:19:54 +01:00
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_ALLOW_PRIVATE_HOSTS Comma-separated private upstream hosts\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_ALLOW_LOOPBACK Permit loopback upstreams and redirects\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_NPM npm registry upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_CARGO Cargo index upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_CARGO_DOWNLOAD Cargo download upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_GEM RubyGems upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_GO Go module proxy upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_HEX Hex repository upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_HEX_API Hex API upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_PUB pub registry upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_PYPI PyPI index and API upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_PYPI_DOWNLOAD PyPI download upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_MAVEN Maven repository upstream URL\n" )
2026-05-22 18:05:20 +02:00
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL Gradle Plugin Portal upstream URL\n" )
2026-08-29 11:19:54 +01:00
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_NUGET NuGet API upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_NUGET_SEARCH NuGet search upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_COMPOSER Packagist API upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_COMPOSER_REPOSITORY Packagist repository upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_CONAN Conan registry upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_CONDA Conda channel upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_CRAN CRAN mirror upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_JULIA Julia package server upstream URL\n" )
2026-09-02 13:06:34 +01:00
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_SWIFT Swift Package Registry upstream URL\n" )
2026-08-29 11:19:54 +01:00
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_OCI_DEFAULT Default OCI registry upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_DEBIAN Debian repository upstream URL\n" )
fmt . Fprintf ( os . Stderr , " PROXY_UPSTREAM_RPM RPM repository upstream URL\n" )
2026-05-04 12:15:16 +02:00
fmt . Fprintf ( os . Stderr , " PROXY_GRADLE_BUILD_CACHE_READ_ONLY Disable Gradle PUT uploads\n" )
fmt . Fprintf ( os . Stderr , " PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE Max Gradle PUT request body size\n" )
fmt . Fprintf ( os . Stderr , " PROXY_GRADLE_BUILD_CACHE_MAX_AGE Gradle cache max age eviction\n" )
fmt . Fprintf ( os . Stderr , " PROXY_GRADLE_BUILD_CACHE_MAX_SIZE Gradle cache max total size\n" )
fmt . Fprintf ( os . Stderr , " PROXY_GRADLE_BUILD_CACHE_SWEEP_INTERVAL Gradle cache eviction sweep interval\n" )
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
fmt . Fprintf ( os . Stderr , " PROXY_HEALTH_STORAGE_PROBE_INTERVAL Storage health probe cache interval\n" )
2026-01-20 21:52:44 +00:00
}
_ = fs . Parse ( os . Args [ 1 : ] )
if * version {
fmt . Printf ( "proxy %s (%s)\n" , Version , Commit )
os . Exit ( 0 )
}
// Load configuration
cfg , err := loadConfig ( * configPath )
if err != nil {
fmt . Fprintf ( os . Stderr , "error loading config: %v\n" , err )
os . Exit ( 1 )
}
// Apply environment variables
cfg . LoadFromEnv ( )
// Apply command line flags (highest priority)
if * listen != "" {
cfg . Listen = * listen
}
if * baseURL != "" {
cfg . BaseURL = * baseURL
}
2026-01-29 16:13:16 +00:00
if * storageURL != "" {
cfg . Storage . URL = * storageURL
}
2026-01-20 21:52:44 +00:00
if * storagePath != "" {
2026-03-18 10:59:29 +00:00
cfg . Storage . Path = * storagePath //nolint:staticcheck // backwards compat
2026-01-20 21:52:44 +00:00
}
2026-01-29 16:06:56 +00:00
if * databaseDriver != "" {
cfg . Database . Driver = * databaseDriver
}
2026-01-20 21:52:44 +00:00
if * databasePath != "" {
cfg . Database . Path = * databasePath
}
2026-01-29 16:06:56 +00:00
if * databaseURL != "" {
cfg . Database . URL = * databaseURL
}
2026-01-20 21:52:44 +00:00
if * logLevel != "" {
cfg . Log . Level = * logLevel
}
if * logFormat != "" {
cfg . Log . Format = * logFormat
}
2026-08-16 18:07:39 +01:00
if * accessLogPath != "" {
cfg . AccessLog . Path = * accessLogPath
}
2026-01-20 21:52:44 +00:00
// Validate configuration
if err := cfg . Validate ( ) ; err != nil {
fmt . Fprintf ( os . Stderr , "invalid configuration: %v\n" , err )
os . Exit ( 1 )
}
// Setup logger
logger := setupLogger ( cfg . Log . Level , cfg . Log . Format )
// Create and start server
2026-08-21 04:25:02 -04:00
srv , err := server . New ( cfg , logger , server . BuildInfo {
Version : Version ,
Commit : Commit ,
} )
2026-01-20 21:52:44 +00:00
if err != nil {
logger . Error ( "failed to create server" , "error" , err )
os . Exit ( 1 )
}
// Handle shutdown signals
ctx , cancel := context . WithCancel ( context . Background ( ) )
go func ( ) {
sigCh := make ( chan os . Signal , 1 )
signal . Notify ( sigCh , syscall . SIGINT , syscall . SIGTERM )
<- sigCh
logger . Info ( "received shutdown signal" )
cancel ( )
} ( )
// Start server in goroutine
errCh := make ( chan error , 1 )
go func ( ) {
errCh <- srv . Start ( )
} ( )
// Wait for shutdown or error
select {
case <- ctx . Done ( ) :
2026-03-18 10:59:29 +00:00
cancel ( )
2026-01-20 21:52:44 +00:00
if err := srv . Shutdown ( context . Background ( ) ) ; err != nil {
logger . Error ( "shutdown error" , "error" , err )
}
case err := <- errCh :
2026-03-18 10:59:29 +00:00
cancel ( )
2026-01-20 21:52:44 +00:00
if err != nil {
logger . Error ( "server error" , "error" , err )
os . Exit ( 1 )
}
}
}
func runStats ( ) {
fs := flag . NewFlagSet ( "stats" , flag . ExitOnError )
2026-01-29 16:06:56 +00:00
databaseDriver := fs . String ( "database-driver" , "sqlite" , "Database driver: sqlite or postgres" )
databasePath := fs . String ( "database-path" , "./cache/proxy.db" , "Path to SQLite database file" )
databaseURL := fs . String ( "database-url" , "" , "PostgreSQL connection URL" )
2026-01-20 21:52:44 +00:00
asJSON := fs . Bool ( "json" , false , "Output as JSON" )
2026-03-18 10:59:29 +00:00
popular := fs . Int ( "popular" , defaultTopN , "Show top N most popular packages" )
recent := fs . Int ( "recent" , defaultTopN , "Show N recently cached packages" )
2026-01-20 21:52:44 +00:00
fs . Usage = func ( ) {
fmt . Fprintf ( os . Stderr , "git-pkgs proxy - Show cache statistics\n\n" )
fmt . Fprintf ( os . Stderr , "Usage: proxy stats [flags]\n\n" )
fmt . Fprintf ( os . Stderr , "Flags:\n" )
fs . PrintDefaults ( )
}
_ = fs . Parse ( os . Args [ 1 : ] )
2026-01-29 16:06:56 +00:00
// Apply environment overrides
if v := os . Getenv ( "PROXY_DATABASE_DRIVER" ) ; v != "" {
* databaseDriver = v
}
if v := os . Getenv ( "PROXY_DATABASE_PATH" ) ; v != "" {
* databasePath = v
}
if v := os . Getenv ( "PROXY_DATABASE_URL" ) ; v != "" {
* databaseURL = v
2026-01-20 21:52:44 +00:00
}
// Open database
2026-01-29 16:06:56 +00:00
var db * database . DB
var err error
switch * databaseDriver {
case "postgres" :
if * databaseURL == "" {
fmt . Fprintf ( os . Stderr , "database-url is required for postgres driver\n" )
os . Exit ( 1 )
}
db , err = database . OpenPostgres ( * databaseURL )
default :
if _ , statErr := os . Stat ( * databasePath ) ; os . IsNotExist ( statErr ) {
fmt . Fprintf ( os . Stderr , "database not found: %s\n" , * databasePath )
fmt . Fprintf ( os . Stderr , "run 'proxy serve' first to create the database\n" )
os . Exit ( 1 )
}
db , err = database . Open ( * databasePath )
}
2026-01-20 21:52:44 +00:00
if err != nil {
fmt . Fprintf ( os . Stderr , "error opening database: %v\n" , err )
os . Exit ( 1 )
}
2026-03-18 10:59:29 +00:00
if err := printStats ( db , * popular , * recent , * asJSON ) ; err != nil {
fmt . Fprintf ( os . Stderr , "%v\n" , err )
os . Exit ( 1 )
}
}
2026-03-19 21:06:02 +00:00
func runMirror ( ) {
fs := flag . NewFlagSet ( "mirror" , flag . ExitOnError )
configPath := fs . String ( "config" , "" , "Path to configuration file" )
storageURL := fs . String ( "storage-url" , "" , "Storage URL (file:// or s3://)" )
databaseDriver := fs . String ( "database-driver" , "" , "Database driver: sqlite or postgres" )
databasePath := fs . String ( "database-path" , "" , "Path to SQLite database file" )
databaseURL := fs . String ( "database-url" , "" , "PostgreSQL connection URL" )
sbomPath := fs . String ( "sbom" , "" , "Path to CycloneDX or SPDX SBOM file" )
concurrency := fs . Int ( "concurrency" , 4 , "Number of parallel downloads" ) //nolint:mnd // default concurrency
dryRun := fs . Bool ( "dry-run" , false , "Show what would be mirrored without downloading" )
2026-10-12 00:20:10 +02:00
platforms := fs . String ( "platform" , "" , "Comma-separated os/arch[/variant] list limiting which platforms of container images are mirrored" )
2026-03-19 21:06:02 +00:00
fs . Usage = func ( ) {
fmt . Fprintf ( os . Stderr , "git-pkgs proxy - Pre-populate cache\n\n" )
fmt . Fprintf ( os . Stderr , "Usage: proxy mirror [flags] [purl...]\n\n" )
fmt . Fprintf ( os . Stderr , "Examples:\n" )
fmt . Fprintf ( os . Stderr , " proxy mirror pkg:npm/lodash@4.17.21\n" )
fmt . Fprintf ( os . Stderr , " proxy mirror --sbom sbom.cdx.json\n" )
2026-10-12 00:20:10 +02:00
fmt . Fprintf ( os . Stderr , " proxy mirror pkg:npm/lodash # all versions\n" )
fmt . Fprintf ( os . Stderr , " proxy mirror --platform linux/amd64 pkg:docker/library/alpine@3.20\n\n" )
2026-03-19 21:06:02 +00:00
fmt . Fprintf ( os . Stderr , "Flags:\n" )
fs . PrintDefaults ( )
}
_ = fs . Parse ( os . Args [ 1 : ] )
purls := fs . Args ( )
2026-10-12 00:20:10 +02:00
var platformList [ ] string
if * platforms != "" {
platformList = [ ] string { * platforms }
}
if _ , err := mirror . ParsePlatforms ( platformList ) ; err != nil {
fmt . Fprintf ( os . Stderr , "error: %v\n" , err )
os . Exit ( 1 )
}
2026-03-19 21:06:02 +00:00
// Determine source
var source mirror . Source
switch {
case * sbomPath != "" :
2026-09-02 16:56:29 +05:30
data , err := os . ReadFile ( * sbomPath )
if err != nil {
fmt . Fprintf ( os . Stderr , "error reading SBOM %s: %v\n" , * sbomPath , err )
os . Exit ( 1 )
}
2026-10-12 00:20:10 +02:00
source = & mirror . SBOMSource { Data : data , Name : * sbomPath , Platforms : platformList }
2026-03-19 21:06:02 +00:00
case len ( purls ) > 0 :
2026-10-12 00:20:10 +02:00
source = & mirror . PURLSource { PURLs : purls , Platforms : platformList }
2026-03-19 21:06:02 +00:00
default :
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
fmt . Fprintf ( os . Stderr , "error: provide PURLs or --sbom\n" )
2026-03-19 21:06:02 +00:00
fs . Usage ( )
os . Exit ( 1 )
}
// Load config
cfg , err := loadConfig ( * configPath )
if err != nil {
fmt . Fprintf ( os . Stderr , "error loading config: %v\n" , err )
os . Exit ( 1 )
}
cfg . LoadFromEnv ( )
if * storageURL != "" {
cfg . Storage . URL = * storageURL
}
if * databaseDriver != "" {
cfg . Database . Driver = * databaseDriver
}
if * databasePath != "" {
cfg . Database . Path = * databasePath
}
if * databaseURL != "" {
cfg . Database . URL = * databaseURL
}
if err := cfg . Validate ( ) ; err != nil {
fmt . Fprintf ( os . Stderr , "invalid configuration: %v\n" , err )
os . Exit ( 1 )
}
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
if ! cfg . Storage . CacheArtifacts {
fmt . Fprintf ( os . Stderr , "error: mirror is not available with storage.cache_artifacts: false: mirrored artifacts would never be served\n" )
os . Exit ( 1 )
}
2026-03-19 21:06:02 +00:00
logger := setupLogger ( "info" , "text" )
// Open database
var db * database . DB
switch cfg . Database . Driver {
case "postgres" :
db , err = database . OpenPostgresOrCreate ( cfg . Database . URL )
default :
db , err = database . OpenOrCreate ( cfg . Database . Path )
}
if err != nil {
fmt . Fprintf ( os . Stderr , "error opening database: %v\n" , err )
os . Exit ( 1 )
}
2026-04-01 15:40:18 +01:00
defer func ( ) { _ = db . Close ( ) } ( )
2026-03-19 21:06:02 +00:00
if err := db . MigrateSchema ( ) ; err != nil {
_ = db . Close ( )
fmt . Fprintf ( os . Stderr , "error migrating schema: %v\n" , err )
2026-04-01 15:40:18 +01:00
os . Exit ( 1 ) //nolint:gocritic // db closed above
2026-03-19 21:06:02 +00:00
}
// Open storage
sURL := cfg . Storage . URL
if sURL == "" {
sURL = "file://" + cfg . Storage . Path //nolint:staticcheck // backwards compat
}
store , err := storage . OpenBucket ( context . Background ( ) , sURL )
if err != nil {
_ = db . Close ( )
fmt . Fprintf ( os . Stderr , "error opening storage: %v\n" , err )
2026-04-01 15:40:18 +01:00
os . Exit ( 1 ) //nolint:gocritic // db closed above
2026-03-19 21:06:02 +00:00
}
2026-10-12 00:02:31 +02:00
// Build proxy with the same upstream clients and authentication as serve
2026-10-12 00:20:10 +02:00
proxy , images := server . NewMirrorProxy ( cfg , db , store , logger )
2026-09-20 14:42:30 +05:30
proxy . Denylist , err = denylist . New ( cfg . Denylist . Packages )
if err != nil {
2026-09-21 11:56:03 +01:00
_ = db . Close ( )
2026-09-20 14:42:30 +05:30
fmt . Fprintf ( os . Stderr , "invalid denylist: %v\n" , err )
2026-09-21 11:56:03 +01:00
os . Exit ( 1 ) //nolint:gocritic // db closed above
2026-09-20 14:42:30 +05:30
}
2026-03-19 21:06:02 +00:00
proxy . CacheMetadata = true // mirror always caches metadata
2026-04-06 19:30:59 +01:00
proxy . MetadataTTL = cfg . ParseMetadataTTL ( )
2026-06-02 07:59:00 +01:00
proxy . MetadataMaxSize = cfg . ParseMetadataMaxSize ( )
2026-03-19 21:06:02 +00:00
m := mirror . New ( proxy , db , store , logger , * concurrency )
2026-10-12 00:20:10 +02:00
m . Images = images
if cfg . Scanning . Enabled {
// Scanners fetch staged layers from the running server, which this
// command does not start.
m . Images = mirror . UnavailableImages { Reason : "container images cannot be mirrored by this command while scanning is enabled; use the mirror API of the running server" }
}
2026-03-19 21:06:02 +00:00
ctx , cancel := context . WithCancel ( context . Background ( ) )
go func ( ) {
sigCh := make ( chan os . Signal , 1 )
signal . Notify ( sigCh , syscall . SIGINT , syscall . SIGTERM )
<- sigCh
cancel ( )
} ( )
if * dryRun {
items , err := m . RunDryRun ( ctx , source )
if err != nil {
fmt . Fprintf ( os . Stderr , "error: %v\n" , err )
os . Exit ( 1 )
}
fmt . Printf ( "Would mirror %d package versions:\n" , len ( items ) )
for _ , item := range items {
fmt . Printf ( " %s\n" , item )
}
return
}
progress , err := m . Run ( ctx , source )
if err != nil {
fmt . Fprintf ( os . Stderr , "error: %v\n" , err )
os . Exit ( 1 )
}
fmt . Printf ( "Mirror complete: %d downloaded, %d skipped (cached), %d failed, %s total\n" ,
progress . Completed , progress . Skipped , progress . Failed , formatSize ( progress . Bytes ) )
if len ( progress . Errors ) > 0 {
fmt . Fprintf ( os . Stderr , "\nErrors:\n" )
for _ , e := range progress . Errors {
fmt . Fprintf ( os . Stderr , " %s/%s@%s: %s\n" , e . Ecosystem , e . Name , e . Version , e . Error )
}
}
}
2026-03-18 10:59:29 +00:00
func printStats ( db * database . DB , popular , recent int , asJSON bool ) error {
2026-01-20 21:52:44 +00:00
defer func ( ) { _ = db . Close ( ) } ( )
stats , err := db . GetCacheStats ( )
if err != nil {
2026-03-18 10:59:29 +00:00
return fmt . Errorf ( "error getting stats: %w" , err )
2026-01-20 21:52:44 +00:00
}
2026-03-18 10:59:29 +00:00
popularPkgs , err := db . GetMostPopularPackages ( popular )
2026-01-20 21:52:44 +00:00
if err != nil {
2026-03-18 10:59:29 +00:00
return fmt . Errorf ( "error getting popular packages: %w" , err )
2026-01-20 21:52:44 +00:00
}
2026-03-18 10:59:29 +00:00
recentPkgs , err := db . GetRecentlyCachedPackages ( recent )
2026-01-20 21:52:44 +00:00
if err != nil {
2026-03-18 10:59:29 +00:00
return fmt . Errorf ( "error getting recent packages: %w" , err )
2026-01-20 21:52:44 +00:00
}
2026-03-18 10:59:29 +00:00
if asJSON {
2026-01-20 21:52:44 +00:00
outputJSON ( stats , popularPkgs , recentPkgs )
} else {
outputText ( stats , popularPkgs , recentPkgs )
}
2026-03-18 10:59:29 +00:00
return nil
2026-01-20 21:52:44 +00:00
}
type jsonOutput struct {
Packages int64 ` json:"packages" `
Versions int64 ` json:"versions" `
Artifacts int64 ` json:"artifacts" `
TotalSize int64 ` json:"total_size_bytes" `
TotalHits int64 ` json:"total_hits" `
Ecosystems map [ string ] int64 ` json:"ecosystems" `
Popular [ ] jsonPopular ` json:"popular" `
Recent [ ] jsonRecent ` json:"recent" `
}
type jsonPopular struct {
Ecosystem string ` json:"ecosystem" `
Name string ` json:"name" `
Hits int64 ` json:"hits" `
Size int64 ` json:"size_bytes" `
}
type jsonRecent struct {
Ecosystem string ` json:"ecosystem" `
Name string ` json:"name" `
Version string ` json:"version" `
CachedAt string ` json:"cached_at" `
Size int64 ` json:"size_bytes" `
}
func outputJSON ( stats * database . CacheStats , popular [ ] database . PopularPackage , recent [ ] database . RecentPackage ) {
out := jsonOutput {
Packages : stats . TotalPackages ,
Versions : stats . TotalVersions ,
Artifacts : stats . TotalArtifacts ,
TotalSize : stats . TotalSize ,
TotalHits : stats . TotalHits ,
Ecosystems : stats . EcosystemCounts ,
Popular : make ( [ ] jsonPopular , len ( popular ) ) ,
Recent : make ( [ ] jsonRecent , len ( recent ) ) ,
}
for i , p := range popular {
out . Popular [ i ] = jsonPopular {
Ecosystem : p . Ecosystem ,
Name : p . Name ,
Hits : p . Hits ,
Size : p . Size ,
}
}
for i , r := range recent {
out . Recent [ i ] = jsonRecent {
Ecosystem : r . Ecosystem ,
Name : r . Name ,
Version : r . Version ,
CachedAt : r . CachedAt . Format ( "2006-01-02 15:04:05" ) ,
Size : r . Size ,
}
}
enc := json . NewEncoder ( os . Stdout )
enc . SetIndent ( "" , " " )
_ = enc . Encode ( out )
}
func outputText ( stats * database . CacheStats , popular [ ] database . PopularPackage , recent [ ] database . RecentPackage ) {
fmt . Printf ( "Cache Statistics\n" )
fmt . Printf ( "================\n\n" )
fmt . Printf ( "Packages: %d\n" , stats . TotalPackages )
fmt . Printf ( "Versions: %d\n" , stats . TotalVersions )
fmt . Printf ( "Artifacts: %d\n" , stats . TotalArtifacts )
fmt . Printf ( "Total size: %s\n" , formatSize ( stats . TotalSize ) )
fmt . Printf ( "Total hits: %d\n" , stats . TotalHits )
if len ( stats . EcosystemCounts ) > 0 {
fmt . Printf ( "\nPackages by ecosystem:\n" )
for eco , count := range stats . EcosystemCounts {
fmt . Printf ( " %-10s %d\n" , eco , count )
}
}
if len ( popular ) > 0 {
fmt . Printf ( "\nMost popular packages:\n" )
for i , p := range popular {
fmt . Printf ( " %2d. %s/%s (%d hits, %s)\n" , i + 1 , p . Ecosystem , p . Name , p . Hits , formatSize ( p . Size ) )
}
}
if len ( recent ) > 0 {
fmt . Printf ( "\nRecently cached:\n" )
for _ , r := range recent {
fmt . Printf ( " %s/%s@%s (%s, %s)\n" , r . Ecosystem , r . Name , r . Version , r . CachedAt . Format ( "2006-01-02 15:04" ) , formatSize ( r . Size ) )
}
}
}
func formatSize ( bytes int64 ) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
TB = GB * 1024
)
switch {
case bytes >= TB :
return fmt . Sprintf ( "%.1f TB" , float64 ( bytes ) / TB )
case bytes >= GB :
return fmt . Sprintf ( "%.1f GB" , float64 ( bytes ) / GB )
case bytes >= MB :
return fmt . Sprintf ( "%.1f MB" , float64 ( bytes ) / MB )
case bytes >= KB :
return fmt . Sprintf ( "%.1f KB" , float64 ( bytes ) / KB )
default :
return fmt . Sprintf ( "%d B" , bytes )
}
}
func loadConfig ( path string ) ( * config . Config , error ) {
if path != "" {
return config . Load ( path )
}
return config . Default ( ) , nil
}
func setupLogger ( level , format string ) * slog . Logger {
var handler slog . Handler
opts := & slog . HandlerOptions {
Level : parseLogLevel ( level ) ,
}
switch strings . ToLower ( format ) {
case "json" :
handler = slog . NewJSONHandler ( os . Stderr , opts )
default :
handler = slog . NewTextHandler ( os . Stderr , opts )
}
return slog . New ( handler )
}
func parseLogLevel ( level string ) slog . Level {
switch strings . ToLower ( level ) {
case "debug" :
return slog . LevelDebug
case "warn" :
return slog . LevelWarn
case "error" :
return slog . LevelError
default :
return slog . LevelInfo
}
}