Skip to content
Merged
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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- `PKGPROXY_HOST` env var to set the listen address without passing `--host` on the command line
- Container image now runs `serve` by default and loads bundled config from `$KO_DATA_PATH`
- `PKGPROXY_TRUST_PROXY` env var (and `--trust-proxy` flag) to opt in to X-Forwarded-For trust
- `PKGPROXY_HOST` env var to set the listen address without passing `--host` on the command line

### Changed

- **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
- Upgraded Echo web framework to v5.1.1
- Config-file errors now list all default paths attempted, not just the last one
- Upgraded Echo web framework to v5.1.0

## [v0.2.0](https://github.com/ganto/pkgproxy/releases/tag/v0.2.0) - 2026-04-06

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,28 @@ podman run --rm -p 8080:8080 -e PKGPROXY_HOST=0.0.0.0 --volume ./cache:/ko-app/c
| `--host` | `PKGPROXY_HOST` | `localhost` | Listen address |
| `--port` | | `8080` | Listen port |
| `--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. |
| `--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. |
| `--debug` | | `false` | Enable debug logging |

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

### 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.

When pkgproxy runs behind a trusted reverse proxy, set `--trust-proxy` to tell it which source addresses are allowed to supply the client IP via `X-Forwarded-For`. Only the explicitly listed sources are trusted — echo's built-in defaults (loopback/link-local/private) are never applied automatically.

Common recipes:

| Topology | Setting |
|----------|---------|
| Same-host reverse proxy (nginx/caddy on localhost) | `PKGPROXY_TRUST_PROXY=loopback` |
| LAN reverse proxy (specific host, tightest control) | `PKGPROXY_TRUST_PROXY=192.168.1.1/32` |
| LAN reverse proxy (any private-range host) | `PKGPROXY_TRUST_PROXY=private` |
| No reverse proxy | Leave unset (default) |

> **Container-bridge caveat:** In a typical `podman run -p 8080:8080` deployment the direct peer is the bridge gateway (e.g. `172.17.0.1`), which falls inside the private range. Setting `PKGPROXY_TRUST_PROXY=private` in that case means any client can inject an arbitrary `X-Forwarded-For` value. Prefer a specific CIDR or IP for tightest control.

## Repository Configuration

An example repository configuration can be found at [configs/pkgproxy.yaml](configs/pkgproxy.yaml).
Expand Down
16 changes: 9 additions & 7 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ Complete documentation is available at https://github.com/ganto/pkgproxy`,

const koDataPathEnvVar = "KO_DATA_PATH"

// injectServeDefault prepends "serve" to os.Args when the binary is called
// with no arguments, making the container image work without an explicit subcommand.
func injectServeDefault() {
if len(os.Args) == 1 {
os.Args = append([]string{os.Args[0], "serve"}, os.Args[1:]...)
// defaultArgs returns the cobra argument list for the current invocation,
// substituting "serve" when the binary was invoked with no user-supplied arguments.
func defaultArgs(osArgs []string) []string {
if len(osArgs) <= 1 {
return []string{"serve"}
}
return osArgs[1:]
}

// resolveConfigPath returns the config file path to use when neither --config
Expand Down Expand Up @@ -115,8 +116,9 @@ func initConfig() error {

// Execute starts the command
func Execute() {
injectServeDefault()
if err := NewRootCommand().Execute(); err != nil {
c := NewRootCommand()
c.SetArgs(defaultArgs(os.Args))
if err := c.Execute(); err != nil {
os.Exit(1)
}
}
32 changes: 13 additions & 19 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,47 @@
package cmd

import (
"os"
"testing"

"github.com/stretchr/testify/assert"
)

func TestInjectServeDefault(t *testing.T) {
func TestDefaultArgs(t *testing.T) {
tests := []struct {
name string
args []string
osArgs []string
wantArgs []string
}{
{
name: "zero args → serve inserted",
args: []string{"pkgproxy"},
wantArgs: []string{"pkgproxy", "serve"},
osArgs: []string{"pkgproxy"},
wantArgs: []string{"serve"},
},
{
name: "--help → unchanged",
args: []string{"pkgproxy", "--help"},
wantArgs: []string{"pkgproxy", "--help"},
osArgs: []string{"pkgproxy", "--help"},
wantArgs: []string{"--help"},
},
{
name: "version → unchanged",
args: []string{"pkgproxy", "version"},
wantArgs: []string{"pkgproxy", "version"},
osArgs: []string{"pkgproxy", "version"},
wantArgs: []string{"version"},
},
{
name: "explicit serve → unchanged",
args: []string{"pkgproxy", "serve"},
wantArgs: []string{"pkgproxy", "serve"},
osArgs: []string{"pkgproxy", "serve"},
wantArgs: []string{"serve"},
},
{
name: "bare flag → unchanged",
args: []string{"pkgproxy", "--debug"},
wantArgs: []string{"pkgproxy", "--debug"},
osArgs: []string{"pkgproxy", "--debug"},
wantArgs: []string{"--debug"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
original := os.Args
t.Cleanup(func() { os.Args = original })

os.Args = tt.args
injectServeDefault()
assert.Equal(t, tt.wantArgs, os.Args)
assert.Equal(t, tt.wantArgs, defaultArgs(tt.osArgs))
})
}
}
106 changes: 100 additions & 6 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ package cmd

import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"os/signal"
"runtime"
"slices"
"strings"
"syscall"
"time"

Expand All @@ -19,16 +23,20 @@ import (
)

var (
listenAddress string
listenPort uint16
publicHost string
listenAddress string
listenPort uint16
publicHost string
trustProxy string
ipExtractor echo.IPExtractor
resolvedTrustProxy string
)

const (
defaultAddress = "localhost"
defaultPort = 8080
hostEnvVar = "PKGPROXY_HOST"
publicHostEnvVar = "PKGPROXY_PUBLIC_HOST"
trustProxyEnvVar = "PKGPROXY_TRUST_PROXY"
)

func newServeCommand() *cobra.Command {
Expand All @@ -38,6 +46,12 @@ func newServeCommand() *cobra.Command {
Short: "Start forward proxy",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
listenAddress = resolveListenHost(cmd.Flag("host").Changed, listenAddress, os.Getenv(hostEnvVar))
resolvedTrustProxy = resolveTrustProxy(cmd.Flag("trust-proxy").Changed, trustProxy, os.Getenv(trustProxyEnvVar))
var err error
ipExtractor, err = parseTrustProxy(resolvedTrustProxy)
if err != nil {
return err
}
return initConfig()
},
RunE: startServer,
Expand All @@ -46,6 +60,7 @@ func newServeCommand() *cobra.Command {
c.PersistentFlags().StringVar(&listenAddress, "host", defaultAddress, "listen address of the pkgproxy.")
c.PersistentFlags().Uint16Var(&listenPort, "port", defaultPort, "listen port of the pkgproxy.")
c.PersistentFlags().StringVar(&publicHost, "public-host", "", "public hostname (or host:port) shown in landing page config snippets; overrides PKGPROXY_PUBLIC_HOST.")
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.")

return c
}
Expand Down Expand Up @@ -74,6 +89,80 @@ func resolvePublicAddr(flagValue string, listenAddr string, port uint16) string
return fmt.Sprintf("%s:%d", listenAddr, port)
}

// resolveTrustProxy determines the trust-proxy value using flag → env var → default precedence.
func resolveTrustProxy(flagChanged bool, flagValue, envValue string) string {
if flagChanged {
return flagValue
}
if envValue != "" {
return envValue
}
return ""
}

// parseTrustProxy converts the resolved trust-proxy string into an echo.IPExtractor.
// Empty or "none" installs ExtractIPDirect (XFF ignored). Other values install
// ExtractIPFromXFFHeader with only the operator-specified trust options; echo's
// implicit defaults (loopback/link-local/private) are never applied automatically.
func parseTrustProxy(value string) (echo.IPExtractor, error) {
var entries []string
for p := range strings.SplitSeq(value, ",") {
p = strings.TrimSpace(strings.ToLower(p))
if p != "" {
entries = append(entries, p)
}
}
slices.Sort(entries)
entries = slices.Compact(entries)
if len(entries) == 0 {
return echo.ExtractIPDirect(), nil
}
if len(entries) == 1 && entries[0] == "none" {
return echo.ExtractIPDirect(), nil
}
if slices.Contains(entries, "none") {
return nil, errors.New("trust-proxy: 'none' cannot be combined with other entries")
}

var (
trustLoopback bool
trustPrivate bool
extraRanges []*net.IPNet
)
for _, e := range entries {
switch e {
case "loopback":
trustLoopback = true
case "private":
trustPrivate = true
default:
_, ipNet, err := net.ParseCIDR(e)
if err != nil {
ip := net.ParseIP(e)
if ip == nil {
return nil, fmt.Errorf("trust-proxy: unrecognized entry %q", e)
}
bits := 128
if ip.To4() != nil {
bits = 32
}
_, ipNet, _ = net.ParseCIDR(fmt.Sprintf("%s/%d", ip.String(), bits))
}
extraRanges = append(extraRanges, ipNet)
}
}

opts := []echo.TrustOption{
echo.TrustLoopback(trustLoopback),
echo.TrustLinkLocal(false),
echo.TrustPrivateNet(trustPrivate),
}
for _, ipNet := range extraRanges {
opts = append(opts, echo.TrustIPRange(ipNet))
}
return echo.ExtractIPFromXFFHeader(opts...), nil
}

func startServer(_ *cobra.Command, _ []string) error {
logLevel := slog.LevelInfo
if enableDebug {
Expand All @@ -86,11 +175,16 @@ func startServer(_ *cobra.Command, _ []string) error {
"goVersion", runtime.Version(),
"buildDate", buildDate(),
)
trustProxyLog := resolvedTrustProxy
if trustProxyLog == "" {
trustProxyLog = "none"
}
slog.Info("trust-proxy", "value", trustProxyLog)

app := echo.New()
// Extract client IP from X-Forwarded-For when running behind a reverse proxy.
// Defaults trust loopback/link-local/private-net, which cover typical nginx/caddy deployments.
app.IPExtractor = echo.ExtractIPFromXFFHeader()
// 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.
app.IPExtractor = ipExtractor

app.Use(middleware.RequestID())
app.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
Expand Down
Loading
Loading