Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ podman run --rm -p 8080:8080 -e PKGPROXY_HOST=0.0.0.0 --volume ./cache:/ko-app/c

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

### Landing page hostname

The config snippets shown on the landing page (`GET /`) need pkgproxy's own
address, e.g. `baseurl=http://<pkgproxy>/fedora/...`. Rather than relying on a
server-side setting, this is filled in automatically, with no configuration
needed:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mentioned this before but I don't really see how this is true with the code of this PR. I also don't really think that this is something that I would want as I often use browsers that don't support Javascript.

I think this entire section of the README.md is not needed.


- **Server-side, from the request's `Host` header.** Every response — including
`curl` and other non-browser clients — already contains a working address
built from the `Host` header the request itself carried (the same header a
reverse proxy forwards by default). No JavaScript required.
- **Client-side, from the page's own URL.** In a browser, a small inline script
additionally corrects the address to `window.location.origin` if it differs
from the server-rendered one — which matters behind a reverse proxy that
changes the scheme (e.g. TLS termination), since the `Host` header alone
can't reveal that.

If a reverse proxy in front of pkgproxy does not forward the original `Host`
header, `curl` (or a browser with JavaScript disabled) will see whatever host
pkgproxy itself observed instead.

### Trusting X-Forwarded-For

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.
Expand Down Expand Up @@ -69,6 +90,26 @@ Each repository supports the following options:
| `mirrors` | yes | Ordered list of upstream mirror URLs |
| `retries` | no | Number of attempts per mirror before moving to the next one (default: `1`) |

### Landing page branding

The top-level `branding` key customizes the title and description shown on the
landing page (and the HTML `<title>`) served at `/`:

```yaml
branding:
title: Acme Package Mirror
description: Internal package cache for Acme Corp.

repositories:
...
```

Both fields are optional and independent — omitting `branding` entirely, or
leaving one of the two fields unset, falls back to the default "pkgproxy" title
and "Caching forward proxy for Linux package repositories." description. The

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you link here to the defaults defined in the pkg/pkgproxy/landing.go instead of literally duplicating the settings.

landing page also always shows the running pkgproxy version below the
description.

### Mirror retries

Some upstream mirrors (e.g. `download.fedoraproject.org`) act as redirectors that
Expand Down
6 changes: 3 additions & 3 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func parseTrustProxy(value string) (echo.IPExtractor, error) {

// newEchoApp wires up the Echo application: IP extraction, middleware chain,
// the landing page route and the caching forward proxy.
func newEchoApp(cacheBasePath string, config *pkgproxy.RepoConfig, publicAddr string, extractor echo.IPExtractor) *echo.Echo {
func newEchoApp(cacheBasePath string, config *pkgproxy.RepoConfig, publicAddr string, extractor echo.IPExtractor, version string) *echo.Echo {
app := pkgproxy.NewEcho()
// Extract client IP from X-Forwarded-For only when a trusted proxy is explicitly configured
// via --trust-proxy. By default, XFF is ignored and the direct connecting IP is used.
Expand Down Expand Up @@ -210,7 +210,7 @@ func newEchoApp(cacheBasePath string, config *pkgproxy.RepoConfig, publicAddr st
CacheBasePath: cacheBasePath,
RepositoryConfig: config,
})
app.GET("/", pkgproxy.LandingHandler(config, publicAddr))
app.GET("/", pkgproxy.LandingHandler(config, publicAddr, version))
app.Use(pkgProxy.Cache)
app.Use(pkgProxy.ForwardProxy)

Expand All @@ -236,7 +236,7 @@ func startServer(_ *cobra.Command, _ []string) error {
slog.Info("trust-proxy", "value", trustProxyLog)

publicAddr := resolvePublicAddr(publicHost, listenAddress, listenPort)
app := newEchoApp(cacheDir, &repoConfig, publicAddr, ipExtractor)
app := newEchoApp(cacheDir, &repoConfig, publicAddr, ipExtractor, Version)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
Expand Down
4 changes: 4 additions & 0 deletions configs/pkgproxy.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
---
branding:
title: Pkgproxy Application
description: Caching forward proxy for Linux package repositories

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I'm including the pkgproxy.yaml as default config into the container image can you please comment the these settings so that the defaults from the binary are used and the settings here are simply used as hints for people who want to customize these.


repositories:
almalinux:
suffixes:
Expand Down
53 changes: 47 additions & 6 deletions pkg/pkgproxy/landing.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const landingTemplate = `<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pkgproxy</title>
<title>{{.Title}}</title>
<style>
body { font-family: monospace; max-width: 900px; margin: 2em auto; padding: 0 1em; color: #222; }
h1 { border-bottom: 2px solid #444; padding-bottom: 0.3em; }
Expand All @@ -26,9 +26,10 @@ ul { padding-left: 1.4em; }
</style>
</head>
<body>
<h1>pkgproxy</h1>
<p>Caching forward proxy for Linux package repositories.</p>
{{range .}}
<h1>{{.Title}}</h1>
<p>{{.Description}}</p>
<p>pkgproxy {{.Version}}</p>
{{range .Repos}}
<h2>{{.Name}}</h2>
<p><strong>Mirrors:</strong></p>
<ul>{{range .Mirrors}}<li><a href="{{.}}">{{.}}</a></li>{{end}}</ul>
Expand All @@ -41,6 +42,13 @@ ul { padding-left: 1.4em; }
</html>
`

// defaultTitle and defaultDescription are used when the config's 'branding'
// block is absent or leaves a field empty.
const (
defaultTitle = "pkgproxy"
defaultDescription = "Caching forward proxy for Linux package repositories."
)

// snippetFuncs maps known repository names to functions that generate
// package manager configuration snippets for the landing page.
// Each function takes the public address (host or host:port) and returns
Expand Down Expand Up @@ -110,6 +118,29 @@ type repoEntry struct {
Mirrors []string
}

type landingData struct {
Title string
Description string
Version string
Repos []repoEntry
}

// brandingOrDefault returns the configured title and description, falling
// back to the built-in pkgproxy defaults for whichever field is unset.
func brandingOrDefault(branding *BrandingConfig) (title string, description string) {
title, description = defaultTitle, defaultDescription
if branding == nil {
return title, description
}
if branding.Title != "" {
title = branding.Title
}
if branding.Description != "" {
description = branding.Description
}
return title, description
}

// sortedRepos returns repository entries sorted alphabetically by name.
func sortedRepos(config *RepoConfig) []repoEntry {
names := make([]string, 0, len(config.Repositories))
Expand All @@ -128,7 +159,7 @@ func sortedRepos(config *RepoConfig) []repoEntry {
// LandingHandler returns an Echo handler that renders an HTML overview page
// listing all configured repositories, their mirrors, and package manager snippets.
// publicAddr is the address (host or host:port) rendered in config snippets.
func LandingHandler(config *RepoConfig, publicAddr string) echo.HandlerFunc {
func LandingHandler(config *RepoConfig, publicAddr string, version string) echo.HandlerFunc {
funcMap := template.FuncMap{
"repoSnippet": func(name string) string {
fn, ok := snippetFuncs[name]
Expand All @@ -140,9 +171,19 @@ func LandingHandler(config *RepoConfig, publicAddr string) echo.HandlerFunc {
}
tmpl := template.Must(template.New("landing").Funcs(funcMap).Parse(landingTemplate))

title, description := brandingOrDefault(config.Branding)
repos := sortedRepos(config)

return func(c *echo.Context) error {
data := landingData{
Title: title,
Description: description,
Version: version,
Repos: repos,
}

var buf bytes.Buffer
if err := tmpl.Execute(&buf, sortedRepos(config)); err != nil {
if err := tmpl.Execute(&buf, data); err != nil {
return err
}
c.Response().Header().Set(echo.HeaderContentType, "text/html; charset=UTF-8")
Expand Down
62 changes: 62 additions & 0 deletions pkg/pkgproxy/landing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ func newLandingApp(config *RepoConfig, publicAddr string) *echo.Echo {
return app
}

func newLandingAppWithVersion(config *RepoConfig, publicAddr string, version string) *echo.Echo {
app := echo.New()
app.GET("/", LandingHandler(config, publicAddr, version))
return app
}

func getLandingBody(t *testing.T, app *echo.Echo) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
Expand Down Expand Up @@ -67,6 +73,62 @@ func TestLandingHandlerMirrorLinks(t *testing.T) {
assert.Contains(t, body, `<a href="https://mirror.example.com/fedora/">https://mirror.example.com/fedora/</a>`)
}

func TestLandingHandlerDefaultBranding(t *testing.T) {
config := &RepoConfig{
Repositories: map[string]Repository{
"fedora": {CacheSuffixes: []string{".rpm"}, Mirrors: []string{"https://mirror.example.com/"}},
},
}
body := getLandingBody(t, newLandingApp(config))

assert.Contains(t, body, "<title>pkgproxy</title>")
assert.Contains(t, body, "<h1>pkgproxy</h1>")
assert.Contains(t, body, "<p>Caching forward proxy for Linux package repositories.</p>")
}

func TestLandingHandlerCustomBranding(t *testing.T) {
config := &RepoConfig{
Branding: &BrandingConfig{
Title: "Acme Package Mirror",
Description: "Internal package cache for Acme Corp.",
},
Repositories: map[string]Repository{
"fedora": {CacheSuffixes: []string{".rpm"}, Mirrors: []string{"https://mirror.example.com/"}},
},
}
body := getLandingBody(t, newLandingApp(config))

assert.Contains(t, body, "<title>Acme Package Mirror</title>")
assert.Contains(t, body, "<h1>Acme Package Mirror</h1>")
assert.Contains(t, body, "<p>Internal package cache for Acme Corp.</p>")
assert.NotContains(t, body, "pkgproxy</h1>")
}

func TestLandingHandlerBrandingPartialOverride(t *testing.T) {
config := &RepoConfig{
Branding: &BrandingConfig{Title: "Acme Package Mirror"},
Repositories: map[string]Repository{
"fedora": {CacheSuffixes: []string{".rpm"}, Mirrors: []string{"https://mirror.example.com/"}},
},
}
body := getLandingBody(t, newLandingApp(config))

assert.Contains(t, body, "<h1>Acme Package Mirror</h1>")
// Description falls back to the default when only the title is customized.
assert.Contains(t, body, "<p>Caching forward proxy for Linux package repositories.</p>")
}

func TestLandingHandlerVersion(t *testing.T) {
config := &RepoConfig{
Repositories: map[string]Repository{
"fedora": {CacheSuffixes: []string{".rpm"}, Mirrors: []string{"https://mirror.example.com/"}},
},
}
body := getLandingBody(t, newLandingAppWithVersion(config, "localhost:8080", "v0.3.1"))

assert.Contains(t, body, "<p>pkgproxy v0.3.1</p>")
}

func TestLandingHandlerKnownSnippets(t *testing.T) {
tests := []struct {
repo string
Expand Down
7 changes: 7 additions & 0 deletions pkg/pkgproxy/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ var repoHandleRegexp = regexp.MustCompile("^[a-zA-Z0-9_~.-]*$")

// RepoConfig defines the upstream package repositories
type RepoConfig struct {
Branding *BrandingConfig `yaml:"branding,omitempty"`
Repositories map[string]Repository `yaml:"repositories"`
}

// BrandingConfig customizes the title and description shown on the landing page.
type BrandingConfig struct {
Title string `yaml:"title,omitempty"`
Description string `yaml:"description,omitempty"`
}

type Repository struct {
CacheSuffixes []string `yaml:"suffixes"`
Exclude []string `yaml:"exclude,omitempty"`
Expand Down
41 changes: 41 additions & 0 deletions pkg/pkgproxy/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,44 @@ func TestValidateConfigWildcardAloneNoWarning(t *testing.T) {

assert.Empty(t, buf.String())
}

func TestLoadConfigBranding(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "pkgproxy.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(`---
branding:
title: Acme Package Mirror
description: Internal package cache for Acme Corp.
repositories:
fedora:
suffixes:
- .rpm
mirrors:
- https://mirror.example.com/
`), 0o600))

var config RepoConfig
require.NoError(t, LoadConfig(&config, configPath))

require.NotNil(t, config.Branding)
assert.Equal(t, "Acme Package Mirror", config.Branding.Title)
assert.Equal(t, "Internal package cache for Acme Corp.", config.Branding.Description)
}

func TestLoadConfigNoBranding(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "pkgproxy.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(`---
repositories:
fedora:
suffixes:
- .rpm
mirrors:
- https://mirror.example.com/
`), 0o600))

var config RepoConfig
require.NoError(t, LoadConfig(&config, configPath))

assert.Nil(t, config.Branding)
}