Skip to content

Commit 9614bbd

Browse files
Add CDN logic, mTLS ability for CDNs, and Branding for webpage
1 parent 5c9b188 commit 9614bbd

18 files changed

Lines changed: 1234 additions & 214 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# local mTLS client certificates (e.g. Red Hat entitlements)
2+
*.pem
3+
14
# build and testing artifacts
25
/bin/
36
/cache/

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- Per-repository `cdn` config field to proxy vendor CDNs that publish no public mirrors
12+
- Per-repository `mtls` config field with client `cert`, `key` and optional `ca` for CDNs using mutual TLS
13+
- Support for proxying entitled Red Hat content from `cdn.redhat.com` without client-side certificates
14+
- Client config snippet for Red Hat Enterprise Linux on the landing page and in the README
15+
- Top-level `branding` config field to customize the landing page title and description
16+
- Landing page now shows the running pkgproxy version
1117
- Container image now runs `serve` by default and loads bundled config from `$KO_DATA_PATH`
1218
- `PKGPROXY_TRUST_PROXY` env var (and `--trust-proxy` flag) to opt in to X-Forwarded-For trust
1319
- `PKGPROXY_HOST` env var to set the listen address without passing `--host` on the command line
1420

1521
### Changed
1622

23+
- Repositories must now define exactly one of `mirrors` or `cdn`; setting both is rejected
24+
- Upstream URLs are validated at startup: they must be absolute and use `http` or `https`
1725
- **Breaking:** `remote_ip` in access logs now reflects the direct connecting peer by default; set `PKGPROXY_TRUST_PROXY` to restore XFF-based IP extraction when running behind a reverse proxy
26+
- **Breaking:** Removed the `--public-host` flag and `PKGPROXY_PUBLIC_HOST` env var; the landing page now fills in config snippet hostnames automatically — server-side from the request's `Host` header (works for `curl` too), further corrected client-side to the browser's own URL when that differs (e.g. behind a TLS-terminating reverse proxy)
1827
- Upgraded Echo web framework to v5.1.1
1928
- Config-file errors now list all default paths attempted, not just the last one
2029

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ $(if $(filter debian,$(1)),TestDebian,\
113113
$(if $(filter ubuntu,$(1)),TestUbuntu,\
114114
$(if $(filter archlinux,$(1)),TestArch,\
115115
$(if $(filter gentoo,$(1)),TestGentoo,\
116-
$(error Unknown DISTRO: $(1). Use one of: fedora centos-stream almalinux rockylinux debian ubuntu archlinux gentoo))))))))))
116+
$(if $(filter rhel,$(1)),TestRHEL,\
117+
$(error Unknown DISTRO: $(1). Use one of: fedora centos-stream almalinux rockylinux debian ubuntu archlinux gentoo rhel)))))))))))
117118
endef
118119

119120
.PHONY: e2e
@@ -157,7 +158,7 @@ run: format vet generate ## Run the application from your host
157158
$(info *************************************************)
158159
$(info ********** EXECUTING 'run' MAKE TARGET **********)
159160
$(info *************************************************)
160-
PKGPROXY_CONFIG=./configs/pkgproxy.yaml PKGPROXY_PUBLIC_HOST=$(shell hostname):8080 CGO_ENABLED=$(CGO_ENABLED) go run . serve --host 0.0.0.0 --debug
161+
PKGPROXY_CONFIG=./configs/pkgproxy.yaml CGO_ENABLED=$(CGO_ENABLED) go run . serve --host 0.0.0.0 --debug
161162

162163
PLATFORMS := $(shell echo $(ARCHS) | sed 's/,/ /g' | sed 's/[^ ]\+/linux\/&/g' | tr ' ' ',')
163164

README.md

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,32 @@ podman run --rm -p 8080:8080 -e PKGPROXY_HOST=0.0.0.0 --volume ./cache:/ko-app/c
3333
| `--cachedir` | | `cache` | Path to the local cache directory |
3434
| `--host` | `PKGPROXY_HOST` | `localhost` | Listen address |
3535
| `--port` | | `8080` | Listen port |
36-
| `--public-host` | `PKGPROXY_PUBLIC_HOST` | | Public hostname (or `host:port`) shown in landing page config snippets. When set, the listen port is not appended. Useful when running behind a reverse proxy. |
3736
| `--trust-proxy` | `PKGPROXY_TRUST_PROXY` | | Comma-separated list of trusted proxy sources for X-Forwarded-For. Accepted values: `none`, `loopback`, `private`, a CIDR (e.g. `10.0.0.0/8`), or a bare IP (promoted to `/32`/`/128`). Unset or empty means no XFF trust. |
3837
| `--debug` | | `false` | Enable debug logging |
3938

4039
Any flag with an env variable listed above can be set via the environment instead of passing the flag.
4140

41+
### Landing page hostname
42+
43+
The config snippets shown on the landing page (`GET /`) need pkgproxy's own
44+
address, e.g. `baseurl=http://<pkgproxy>/fedora/...`. Rather than relying on a
45+
server-side setting, this is filled in automatically, with no configuration
46+
needed:
47+
48+
- **Server-side, from the request's `Host` header.** Every response — including
49+
`curl` and other non-browser clients — already contains a working address
50+
built from the `Host` header the request itself carried (the same header a
51+
reverse proxy forwards by default). No JavaScript required.
52+
- **Client-side, from the page's own URL.** In a browser, a small inline script
53+
additionally corrects the address to `window.location.origin` if it differs
54+
from the server-rendered one — which matters behind a reverse proxy that
55+
changes the scheme (e.g. TLS termination), since the `Host` header alone
56+
can't reveal that.
57+
58+
If a reverse proxy in front of pkgproxy does not forward the original `Host`
59+
header, `curl` (or a browser with JavaScript disabled) will see whatever host
60+
pkgproxy itself observed instead.
61+
4262
### Trusting X-Forwarded-For
4363

4464
By default pkgproxy ignores the `X-Forwarded-For` header and uses the direct connecting IP address for the `remote_ip` access-log field. This is the safe behavior when pkgproxy faces the internet directly or runs in a container without a reverse proxy in front of it.
@@ -66,8 +86,94 @@ Each repository supports the following options:
6686
|-----|----------|-------------|
6787
| `suffixes` | yes | File suffixes that are eligible for caching (e.g. `.rpm`, `.deb`). Use `"*"` to cache all files. |
6888
| `exclude` | no | List of file names to exclude from caching, even when they match a suffix. Useful with the `"*"` wildcard suffix. |
69-
| `mirrors` | yes | Ordered list of upstream mirror URLs |
70-
| `retries` | no | Number of attempts per mirror before moving to the next one (default: `1`) |
89+
| `mirrors` | yes* | Ordered list of upstream mirror URLs |
90+
| `cdn` | yes* | Single upstream CDN URL, used instead of `mirrors` |
91+
| `mtls` | no | Client certificate (`cert`), private key (`key`) and optional CA bundle (`ca`) used with a `cdn` requiring mutual TLS |
92+
| `retries` | no | Number of attempts per upstream before moving to the next one (default: `1`) |
93+
94+
\* Each repository must define exactly one of `mirrors` or `cdn`; setting both is rejected.
95+
96+
### Landing page branding
97+
98+
The top-level `branding` key customizes the title and description shown on the
99+
landing page (and the HTML `<title>`) served at `/`:
100+
101+
```yaml
102+
branding:
103+
title: Acme Package Mirror
104+
description: Internal package cache for Acme Corp.
105+
106+
repositories:
107+
...
108+
```
109+
110+
Both fields are optional and independent — omitting `branding` entirely, or
111+
leaving one of the two fields unset, falls back to the default "pkgproxy" title
112+
and "Caching forward proxy for Linux package repositories." description. The
113+
landing page also always shows the running pkgproxy version below the
114+
description.
115+
116+
### CDN upstreams
117+
118+
Some vendors do not publish public mirrors and serve their packages from a single
119+
CDN instead. Use `cdn` in place of `mirrors` for those repositories:
120+
121+
```yaml
122+
repositories:
123+
rhel:
124+
suffixes:
125+
- .rpm
126+
cdn: https://cdn.redhat.com/
127+
```
128+
129+
Requests are mapped the same way as for mirrors: the repository name is stripped
130+
from the request path and the remainder is appended to the CDN URL, so
131+
`/rhel/content/dist/rhel9/9/x86_64/baseos/os/` is fetched from
132+
`https://cdn.redhat.com/content/dist/rhel9/9/x86_64/baseos/os/`.
133+
134+
### CDN client certificates (mTLS)
135+
136+
When the CDN requires mutual TLS — as the Red Hat CDN does for entitled content —
137+
add an `mtls` block with the client certificate and its private key. pkgproxy
138+
presents them during the TLS handshake with the CDN:
139+
140+
```yaml
141+
repositories:
142+
rhel:
143+
suffixes:
144+
- .drpm
145+
- .rpm
146+
cdn: https://cdn.redhat.com/
147+
mtls:
148+
cert: /etc/pki/entitlement/1234567890123456789.pem
149+
key: /etc/pki/entitlement/1234567890123456789-key.pem
150+
ca: /etc/rhsm/ca/redhat-uep.pem
151+
```
152+
153+
On a subscribed Red Hat host the entitlement certificate and its key are the
154+
`.pem` file pair in `/etc/pki/entitlement/`.
155+
156+
The optional `ca` points at a CA bundle used to verify the CDN's *own* server
157+
certificate, and is added to the system trust store rather than replacing it.
158+
It is required for `cdn.redhat.com`, whose certificate is issued by a private
159+
Red Hat CA that public trust stores do not contain — without it every request
160+
fails with `x509: certificate signed by unknown authority`.
161+
162+
Notes:
163+
164+
- `mtls` is only valid together with `cdn`, and both `cert` and `key` are required.
165+
- Relative paths are resolved against the working directory of the pkgproxy
166+
process. Prefer absolute paths, especially for container deployments.
167+
- pkgproxy **refuses to start** if the certificate, key or CA bundle cannot be
168+
loaded. Proxying without them would only produce opaque TLS or authorization
169+
errors from the CDN.
170+
- The certificate is scoped to the configured CDN host. If the CDN redirects to a
171+
different host, the redirect is followed **without** the client certificate so
172+
the credential is never sent elsewhere.
173+
- Clients talking to pkgproxy need no entitlement certificate of their own — this
174+
is the point of proxying an entitled CDN for a local network. Protect access to
175+
pkgproxy accordingly, since it will serve entitled content to anyone who can
176+
reach it.
71177

72178
### Mirror retries
73179

@@ -214,6 +320,21 @@ For Enterprise distributions the URL suffix `epel-$releasever-$basearch` must be
214320
baseurl=http://<pkgproxy>:8080/rockylinux/$releasever/BaseOS/$basearch/os/
215321
```
216322

323+
### Red Hat Enterprise Linux
324+
325+
Requires a `rhel` repository configured with `cdn` and `mtls` (see [CDN client
326+
certificates](#cdn-client-certificates-mtls)). Disable the subscription-manager
327+
managed repositories, then e.g. `/etc/yum.repos.d/rhel.repo` (adjust other
328+
repositories accordingly):
329+
```
330+
[rhel-baseos-rpms]
331+
# baseurl=https://cdn.redhat.com/content/dist/rhel$releasever/$releasever/$basearch/baseos/os
332+
baseurl=http://<pkgproxy>:8080/rhel/content/dist/rhel$releasever/$releasever/$basearch/baseos/os
333+
```
334+
335+
The client needs no `sslclientcert`/`sslclientkey` of its own — pkgproxy holds the
336+
entitlement certificate and authenticates against the CDN on the client's behalf.
337+
217338
### Ubuntu
218339

219340
E.g. Ubuntu 24.04 Noble Numbat: `/etc/apt/sources.list` (substitute your release codename):

cmd/config_test.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,20 @@ func writeConfig(t *testing.T, dir, name string) string {
2424

2525
func TestResolveConfigPath(t *testing.T) {
2626
tests := []struct {
27-
name string
28-
localExists bool
29-
localIsDir bool
30-
koDataSet bool
31-
koFileExists bool
32-
wantPath func(koDir string) string
33-
wantCandidates func(koDir string) []string
27+
name string
28+
localExists bool
29+
localIsDir bool
30+
koDataSet bool
31+
koFileExists bool
32+
wantPath func(koDir string) string
33+
wantCandidates func(koDir string) []string
3434
}{
3535
{
36-
name: "local file wins over ko fallback",
37-
localExists: true,
38-
koDataSet: true,
39-
koFileExists: true,
40-
wantPath: func(_ string) string { return defaultConfigPath },
36+
name: "local file wins over ko fallback",
37+
localExists: true,
38+
koDataSet: true,
39+
koFileExists: true,
40+
wantPath: func(_ string) string { return defaultConfigPath },
4141
wantCandidates: func(_ string) []string { return []string{defaultConfigPath} },
4242
},
4343
{
@@ -51,10 +51,10 @@ func TestResolveConfigPath(t *testing.T) {
5151
},
5252
},
5353
{
54-
name: "both missing returns default path",
55-
localExists: false,
56-
koDataSet: false,
57-
wantPath: func(_ string) string { return defaultConfigPath },
54+
name: "both missing returns default path",
55+
localExists: false,
56+
koDataSet: false,
57+
wantPath: func(_ string) string { return defaultConfigPath },
5858
wantCandidates: func(_ string) []string { return []string{defaultConfigPath} },
5959
},
6060
{

cmd/serve.go

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import (
2525
var (
2626
listenAddress string
2727
listenPort uint16
28-
publicHost string
2928
trustProxy string
3029
ipExtractor echo.IPExtractor
3130
resolvedTrustProxy string
@@ -35,7 +34,6 @@ const (
3534
defaultAddress = "localhost"
3635
defaultPort = 8080
3736
hostEnvVar = "PKGPROXY_HOST"
38-
publicHostEnvVar = "PKGPROXY_PUBLIC_HOST"
3937
trustProxyEnvVar = "PKGPROXY_TRUST_PROXY"
4038
)
4139

@@ -59,7 +57,6 @@ func newServeCommand() *cobra.Command {
5957
}
6058
c.PersistentFlags().StringVar(&listenAddress, "host", defaultAddress, "listen address of the pkgproxy.")
6159
c.PersistentFlags().Uint16Var(&listenPort, "port", defaultPort, "listen port of the pkgproxy.")
62-
c.PersistentFlags().StringVar(&publicHost, "public-host", "", "public hostname (or host:port) shown in landing page config snippets; overrides PKGPROXY_PUBLIC_HOST.")
6360
c.PersistentFlags().StringVar(&trustProxy, "trust-proxy", "", "comma-separated list of trusted proxy addresses for X-Forwarded-For: none, loopback, private, CIDR, or IP; overrides PKGPROXY_TRUST_PROXY.")
6461

6562
return c
@@ -76,19 +73,6 @@ func resolveListenHost(flagChanged bool, flagValue, envValue string) string {
7673
return defaultAddress
7774
}
7875

79-
// resolvePublicAddr determines the address rendered in landing page config snippets.
80-
// The CLI flag takes precedence over the environment variable. If neither is set,
81-
// the listen host:port is used.
82-
func resolvePublicAddr(flagValue string, listenAddr string, port uint16) string {
83-
if flagValue != "" {
84-
return flagValue
85-
}
86-
if v := os.Getenv(publicHostEnvVar); v != "" {
87-
return v
88-
}
89-
return fmt.Sprintf("%s:%d", listenAddr, port)
90-
}
91-
9276
// resolveTrustProxy determines the trust-proxy value using flag → env var → default precedence.
9377
func resolveTrustProxy(flagChanged bool, flagValue, envValue string) string {
9478
if flagChanged {
@@ -221,12 +205,14 @@ func startServer(_ *cobra.Command, _ []string) error {
221205
})
222206
app.Use(middleware.Recover())
223207

224-
pkgProxy := pkgproxy.New(&pkgproxy.PkgProxyConfig{
208+
pkgProxy, err := pkgproxy.New(&pkgproxy.PkgProxyConfig{
225209
CacheBasePath: cacheDir,
226210
RepositoryConfig: &repoConfig,
227211
})
228-
publicAddr := resolvePublicAddr(publicHost, listenAddress, listenPort)
229-
app.GET("/", pkgproxy.LandingHandler(&repoConfig, publicAddr))
212+
if err != nil {
213+
return fmt.Errorf("unable to initialize proxy: %w", err)
214+
}
215+
app.GET("/", pkgproxy.LandingHandler(&repoConfig, Version))
230216
app.Use(pkgProxy.Cache)
231217
app.Use(pkgProxy.ForwardProxy)
232218

cmd/serve_test.go

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -275,54 +275,3 @@ func TestParseTrustProxy(t *testing.T) {
275275
})
276276
}
277277
}
278-
279-
func TestResolvePublicAddr(t *testing.T) {
280-
tests := []struct {
281-
name string
282-
flagValue string
283-
envValue string
284-
listenAddr string
285-
listenPort uint16
286-
want string
287-
}{
288-
{
289-
name: "flag takes precedence over env var",
290-
flagValue: "myproxy.lan",
291-
envValue: "other.host",
292-
listenAddr: "localhost",
293-
listenPort: 8080,
294-
want: "myproxy.lan",
295-
},
296-
{
297-
name: "env var used when flag is empty",
298-
flagValue: "",
299-
envValue: "myproxy.lan",
300-
listenAddr: "localhost",
301-
listenPort: 8080,
302-
want: "myproxy.lan",
303-
},
304-
{
305-
name: "defaults to listen host:port when neither is set",
306-
flagValue: "",
307-
envValue: "",
308-
listenAddr: "localhost",
309-
listenPort: 8080,
310-
want: "localhost:8080",
311-
},
312-
{
313-
name: "flag with embedded port used verbatim",
314-
flagValue: "myproxy.lan:9090",
315-
envValue: "",
316-
listenAddr: "localhost",
317-
listenPort: 8080,
318-
want: "myproxy.lan:9090",
319-
},
320-
}
321-
for _, tt := range tests {
322-
t.Run(tt.name, func(t *testing.T) {
323-
t.Setenv(publicHostEnvVar, tt.envValue)
324-
got := resolvePublicAddr(tt.flagValue, tt.listenAddr, tt.listenPort)
325-
assert.Equal(t, tt.want, got)
326-
})
327-
}
328-
}

configs/pkgproxy.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
---
2+
branding:
3+
title: Pkgproxy Application
4+
description: Caching forward proxy for Linux package repositories
5+
26
repositories:
37
almalinux:
48
suffixes:
@@ -87,6 +91,15 @@ repositories:
8791
- https://mirror.init7.net/rockylinux/
8892
- https://mirror.puzzle.ch/rockylinux/
8993
- https://dl.rockylinux.org/pub/rocky/
94+
rhel:
95+
suffixes:
96+
- .drpm
97+
- .rpm
98+
cdn: https://cdn.redhat.com/
99+
mtls:
100+
cert: entitlement.pem
101+
key: entitlement-key.pem
102+
ca: /etc/rhsm/ca/redhat-uep.pem
90103
ubuntu:
91104
suffixes:
92105
- .deb

0 commit comments

Comments
 (0)