Skip to content

Commit da4fa2f

Browse files
authored
Add PROXY_UI_URL for separate UI/package URL advertisement (#161)
Adds UIBaseURL (env PROXY_UI_URL), advertised separately from BaseURL for deployments where the UI is reached on a public domain while build machines hit a Docker network alias for the package endpoints. Defaults to BaseURL when unset. Consumers: - Logged alongside base_url at startup. - <link rel="canonical"> and og:url / og:title / og:site_name in every UI page, omitted when UIBaseURL is empty. - Banner on the install guide when UIBaseURL differs from BaseURL, clarifying that the URLs in the snippets are the package endpoint and that the UI itself lives elsewhere. Docs call out that the proxy serves UI and package endpoints on the same listener, so reverse proxies fronting the UI publicly must restrict the public route to PathPrefix(/ui) to avoid exposing /npm, /pypi, etc. nginx and Traefik examples both show the path-split pattern.
1 parent 08d975c commit da4fa2f

12 files changed

Lines changed: 272 additions & 40 deletions

File tree

README.md

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ The proxy can be configured via:
424424
```bash
425425
PROXY_LISTEN=:8080
426426
PROXY_BASE_URL=http://localhost:8080
427+
PROXY_UI_URL=http://localhost:8080 # Optional; defaults to PROXY_BASE_URL
427428
PROXY_STORAGE_URL=file:///var/cache/proxy/artifacts
428429
PROXY_DATABASE_DRIVER=sqlite
429430
PROXY_DATABASE_PATH=./cache/proxy.db
@@ -934,49 +935,47 @@ When running behind nginx, Apache, or another reverse proxy, set `base_url` to y
934935
base_url: "https://proxy.example.com"
935936
```
936937

937-
nginx example:
938+
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):
938939

939-
```nginx
940-
server {
941-
listen 443 ssl;
942-
server_name proxy.example.com;
943-
944-
location / {
945-
proxy_pass http://127.0.0.1:8080;
946-
proxy_set_header Host $host;
947-
proxy_set_header X-Real-IP $remote_addr;
948-
proxy_buffering off;
949-
}
950-
}
940+
```yaml
941+
base_url: "http://pkg-proxy:8080" # internal alias for build machines
942+
ui_base_url: "https://proxy.example.com/ui" # public UI URL
951943
```
952944

953-
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:
945+
When unset, `ui_base_url` defaults to `base_url`.
946+
947+
> **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.
948+
949+
nginx example, restricting the public host to the UI while leaving package endpoints reachable only on the internal listener:
954950

955951
```nginx
956952
server {
957953
listen 443 ssl;
958954
server_name proxy.example.com;
959955
960-
# Web UI — require auth
961956
location /ui/ {
962-
auth_basic "git-pkgs proxy";
963-
auth_basic_user_file /etc/nginx/.htpasswd;
964957
proxy_pass http://127.0.0.1:8080;
965958
proxy_set_header Host $host;
966959
proxy_set_header X-Real-IP $remote_addr;
967960
proxy_buffering off;
968961
}
969962
970-
# Package endpoints — open to build machines
971963
location / {
972-
proxy_pass http://127.0.0.1:8080;
973-
proxy_set_header Host $host;
974-
proxy_set_header X-Real-IP $remote_addr;
975-
proxy_buffering off;
964+
return 404;
976965
}
977966
}
978967
```
979968

969+
Traefik example using `PathPrefix(/ui)` so the public router only matches UI traffic:
970+
971+
```yaml
972+
labels:
973+
traefik.enable: "true"
974+
traefik.http.services.pkg-proxy.loadbalancer.server.port: "8080"
975+
traefik.http.routers.pkg-proxy.rule: "Host(`proxy.example.com`) && PathPrefix(`/ui`)"
976+
traefik.http.routers.pkg-proxy.entrypoints: "websecure"
977+
```
978+
980979
## Cache Management
981980
982981
The proxy stores artifacts in the configured storage directory with this structure:

config.example.yaml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@
44
# Server listen address
55
listen: ":8080"
66

7-
# Public URL where this proxy is accessible
8-
# Used for rewriting package metadata URLs
7+
# Public URL where package endpoints are reachable.
8+
# Used for rewriting package metadata URLs and shown in install guide snippets
9+
# so users know what to point their package manager at.
910
base_url: "http://localhost:8080"
1011

12+
# Public URL where the web UI is reached. Defaults to base_url when unset.
13+
# Set this separately when the UI is served on a different hostname than the
14+
# package endpoints — for example, the UI on a public domain behind auth while
15+
# build machines hit a Docker network alias for the package endpoints.
16+
# ui_base_url: "https://proxy.example.com/ui"
17+
1118
# Artifact storage configuration
1219
storage:
1320
# Storage backend URL

docs/configuration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ See `config.example.yaml` in the repository root for a complete example.
1717
| Config | Environment | Flag | Default | Description |
1818
|--------|-------------|------|---------|-------------|
1919
| `listen` | `PROXY_LISTEN` | `-listen` | `:8080` | Address to listen on |
20-
| `base_url` | `PROXY_BASE_URL` | `-base-url` | `http://localhost:8080` | Public URL for the proxy |
20+
| `base_url` | `PROXY_BASE_URL` | `-base-url` | `http://localhost:8080` | Public URL package managers use to reach this proxy |
21+
| `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. |
2122

2223
## Storage
2324

internal/config/config.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,20 @@ type Config struct {
6666
// Listen is the address to listen on (e.g., ":8080", "127.0.0.1:8080").
6767
Listen string `json:"listen" yaml:"listen"`
6868

69-
// BaseURL is the public URL where this proxy is accessible.
70-
// Used for rewriting package metadata URLs.
69+
// BaseURL is the public URL where package endpoints are reachable.
70+
// Used for rewriting package metadata URLs and shown to humans on the
71+
// install guide so they know what to point their package manager at.
7172
// Example: "https://proxy.example.com" or "http://localhost:8080"
7273
BaseURL string `json:"base_url" yaml:"base_url"`
7374

75+
// UIBaseURL is the public URL where the web UI is reachable. Defaults to
76+
// BaseURL when unset. Set this separately when the UI is served on a
77+
// different hostname than the package endpoints — for example, the UI on a
78+
// public domain behind auth while build machines hit a Docker network alias
79+
// for the package endpoints.
80+
// Example: "https://proxy.example.com/ui"
81+
UIBaseURL string `json:"ui_base_url" yaml:"ui_base_url"`
82+
7483
// Storage configures artifact storage.
7584
Storage StorageConfig `json:"storage" yaml:"storage"`
7685

@@ -365,6 +374,7 @@ func Load(path string) (*Config, error) {
365374
// Environment variables use the PROXY_ prefix:
366375
// - PROXY_LISTEN
367376
// - PROXY_BASE_URL
377+
// - PROXY_UI_URL
368378
// - PROXY_STORAGE_PATH
369379
// - PROXY_STORAGE_MAX_SIZE
370380
// - PROXY_DATABASE_PATH
@@ -378,6 +388,9 @@ func (c *Config) LoadFromEnv() {
378388
if v := os.Getenv("PROXY_BASE_URL"); v != "" {
379389
c.BaseURL = v
380390
}
391+
if v := os.Getenv("PROXY_UI_URL"); v != "" {
392+
c.UIBaseURL = v
393+
}
381394
if v := os.Getenv("PROXY_STORAGE_URL"); v != "" {
382395
c.Storage.URL = v
383396
}
@@ -452,6 +465,16 @@ func (c *Config) LoadFromEnv() {
452465
}
453466
}
454467

468+
// validateAbsoluteURL returns an error if value is not a parseable URL with
469+
// both a scheme and host. fieldName is used in the error message.
470+
func validateAbsoluteURL(fieldName, value string) error {
471+
u, err := url.Parse(value)
472+
if err != nil || u.Scheme == "" || u.Host == "" {
473+
return fmt.Errorf("invalid %s %q: must be an absolute URL", fieldName, value)
474+
}
475+
return nil
476+
}
477+
455478
// Validate checks the configuration for errors.
456479
func (c *Config) Validate() error {
457480
if c.Listen == "" {
@@ -460,6 +483,11 @@ func (c *Config) Validate() error {
460483
if c.BaseURL == "" {
461484
return fmt.Errorf("base_url is required")
462485
}
486+
if c.UIBaseURL == "" {
487+
c.UIBaseURL = c.BaseURL
488+
} else if err := validateAbsoluteURL("ui_base_url", c.UIBaseURL); err != nil {
489+
return err
490+
}
463491
if c.Storage.URL == "" && c.Storage.Path == "" {
464492
return fmt.Errorf("storage.url or storage.path is required")
465493
}
@@ -508,9 +536,8 @@ func (c *Config) Validate() error {
508536

509537
// Validate direct serve base URL if specified
510538
if c.Storage.DirectServeBaseURL != "" {
511-
u, err := url.Parse(c.Storage.DirectServeBaseURL)
512-
if err != nil || u.Scheme == "" || u.Host == "" {
513-
return fmt.Errorf("invalid storage.direct_serve_base_url %q: must be an absolute URL", c.Storage.DirectServeBaseURL)
539+
if err := validateAbsoluteURL("storage.direct_serve_base_url", c.Storage.DirectServeBaseURL); err != nil {
540+
return err
514541
}
515542
}
516543

internal/config/config_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ func TestLoadFromEnv(t *testing.T) {
268268

269269
t.Setenv("PROXY_LISTEN", ":9000")
270270
t.Setenv("PROXY_BASE_URL", "https://env.example.com")
271+
t.Setenv("PROXY_UI_URL", "https://ui.env.example.com/ui")
271272
t.Setenv("PROXY_STORAGE_PATH", "/env/cache")
272273
t.Setenv("PROXY_LOG_LEVEL", testLevelDebug)
273274
t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public")
@@ -286,6 +287,9 @@ func TestLoadFromEnv(t *testing.T) {
286287
if cfg.BaseURL != "https://env.example.com" {
287288
t.Errorf("BaseURL = %q, want %q", cfg.BaseURL, "https://env.example.com")
288289
}
290+
if cfg.UIBaseURL != "https://ui.env.example.com/ui" {
291+
t.Errorf("UIBaseURL = %q, want %q", cfg.UIBaseURL, "https://ui.env.example.com/ui")
292+
}
289293
if cfg.Storage.Path != "/env/cache" {
290294
t.Errorf("Storage.Path = %q, want %q", cfg.Storage.Path, "/env/cache")
291295
}
@@ -620,6 +624,40 @@ func TestLoadDirectServeFromEnv(t *testing.T) {
620624
}
621625
}
622626

627+
func TestValidateUIBaseURLDefaultsToBaseURL(t *testing.T) {
628+
cfg := Default()
629+
cfg.BaseURL = "https://proxy.example.com"
630+
cfg.UIBaseURL = ""
631+
632+
if err := cfg.Validate(); err != nil {
633+
t.Fatalf("unexpected validation error: %v", err)
634+
}
635+
if cfg.UIBaseURL != "https://proxy.example.com" {
636+
t.Errorf("UIBaseURL = %q, want it to default to BaseURL %q", cfg.UIBaseURL, "https://proxy.example.com")
637+
}
638+
}
639+
640+
func TestValidateUIBaseURL(t *testing.T) {
641+
cfg := Default()
642+
643+
cfg.UIBaseURL = "not a url"
644+
if err := cfg.Validate(); err == nil {
645+
t.Error("expected validation error for relative ui_base_url")
646+
}
647+
648+
cfg = Default()
649+
cfg.UIBaseURL = "://bad"
650+
if err := cfg.Validate(); err == nil {
651+
t.Error("expected validation error for unparseable ui_base_url")
652+
}
653+
654+
cfg = Default()
655+
cfg.UIBaseURL = "https://ui.example.com/ui"
656+
if err := cfg.Validate(); err != nil {
657+
t.Errorf("unexpected error for valid ui_base_url: %v", err)
658+
}
659+
}
660+
623661
func TestValidateDirectServeBaseURL(t *testing.T) {
624662
cfg := Default()
625663

internal/server/browse.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ func isLikelyText(filename string) bool {
478478

479479
// BrowseSourceData contains data for the browse source page.
480480
type BrowseSourceData struct {
481+
Layout
481482
Ecosystem string
482483
PackageName string
483484
Version string
@@ -583,6 +584,7 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
583584

584585
// ComparePageData contains data for the version comparison page.
585586
type ComparePageData struct {
587+
Layout
586588
Ecosystem string
587589
PackageName string
588590
FromVersion string

internal/server/dashboard.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
// DashboardData contains data for rendering the dashboard.
1010
type DashboardData struct {
11+
Layout
1112
Stats DashboardStats
1213
EnrichmentStats EnrichmentStatsView
1314
RecentPackages []PackageInfo
@@ -60,6 +61,7 @@ type RegistryConfig struct {
6061

6162
// PackageShowData contains data for rendering the package show page.
6263
type PackageShowData struct {
64+
Layout
6365
Package *database.Package
6466
Versions []database.Version
6567
Vulnerabilities []database.Vulnerability
@@ -68,6 +70,7 @@ type PackageShowData struct {
6870

6971
// VersionShowData contains data for rendering the version show page.
7072
type VersionShowData struct {
73+
Layout
7174
Package *database.Package
7275
Version *database.Version
7376
Artifacts []database.Artifact
@@ -79,6 +82,7 @@ type VersionShowData struct {
7982

8083
// SearchPageData contains data for rendering the search results page.
8184
type SearchPageData struct {
85+
Layout
8286
Query string
8387
Ecosystem string
8488
Results []SearchResultItem
@@ -104,6 +108,7 @@ type SearchResultItem struct {
104108

105109
// PackagesListPageData contains data for rendering the packages list page.
106110
type PackagesListPageData struct {
111+
Layout
107112
Ecosystem string
108113
SortBy string
109114
Results []SearchResultItem

internal/server/layout.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package server
2+
3+
import "net/http"
4+
5+
// Layout carries per-request fields consumed by the shared base template
6+
// (canonical URL, og:url). It is embedded in every page data struct so that
7+
// templates can reference {{.UIBaseURL}} and {{.CanonicalPath}} alongside the
8+
// page's own fields.
9+
type Layout struct {
10+
UIBaseURL string
11+
CanonicalPath string
12+
}
13+
14+
func (s *Server) layoutFor(r *http.Request) Layout {
15+
return Layout{
16+
UIBaseURL: s.cfg.UIBaseURL,
17+
CanonicalPath: r.URL.Path,
18+
}
19+
}

0 commit comments

Comments
 (0)