diff --git a/README.md b/README.md index 33d839a..4609b66 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,7 @@ The proxy can be configured via: ```bash PROXY_LISTEN=:8080 PROXY_BASE_URL=http://localhost:8080 +PROXY_UI_URL=http://localhost:8080 # Optional; defaults to PROXY_BASE_URL PROXY_STORAGE_URL=file:///var/cache/proxy/artifacts PROXY_DATABASE_DRIVER=sqlite PROXY_DATABASE_PATH=./cache/proxy.db @@ -934,49 +935,47 @@ When running behind nginx, Apache, or another reverse proxy, set `base_url` to y base_url: "https://proxy.example.com" ``` -nginx example: +If the UI is reached on a different hostname than the package endpoints — for example, the UI exposed publicly on a domain while build machines hit a Docker network alias — set `ui_base_url` separately. `base_url` is the URL package managers and metadata rewriting use; `ui_base_url` is the URL advertised to humans visiting the web UI (canonical/`og:url` tags and the install guide banner): -```nginx -server { - listen 443 ssl; - server_name proxy.example.com; - - location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_buffering off; - } -} +```yaml +base_url: "http://pkg-proxy:8080" # internal alias for build machines +ui_base_url: "https://proxy.example.com/ui" # public UI URL ``` -The UI is mounted under `/ui` so you can apply different access rules to it than to the package endpoints — for example, require auth for humans browsing the UI while leaving `/npm`, `/pypi`, and other package endpoints open to unauthenticated build machines: +When unset, `ui_base_url` defaults to `base_url`. + +> **Warning:** the proxy serves the UI and package endpoints on the same listener. Setting `ui_base_url` only changes what URL the UI advertises to humans; it does not stop package endpoints from being reachable on the same hostname and port. When fronting the proxy with a public reverse proxy, restrict the public route to `PathPrefix(/ui)` (or your proxy's equivalent), otherwise `/npm`, `/pypi`, and the other package endpoints stay exposed alongside the UI. + +nginx example, restricting the public host to the UI while leaving package endpoints reachable only on the internal listener: ```nginx server { listen 443 ssl; server_name proxy.example.com; - # Web UI — require auth location /ui/ { - auth_basic "git-pkgs proxy"; - auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; } - # Package endpoints — open to build machines location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_buffering off; + return 404; } } ``` +Traefik example using `PathPrefix(/ui)` so the public router only matches UI traffic: + +```yaml +labels: + traefik.enable: "true" + traefik.http.services.pkg-proxy.loadbalancer.server.port: "8080" + traefik.http.routers.pkg-proxy.rule: "Host(`proxy.example.com`) && PathPrefix(`/ui`)" + traefik.http.routers.pkg-proxy.entrypoints: "websecure" +``` + ## Cache Management The proxy stores artifacts in the configured storage directory with this structure: diff --git a/config.example.yaml b/config.example.yaml index 11c751c..62b4105 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -4,10 +4,17 @@ # Server listen address listen: ":8080" -# Public URL where this proxy is accessible -# Used for rewriting package metadata URLs +# Public URL where package endpoints are reachable. +# Used for rewriting package metadata URLs and shown in install guide snippets +# so users know what to point their package manager at. base_url: "http://localhost:8080" +# Public URL where the web UI is reached. Defaults to base_url when unset. +# Set this separately when the UI is served on a different hostname than the +# package endpoints — for example, the UI on a public domain behind auth while +# build machines hit a Docker network alias for the package endpoints. +# ui_base_url: "https://proxy.example.com/ui" + # Artifact storage configuration storage: # Storage backend URL diff --git a/docs/configuration.md b/docs/configuration.md index 1310bd0..f220279 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,7 +17,8 @@ See `config.example.yaml` in the repository root for a complete example. | Config | Environment | Flag | Default | Description | |--------|-------------|------|---------|-------------| | `listen` | `PROXY_LISTEN` | `-listen` | `:8080` | Address to listen on | -| `base_url` | `PROXY_BASE_URL` | `-base-url` | `http://localhost:8080` | Public URL for the proxy | +| `base_url` | `PROXY_BASE_URL` | `-base-url` | `http://localhost:8080` | Public URL package managers use to reach this proxy | +| `ui_base_url` | `PROXY_UI_URL` | - | (defaults to `base_url`) | Public URL where the web UI is reached. Set separately when the UI lives behind a different hostname than package endpoints (e.g. public domain vs Docker network alias). Used for canonical/og:url tags and the install guide banner. The proxy still serves package endpoints on the same listener, so any reverse proxy fronting the UI publicly should restrict the public route to `PathPrefix(/ui)` to avoid exposing package endpoints. | ## Storage diff --git a/internal/config/config.go b/internal/config/config.go index 0e8405d..e84e887 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,11 +66,20 @@ type Config struct { // Listen is the address to listen on (e.g., ":8080", "127.0.0.1:8080"). Listen string `json:"listen" yaml:"listen"` - // BaseURL is the public URL where this proxy is accessible. - // Used for rewriting package metadata URLs. + // BaseURL is the public URL where package endpoints are reachable. + // Used for rewriting package metadata URLs and shown to humans on the + // install guide so they know what to point their package manager at. // Example: "https://proxy.example.com" or "http://localhost:8080" BaseURL string `json:"base_url" yaml:"base_url"` + // UIBaseURL is the public URL where the web UI is reachable. Defaults to + // BaseURL when unset. Set this separately when the UI is served on a + // different hostname than the package endpoints — for example, the UI on a + // public domain behind auth while build machines hit a Docker network alias + // for the package endpoints. + // Example: "https://proxy.example.com/ui" + UIBaseURL string `json:"ui_base_url" yaml:"ui_base_url"` + // Storage configures artifact storage. Storage StorageConfig `json:"storage" yaml:"storage"` @@ -365,6 +374,7 @@ func Load(path string) (*Config, error) { // Environment variables use the PROXY_ prefix: // - PROXY_LISTEN // - PROXY_BASE_URL +// - PROXY_UI_URL // - PROXY_STORAGE_PATH // - PROXY_STORAGE_MAX_SIZE // - PROXY_DATABASE_PATH @@ -378,6 +388,9 @@ func (c *Config) LoadFromEnv() { if v := os.Getenv("PROXY_BASE_URL"); v != "" { c.BaseURL = v } + if v := os.Getenv("PROXY_UI_URL"); v != "" { + c.UIBaseURL = v + } if v := os.Getenv("PROXY_STORAGE_URL"); v != "" { c.Storage.URL = v } @@ -452,6 +465,16 @@ func (c *Config) LoadFromEnv() { } } +// validateAbsoluteURL returns an error if value is not a parseable URL with +// both a scheme and host. fieldName is used in the error message. +func validateAbsoluteURL(fieldName, value string) error { + u, err := url.Parse(value) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid %s %q: must be an absolute URL", fieldName, value) + } + return nil +} + // Validate checks the configuration for errors. func (c *Config) Validate() error { if c.Listen == "" { @@ -460,6 +483,11 @@ func (c *Config) Validate() error { if c.BaseURL == "" { return fmt.Errorf("base_url is required") } + if c.UIBaseURL == "" { + c.UIBaseURL = c.BaseURL + } else if err := validateAbsoluteURL("ui_base_url", c.UIBaseURL); err != nil { + return err + } if c.Storage.URL == "" && c.Storage.Path == "" { return fmt.Errorf("storage.url or storage.path is required") } @@ -508,9 +536,8 @@ func (c *Config) Validate() error { // Validate direct serve base URL if specified if c.Storage.DirectServeBaseURL != "" { - u, err := url.Parse(c.Storage.DirectServeBaseURL) - if err != nil || u.Scheme == "" || u.Host == "" { - return fmt.Errorf("invalid storage.direct_serve_base_url %q: must be an absolute URL", c.Storage.DirectServeBaseURL) + if err := validateAbsoluteURL("storage.direct_serve_base_url", c.Storage.DirectServeBaseURL); err != nil { + return err } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d633c25..2f4fdd2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -268,6 +268,7 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("PROXY_LISTEN", ":9000") t.Setenv("PROXY_BASE_URL", "https://env.example.com") + t.Setenv("PROXY_UI_URL", "https://ui.env.example.com/ui") t.Setenv("PROXY_STORAGE_PATH", "/env/cache") t.Setenv("PROXY_LOG_LEVEL", testLevelDebug) t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public") @@ -286,6 +287,9 @@ func TestLoadFromEnv(t *testing.T) { if cfg.BaseURL != "https://env.example.com" { t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://env.example.com") } + if cfg.UIBaseURL != "https://ui.env.example.com/ui" { + t.Errorf("UIBaseURL = %q, want %q", cfg.UIBaseURL, "https://ui.env.example.com/ui") + } if cfg.Storage.Path != "/env/cache" { t.Errorf("Storage.Path = %q, want %q", cfg.Storage.Path, "/env/cache") } @@ -620,6 +624,40 @@ func TestLoadDirectServeFromEnv(t *testing.T) { } } +func TestValidateUIBaseURLDefaultsToBaseURL(t *testing.T) { + cfg := Default() + cfg.BaseURL = "https://proxy.example.com" + cfg.UIBaseURL = "" + + if err := cfg.Validate(); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + if cfg.UIBaseURL != "https://proxy.example.com" { + t.Errorf("UIBaseURL = %q, want it to default to BaseURL %q", cfg.UIBaseURL, "https://proxy.example.com") + } +} + +func TestValidateUIBaseURL(t *testing.T) { + cfg := Default() + + cfg.UIBaseURL = "not a url" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for relative ui_base_url") + } + + cfg = Default() + cfg.UIBaseURL = "://bad" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for unparseable ui_base_url") + } + + cfg = Default() + cfg.UIBaseURL = "https://ui.example.com/ui" + if err := cfg.Validate(); err != nil { + t.Errorf("unexpected error for valid ui_base_url: %v", err) + } +} + func TestValidateDirectServeBaseURL(t *testing.T) { cfg := Default() diff --git a/internal/server/browse.go b/internal/server/browse.go index ba25afc..504f5f1 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -478,6 +478,7 @@ func isLikelyText(filename string) bool { // BrowseSourceData contains data for the browse source page. type BrowseSourceData struct { + Layout Ecosystem string PackageName string Version string @@ -583,6 +584,7 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, // ComparePageData contains data for the version comparison page. type ComparePageData struct { + Layout Ecosystem string PackageName string FromVersion string diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 1de294c..797a9ca 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -8,6 +8,7 @@ import ( // DashboardData contains data for rendering the dashboard. type DashboardData struct { + Layout Stats DashboardStats EnrichmentStats EnrichmentStatsView RecentPackages []PackageInfo @@ -60,6 +61,7 @@ type RegistryConfig struct { // PackageShowData contains data for rendering the package show page. type PackageShowData struct { + Layout Package *database.Package Versions []database.Version Vulnerabilities []database.Vulnerability @@ -68,6 +70,7 @@ type PackageShowData struct { // VersionShowData contains data for rendering the version show page. type VersionShowData struct { + Layout Package *database.Package Version *database.Version Artifacts []database.Artifact @@ -79,6 +82,7 @@ type VersionShowData struct { // SearchPageData contains data for rendering the search results page. type SearchPageData struct { + Layout Query string Ecosystem string Results []SearchResultItem @@ -104,6 +108,7 @@ type SearchResultItem struct { // PackagesListPageData contains data for rendering the packages list page. type PackagesListPageData struct { + Layout Ecosystem string SortBy string Results []SearchResultItem diff --git a/internal/server/layout.go b/internal/server/layout.go new file mode 100644 index 0000000..ef39858 --- /dev/null +++ b/internal/server/layout.go @@ -0,0 +1,19 @@ +package server + +import "net/http" + +// Layout carries per-request fields consumed by the shared base template +// (canonical URL, og:url). It is embedded in every page data struct so that +// templates can reference {{.UIBaseURL}} and {{.CanonicalPath}} alongside the +// page's own fields. +type Layout struct { + UIBaseURL string + CanonicalPath string +} + +func (s *Server) layoutFor(r *http.Request) Layout { + return Layout{ + UIBaseURL: s.cfg.UIBaseURL, + CanonicalPath: r.URL.Path, + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 607cb9b..cb99648 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -300,6 +300,7 @@ func (s *Server) Start() error { s.logger.Info("starting server", "listen", s.cfg.Listen, "base_url", s.cfg.BaseURL, + "ui_url", s.cfg.UIBaseURL, "storage", s.storage.URL(), "database", s.cfg.Database.Path) go s.updateCacheStatsMetrics() @@ -402,6 +403,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { // Build dashboard data data := DashboardData{ + Layout: s.layoutFor(r), Stats: DashboardStats{ CachedArtifacts: stats.TotalArtifacts, TotalSize: formatSize(stats.TotalSize), @@ -488,8 +490,12 @@ func (s *Server) handleOpenAPIJSON(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { data := struct { + Layout + BaseURL string Registries []RegistryConfig }{ + Layout: s.layoutFor(r), + BaseURL: s.cfg.BaseURL, Registries: getRegistryConfigs(s.cfg.BaseURL), } @@ -552,6 +558,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { totalPages := int((total + int64(limit) - 1) / int64(limit)) data := SearchPageData{ + Layout: s.layoutFor(r), Query: query, Ecosystem: ecosystem, Results: items, @@ -627,6 +634,7 @@ func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) { totalPages := int((total + int64(limit) - 1) / int64(limit)) data := PackagesListPageData{ + Layout: s.layoutFor(r), Ecosystem: ecosystem, SortBy: sortBy, Results: items, @@ -670,7 +678,7 @@ func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) { if seg == "compare" && i > 0 && i < len(segments)-1 { name := strings.Join(segments[:i], "/") versions := strings.Join(segments[i+1:], "/") - s.showComparePage(w, ecosystem, name, versions) + s.showComparePage(w, r, ecosystem, name, versions) return } } @@ -690,7 +698,7 @@ func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) { // segment is a version (if present) and everything else is the name. if len(segments) == 1 { // Single segment, no DB match: try package show (will 404). - s.showPackage(w, ecosystem, segments[0]) + s.showPackage(w, r, ecosystem, segments[0]) return } name = strings.Join(segments[:len(segments)-1], "/") @@ -699,17 +707,17 @@ func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) { switch { case len(rest) == 0 && !browse: - s.showPackage(w, ecosystem, name) + s.showPackage(w, r, ecosystem, name) case len(rest) == 1 && browse: - s.showBrowseSource(w, ecosystem, name, rest[0]) + s.showBrowseSource(w, r, ecosystem, name, rest[0]) case len(rest) == 1: - s.showVersion(w, ecosystem, name, rest[0]) + s.showVersion(w, r, ecosystem, name, rest[0]) default: http.Error(w, "not found", http.StatusNotFound) } } -func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) { +func (s *Server) showPackage(w http.ResponseWriter, r *http.Request, ecosystem, name string) { pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name) if err != nil { s.logger.Error("failed to get package", "error", err, "ecosystem", ecosystem, "name", name) @@ -734,6 +742,7 @@ func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) { } data := PackageShowData{ + Layout: s.layoutFor(r), Package: pkg, Versions: versions, Vulnerabilities: vulns, @@ -745,7 +754,7 @@ func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) { } } -func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version string) { +func (s *Server) showVersion(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name) if err != nil || pkg == nil { s.logger.Error("failed to get package", "error", err) @@ -784,6 +793,7 @@ func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version str } data := VersionShowData{ + Layout: s.layoutFor(r), Package: pkg, Version: ver, Artifacts: artifacts, @@ -798,8 +808,9 @@ func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version str } } -func (s *Server) showBrowseSource(w http.ResponseWriter, ecosystem, name, version string) { +func (s *Server) showBrowseSource(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) { data := BrowseSourceData{ + Layout: s.layoutFor(r), Ecosystem: ecosystem, PackageName: name, Version: version, @@ -811,7 +822,7 @@ func (s *Server) showBrowseSource(w http.ResponseWriter, ecosystem, name, versio } } -func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, versions string) { +func (s *Server) showComparePage(w http.ResponseWriter, r *http.Request, ecosystem, name, versions string) { const compareVersionParts = 2 parts := strings.Split(versions, "...") if len(parts) != compareVersionParts { @@ -820,6 +831,7 @@ func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, version } data := ComparePageData{ + Layout: s.layoutFor(r), Ecosystem: ecosystem, PackageName: name, FromVersion: parts[0], diff --git a/internal/server/templates/layout/base.html b/internal/server/templates/layout/base.html index a7d03cc..aa028d8 100644 --- a/internal/server/templates/layout/base.html +++ b/internal/server/templates/layout/base.html @@ -5,6 +5,12 @@ {{block "title" .}}git-pkgs proxy{{end}} + {{if .UIBaseURL}} + + + + + {{end}}