localghost 3.2.0__tar.gz → 3.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {localghost-3.2.0 → localghost-3.2.1}/PKG-INFO +1 -1
  2. {localghost-3.2.0 → localghost-3.2.1}/pyproject.toml +1 -1
  3. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/proxy_compose.yaml +5 -0
  4. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/proxy_compose_https.yaml +3 -0
  5. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/proxy_compose_tailscale.yaml +10 -0
  6. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/proxy_compose_tailscale_https.yaml +1 -0
  7. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/runner.py +7 -0
  8. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/tailscale_gateway/main.go +75 -13
  9. localghost-3.2.1/src/localghost/tailscale_gateway/proxy_test.go +145 -0
  10. localghost-3.2.1/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/.traefik.yml +7 -0
  11. localghost-3.2.1/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client.go +143 -0
  12. localghost-3.2.1/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client_test.go +118 -0
  13. localghost-3.2.1/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/go.mod +3 -0
  14. {localghost-3.2.0 → localghost-3.2.1}/LICENSE +0 -0
  15. {localghost-3.2.0 → localghost-3.2.1}/README.md +0 -0
  16. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/.dockerignore +0 -0
  17. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/Dockerfile +0 -0
  18. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/__init__.py +0 -0
  19. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/cli.py +0 -0
  20. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/compose.py +0 -0
  21. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/config.py +0 -0
  22. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/feedback.py +0 -0
  23. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/forwarder.py +0 -0
  24. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/generator.py +0 -0
  25. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/paths.py +0 -0
  26. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/picker.py +0 -0
  27. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/ports.py +0 -0
  28. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/registry.py +0 -0
  29. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/roots.py +0 -0
  30. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/routes.py +0 -0
  31. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/sessions.py +0 -0
  32. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/statusbar.py +0 -0
  33. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/tailscale.py +0 -0
  34. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/tailscale_gateway/Dockerfile +0 -0
  35. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/tailscale_gateway/go.mod +0 -0
  36. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/tailscale_gateway/go.sum +0 -0
  37. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/tailscale_gateway/main_test.go +0 -0
  38. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/.traefik.yml +0 -0
  39. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca.go +0 -0
  40. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca_test.go +0 -0
  41. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/cmd/bootstrap/main.go +0 -0
  42. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker.go +0 -0
  43. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker_test.go +0 -0
  44. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.mod +0 -0
  45. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.sum +0 -0
  46. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider.go +0 -0
  47. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider_test.go +0 -0
  48. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/storage.go +0 -0
  49. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/LICENSE +0 -0
  50. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/config.go +0 -0
  51. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/http_config.go +0 -0
  52. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/marshaler.go +0 -0
  53. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/middlewares.go +0 -0
  54. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/plugins.go +0 -0
  55. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_config.go +0 -0
  56. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_middlewares.go +0 -0
  57. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/certificate.go +0 -0
  58. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/tls.go +0 -0
  59. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/domains.go +0 -0
  60. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/tls.go +0 -0
  61. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/udp_config.go +0 -0
  62. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/modules.txt +0 -0
  63. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/.traefik.yml +0 -0
  64. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback.go +0 -0
  65. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback_test.go +0 -0
  66. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/go.mod +0 -0
  67. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/logo.go +0 -0
  68. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/.traefik.yml +0 -0
  69. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/go.mod +0 -0
  70. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/provider.go +0 -0
  71. {localghost-3.2.0 → localghost-3.2.1}/src/localghost/trust.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localghost
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: Localghost serves local applications at friendly .localhost URLs
5
5
  Keywords: localghost,localhost,docker,compose,traefik,development,proxy
6
6
  Author: SmileyChris
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "localghost"
3
- version = "3.2.0"
3
+ version = "3.2.1"
4
4
  description = "Localghost serves local applications at friendly .localhost URLs"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -19,6 +19,10 @@ services:
19
19
  - --providers.docker.exposedbydefault=false
20
20
  - --providers.docker.network=localghost
21
21
  - --experimental.localplugins.localghostFallback.modulename=github.com/SmileyChris/traefik-localghost-fallback
22
+ # Docker hands Traefik loopback connections from an address of its own;
23
+ # every request passes this middleware so applications see 127.0.0.1.
24
+ - --experimental.localplugins.localghostClient.modulename=github.com/SmileyChris/traefik-localghost-client
25
+ - --entrypoints.web.http.middlewares=localghost-client@docker
22
26
  healthcheck:
23
27
  test: [CMD, traefik, healthcheck, --ping]
24
28
  interval: 10s
@@ -49,6 +53,7 @@ services:
49
53
  - traefik.http.routers.localghost-fallback.middlewares=localghost-fallback
50
54
  - traefik.http.routers.localghost-fallback.service=noop@internal
51
55
  - traefik.http.middlewares.localghost-fallback.plugin.localghostFallback.registryPath=/var/lib/localghost-registry
56
+ - traefik.http.middlewares.localghost-client.plugin.localghostClient.routesPath=/proc/net/route
52
57
 
53
58
  networks:
54
59
  localghost:
@@ -46,6 +46,9 @@ services:
46
46
  - --experimental.abortonpluginfailure=true
47
47
  - --experimental.localplugins.localghostCA.modulename=github.com/SmileyChris/traefik-localghost-ca
48
48
  - --experimental.localplugins.localghostFallback.modulename=github.com/SmileyChris/traefik-localghost-fallback
49
+ - --experimental.localplugins.localghostClient.modulename=github.com/SmileyChris/traefik-localghost-client
50
+ - --entrypoints.web.http.middlewares=localghost-client@docker
51
+ - --entrypoints.websecure.http.middlewares=localghost-client@docker
49
52
  ports:
50
53
  - 127.0.0.1:${LOCALGHOST_HTTPS_PORT:-443}:443
51
54
  volumes:
@@ -14,6 +14,16 @@ services:
14
14
  - --api.insecure=false
15
15
  - --entrypoints.web.address=:80
16
16
  - --entrypoints.websecure.address=:443
17
+ # The gateway speaks for tailnet clients: it replaces their forwarded
18
+ # headers on plain HTTP and names the peer in a PROXY header on the TLS
19
+ # passthrough. It sits on a Docker network, and everything else that
20
+ # reaches these loopback-published entrypoints is this machine, arriving
21
+ # from the Docker gateway -- all private addresses.
22
+ - --entrypoints.web.forwardedheaders.trustedips=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
23
+ - --entrypoints.websecure.proxyprotocol.trustedips=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
24
+ - --entrypoints.web.http.middlewares=localghost-client@docker
25
+ - --entrypoints.websecure.http.middlewares=localghost-client@docker
26
+ - --experimental.localplugins.localghostClient.modulename=github.com/SmileyChris/traefik-localghost-client
17
27
  - --global.checknewversion=false
18
28
  - --global.sendanonymoususage=false
19
29
  - --ping=true
@@ -21,6 +21,7 @@ services:
21
21
  - --root-ca=/var/lib/localghost-root/rootCA.pem
22
22
  - --http-target=traefik:80
23
23
  - --https-target=traefik:443
24
+ - --https-proxy-protocol
24
25
  volumes:
25
26
  # The signer volume contains only the exported public root plus the
26
27
  # constrained online signer. The offline root volume must never be
@@ -578,6 +578,13 @@ def create_run_bridge_compose(
578
578
  ":8080",
579
579
  "--to",
580
580
  f"http://host.docker.internal:{host_port}",
581
+ # Traefik is the edge that saw the client. Caddy would otherwise
582
+ # replace the client address and scheme with its own view of this
583
+ # internal hop, so hand Traefik's values on unchanged.
584
+ "--header-up",
585
+ "X-Forwarded-For: {http.request.header.X-Forwarded-For}",
586
+ "--header-up",
587
+ "X-Forwarded-Proto: {http.request.header.X-Forwarded-Proto}",
581
588
  ]
582
589
  )
583
590
  service["extra_hosts"] = CommentedSeq(["host.docker.internal:host-gateway"])
@@ -43,7 +43,10 @@ type configuration struct {
43
43
  rootCA string
44
44
  httpTarget string
45
45
  httpsTarget string
46
- bootstrap bool
46
+ // httpsProxyProtocol names each tailnet client to the HTTPS target in a
47
+ // PROXY protocol header; the hub's websecure entrypoint expects one.
48
+ httpsProxyProtocol bool
49
+ bootstrap bool
47
50
  }
48
51
 
49
52
  // servesHTTPS reports whether the gateway fronts a TLS-terminating Traefik.
@@ -62,6 +65,7 @@ func main() {
62
65
  flag.StringVar(&cfg.rootCA, "root-ca", "", "public root certificate; empty serves HTTP only")
63
66
  flag.StringVar(&cfg.httpTarget, "http-target", "traefik:80", "HTTP proxy target")
64
67
  flag.StringVar(&cfg.httpsTarget, "https-target", "", "HTTPS TCP target; required with --root-ca")
68
+ flag.BoolVar(&cfg.httpsProxyProtocol, "https-proxy-protocol", false, "name each tailnet client to the HTTPS target in a PROXY protocol v1 header")
65
69
  flag.BoolVar(&cfg.bootstrap, "bootstrap", false, "enroll from an auth key on stdin, then exit")
66
70
  healthProbe := flag.Bool("health-probe", false, "check the running gateway's health listener, then exit")
67
71
  flag.Parse()
@@ -164,7 +168,7 @@ func run(ctx context.Context, cfg configuration) error {
164
168
  startDNS(ctx, server, cfg.suffix, ip4, ip6, errCh)
165
169
  startHTTP(ctx, server, cfg, errCh)
166
170
  if cfg.servesHTTPS() {
167
- startTCPProxy(ctx, server, ":443", cfg.httpsTarget, errCh)
171
+ startTCPProxy(ctx, server, ":443", cfg.httpsTarget, cfg.httpsProxyProtocol, errCh)
168
172
  }
169
173
  startHealth(ctx, errCh)
170
174
 
@@ -330,20 +334,45 @@ func startHealth(ctx context.Context, errCh chan<- error) {
330
334
  }()
331
335
  }
332
336
 
337
+ // newHTTPProxy forwards tailnet HTTP to target with the original Host header.
338
+ // Traefik believes forwarded headers from the gateway, so whatever a tailnet
339
+ // client claimed about itself is dropped and replaced with the address its
340
+ // connection actually came from.
341
+ func newHTTPProxy(target string) *httputil.ReverseProxy {
342
+ upstream := &url.URL{Scheme: "http", Host: target}
343
+ return &httputil.ReverseProxy{
344
+ Rewrite: func(request *httputil.ProxyRequest) {
345
+ request.SetURL(upstream)
346
+ request.Out.Host = request.In.Host
347
+ stripForwardedHeaders(request.Out.Header)
348
+ request.SetXForwarded()
349
+ if client := request.Out.Header.Get("X-Forwarded-For"); client != "" {
350
+ request.Out.Header.Set("X-Real-Ip", client)
351
+ }
352
+ },
353
+ }
354
+ }
355
+
356
+ // stripForwardedHeaders removes every header Traefik would take as a proxy's
357
+ // word about the original request: the X-Forwarded-* family, X-Real-Ip, and
358
+ // the standard Forwarded header.
359
+ func stripForwardedHeaders(header http.Header) {
360
+ for name := range header {
361
+ if strings.HasPrefix(name, "X-Forwarded-") {
362
+ header.Del(name)
363
+ }
364
+ }
365
+ header.Del("X-Real-Ip")
366
+ header.Del("Forwarded")
367
+ }
368
+
333
369
  func startHTTP(ctx context.Context, server *tsnet.Server, cfg configuration, errCh chan<- error) {
334
370
  listener, err := server.Listen("tcp", ":80")
335
371
  if err != nil {
336
372
  errCh <- fmt.Errorf("listening for HTTP: %w", err)
337
373
  return
338
374
  }
339
- target := &url.URL{Scheme: "http", Host: cfg.httpTarget}
340
- proxy := httputil.NewSingleHostReverseProxy(target)
341
- originalDirector := proxy.Director
342
- proxy.Director = func(request *http.Request) {
343
- host := request.Host
344
- originalDirector(request)
345
- request.Host = host
346
- }
375
+ proxy := newHTTPProxy(cfg.httpTarget)
347
376
  handler := http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
348
377
  if serveTrust(w, request, cfg) {
349
378
  return
@@ -451,7 +480,7 @@ on the hosting machine.</p>
451
480
  `, cfg.suffix, command, fingerprint)
452
481
  }
453
482
 
454
- func startTCPProxy(ctx context.Context, server *tsnet.Server, address, target string, errCh chan<- error) {
483
+ func startTCPProxy(ctx context.Context, server *tsnet.Server, address, target string, proxyProtocol bool, errCh chan<- error) {
455
484
  listener, err := server.Listen("tcp", address)
456
485
  if err != nil {
457
486
  errCh <- fmt.Errorf("listening on %s: %w", address, err)
@@ -470,12 +499,16 @@ func startTCPProxy(ctx context.Context, server *tsnet.Server, address, target st
470
499
  }
471
500
  return
472
501
  }
473
- go proxyConnection(ctx, incoming, target)
502
+ go proxyConnection(ctx, incoming, target, proxyProtocol)
474
503
  }
475
504
  }()
476
505
  }
477
506
 
478
- func proxyConnection(ctx context.Context, incoming net.Conn, target string) {
507
+ // proxyConnection relays one tailnet connection to target. With proxyProtocol
508
+ // it first names the tailnet peer in a PROXY protocol v1 header: the stream is
509
+ // TLS the gateway never opens, so this is the only way Traefik can learn who
510
+ // connected rather than seeing the gateway itself.
511
+ func proxyConnection(ctx context.Context, incoming net.Conn, target string, proxyProtocol bool) {
479
512
  defer incoming.Close()
480
513
  dialer := net.Dialer{Timeout: 10 * time.Second}
481
514
  outgoing, err := dialer.DialContext(ctx, "tcp", target)
@@ -484,6 +517,13 @@ func proxyConnection(ctx context.Context, incoming net.Conn, target string) {
484
517
  return
485
518
  }
486
519
  defer outgoing.Close()
520
+ if proxyProtocol {
521
+ header := proxyProtocolHeader(incoming.RemoteAddr(), incoming.LocalAddr())
522
+ if _, err := io.WriteString(outgoing, header); err != nil {
523
+ log.Printf("proxy header to %s failed: %v", target, err)
524
+ return
525
+ }
526
+ }
487
527
  var wait sync.WaitGroup
488
528
  wait.Add(2)
489
529
  copyHalf := func(destination, source net.Conn) {
@@ -497,3 +537,25 @@ func proxyConnection(ctx context.Context, incoming net.Conn, target string) {
497
537
  go copyHalf(incoming, outgoing)
498
538
  wait.Wait()
499
539
  }
540
+
541
+ // proxyProtocolHeader renders the PROXY protocol v1 line for a connection from
542
+ // source to destination. v1 names one address family for both ends, so a pair
543
+ // that disagrees, or an address that is not ip:port, is sent as UNKNOWN and
544
+ // the receiver keeps the connection's own addresses.
545
+ func proxyProtocolHeader(source, destination net.Addr) string {
546
+ from, errFrom := netip.ParseAddrPort(source.String())
547
+ to, errTo := netip.ParseAddrPort(destination.String())
548
+ if errFrom != nil || errTo != nil {
549
+ return "PROXY UNKNOWN\r\n"
550
+ }
551
+ fromIP := from.Addr().Unmap().WithZone("")
552
+ toIP := to.Addr().Unmap().WithZone("")
553
+ if fromIP.Is4() != toIP.Is4() {
554
+ return "PROXY UNKNOWN\r\n"
555
+ }
556
+ family := "TCP4"
557
+ if fromIP.Is6() {
558
+ family = "TCP6"
559
+ }
560
+ return fmt.Sprintf("PROXY %s %s %s %d %d\r\n", family, fromIP, toIP, from.Port(), to.Port())
561
+ }
@@ -0,0 +1,145 @@
1
+ package main
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "fmt"
7
+ "io"
8
+ "net"
9
+ "net/http"
10
+ "net/http/httptest"
11
+ "net/url"
12
+ "testing"
13
+ "time"
14
+ )
15
+
16
+ func TestHTTPProxyReplacesWhatTheClientClaimsAboutItself(t *testing.T) {
17
+ seen := make(chan *http.Request, 1)
18
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
19
+ seen <- request
20
+ }))
21
+ defer backend.Close()
22
+ target, err := url.Parse(backend.URL)
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+
27
+ request := httptest.NewRequest("GET", "http://demo.tail1234/", nil)
28
+ request.RemoteAddr = "100.101.102.103:4242"
29
+ request.Header.Set("X-Forwarded-For", "127.0.0.1")
30
+ request.Header.Set("X-Real-Ip", "127.0.0.1")
31
+ request.Header.Set("X-Forwarded-Port", "1")
32
+ request.Header.Set("Forwarded", "for=127.0.0.1")
33
+ newHTTPProxy(target.Host).ServeHTTP(httptest.NewRecorder(), request)
34
+
35
+ received := <-seen
36
+ if got := received.Header.Values("X-Forwarded-For"); len(got) != 1 || got[0] != "100.101.102.103" {
37
+ t.Fatalf("X-Forwarded-For = %q, want only the tailnet peer", got)
38
+ }
39
+ if got := received.Header.Get("X-Real-Ip"); got != "100.101.102.103" {
40
+ t.Fatalf("X-Real-Ip = %q, want the tailnet peer", got)
41
+ }
42
+ if got := received.Header.Get("X-Forwarded-Proto"); got != "http" {
43
+ t.Fatalf("X-Forwarded-Proto = %q, want http", got)
44
+ }
45
+ for _, name := range []string{"X-Forwarded-Port", "Forwarded"} {
46
+ if got := received.Header.Get(name); got != "" {
47
+ t.Fatalf("%s = %q, want the client's claim dropped", name, got)
48
+ }
49
+ }
50
+ if received.Host != "demo.tail1234" {
51
+ t.Fatalf("Host = %q, want the original host", received.Host)
52
+ }
53
+ }
54
+
55
+ func TestProxyProtocolHeaderNamesTheTailnetPeer(t *testing.T) {
56
+ tcp := func(ip string, port int) net.Addr {
57
+ return &net.TCPAddr{IP: net.ParseIP(ip), Port: port}
58
+ }
59
+ cases := []struct {
60
+ name string
61
+ source net.Addr
62
+ destination net.Addr
63
+ want string
64
+ }{
65
+ {"ipv4", tcp("100.101.102.103", 4242), tcp("100.64.0.1", 443), "PROXY TCP4 100.101.102.103 100.64.0.1 4242 443\r\n"},
66
+ {"ipv6", tcp("fd7a:115c:a1e0::1", 4242), tcp("fd7a:115c:a1e0::2", 443), "PROXY TCP6 fd7a:115c:a1e0::1 fd7a:115c:a1e0::2 4242 443\r\n"},
67
+ {"mixed families", tcp("fd7a:115c:a1e0::1", 4242), tcp("100.64.0.1", 443), "PROXY UNKNOWN\r\n"},
68
+ }
69
+ for _, tc := range cases {
70
+ t.Run(tc.name, func(t *testing.T) {
71
+ if got := proxyProtocolHeader(tc.source, tc.destination); got != tc.want {
72
+ t.Fatalf("got %q, want %q", got, tc.want)
73
+ }
74
+ })
75
+ }
76
+ }
77
+
78
+ // relayOnce accepts one connection on a fresh listener, relays it to a fresh
79
+ // target, and returns the client end and the target's end of the stream.
80
+ func relayOnce(t *testing.T, proxyProtocol bool) (client, upstream net.Conn) {
81
+ t.Helper()
82
+ target, err := net.Listen("tcp", "127.0.0.1:0")
83
+ if err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ t.Cleanup(func() { _ = target.Close() })
87
+ front, err := net.Listen("tcp", "127.0.0.1:0")
88
+ if err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ t.Cleanup(func() { _ = front.Close() })
92
+ ctx, cancel := context.WithCancel(context.Background())
93
+ t.Cleanup(cancel)
94
+ go func() {
95
+ incoming, err := front.Accept()
96
+ if err != nil {
97
+ return
98
+ }
99
+ proxyConnection(ctx, incoming, target.Addr().String(), proxyProtocol)
100
+ }()
101
+ client, err = net.Dial("tcp", front.Addr().String())
102
+ if err != nil {
103
+ t.Fatal(err)
104
+ }
105
+ t.Cleanup(func() { _ = client.Close() })
106
+ upstream, err = target.Accept()
107
+ if err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ t.Cleanup(func() { _ = upstream.Close() })
111
+ _ = upstream.SetReadDeadline(time.Now().Add(5 * time.Second))
112
+ return client, upstream
113
+ }
114
+
115
+ func TestProxyConnectionAnnouncesThePeerBeforeTheStream(t *testing.T) {
116
+ client, upstream := relayOnce(t, true)
117
+ if _, err := client.Write([]byte("hello")); err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ reader := bufio.NewReader(upstream)
121
+ line, err := reader.ReadString('\n')
122
+ if err != nil {
123
+ t.Fatal(err)
124
+ }
125
+ want := fmt.Sprintf("PROXY TCP4 127.0.0.1 127.0.0.1 %d %d\r\n",
126
+ client.LocalAddr().(*net.TCPAddr).Port, client.RemoteAddr().(*net.TCPAddr).Port)
127
+ if line != want {
128
+ t.Fatalf("header = %q, want %q", line, want)
129
+ }
130
+ payload := make([]byte, 5)
131
+ if _, err := io.ReadFull(reader, payload); err != nil || string(payload) != "hello" {
132
+ t.Fatalf("payload = %q (%v), want the client's bytes untouched", payload, err)
133
+ }
134
+ }
135
+
136
+ func TestProxyConnectionWithoutProxyProtocolRelaysOnlyTheStream(t *testing.T) {
137
+ client, upstream := relayOnce(t, false)
138
+ if _, err := client.Write([]byte("hello")); err != nil {
139
+ t.Fatal(err)
140
+ }
141
+ payload := make([]byte, 5)
142
+ if _, err := io.ReadFull(upstream, payload); err != nil || string(payload) != "hello" {
143
+ t.Fatalf("payload = %q (%v), want the client's bytes first", payload, err)
144
+ }
145
+ }
@@ -0,0 +1,7 @@
1
+ displayName: Localghost Client Address
2
+ type: middleware
3
+ import: github.com/SmileyChris/traefik-localghost-client
4
+ summary: Names requests from this machine as 127.0.0.1
5
+
6
+ testData:
7
+ routesPath: /proc/net/route
@@ -0,0 +1,143 @@
1
+ // Package traefik_localghost_client implements a Traefik middleware plugin
2
+ // that names requests from this machine as 127.0.0.1.
3
+ //
4
+ // The hub publishes its ports on loopback only, yet Docker hands Traefik those
5
+ // connections from an address of its own: the bridge gateway on a native
6
+ // daemon, the VM's gateway under Docker Desktop. Left alone, every
7
+ // application would be told that address instead of the loopback the browser
8
+ // used. Everything else that reaches Traefik is a container on one of its own
9
+ // networks (the tailnet gateway among them) or a tailnet device named by the
10
+ // gateway's PROXY header. So an IPv4 source that is the gateway, or is on
11
+ // none of Traefik's networks and is not a Tailscale address, is this machine.
12
+ package traefik_localghost_client
13
+
14
+ import (
15
+ "bufio"
16
+ "context"
17
+ "encoding/hex"
18
+ "errors"
19
+ "fmt"
20
+ "net"
21
+ "net/http"
22
+ "os"
23
+ "strings"
24
+ )
25
+
26
+ type Config struct {
27
+ // RoutesPath is the kernel's IPv4 routing table inside the Traefik
28
+ // container: its default route names the host, and its directly
29
+ // connected routes name the networks containers arrive from.
30
+ RoutesPath string `json:"routesPath,omitempty"`
31
+ }
32
+
33
+ func CreateConfig() *Config { return &Config{RoutesPath: "/proc/net/route"} }
34
+
35
+ // tailnet is Tailscale's IPv4 range. Tailnet devices are off every local
36
+ // network, but they are anything but this machine.
37
+ var tailnet = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)}
38
+
39
+ type client struct {
40
+ next http.Handler
41
+ gateway net.IP
42
+ networks []*net.IPNet
43
+ }
44
+
45
+ func New(_ context.Context, next http.Handler, config *Config, _ string) (http.Handler, error) {
46
+ if config == nil || config.RoutesPath == "" {
47
+ return nil, errors.New("localghost client: routesPath is required")
48
+ }
49
+ gateway, networks, err := readRoutes(config.RoutesPath)
50
+ if err != nil {
51
+ // Without its own networks to compare against, every source would
52
+ // look like this machine. Pass requests through untouched instead;
53
+ // applications then see Docker's address, as they would without us.
54
+ fmt.Fprintf(os.Stdout, "localghost client: leaving client addresses alone: %v\n", err)
55
+ return next, nil
56
+ }
57
+ return &client{next: next, gateway: gateway, networks: networks}, nil
58
+ }
59
+
60
+ func (c *client) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
61
+ host, port, err := net.SplitHostPort(req.RemoteAddr)
62
+ if err == nil && c.isThisMachine(net.ParseIP(host)) {
63
+ req.RemoteAddr = net.JoinHostPort("127.0.0.1", port)
64
+ // Traefik has already recorded the connection's address here. A value
65
+ // it kept from a trusted sender is that sender's claim; leave it be.
66
+ if req.Header.Get("X-Real-Ip") == host {
67
+ req.Header.Set("X-Real-Ip", "127.0.0.1")
68
+ }
69
+ }
70
+ c.next.ServeHTTP(rw, req)
71
+ }
72
+
73
+ func (c *client) isThisMachine(ip net.IP) bool {
74
+ // IPv6 is left alone: this table only lists IPv4 networks, so an IPv6
75
+ // container address would otherwise be mistaken for the host.
76
+ ip = ip.To4()
77
+ if ip == nil || ip.IsLoopback() {
78
+ return false
79
+ }
80
+ if ip.Equal(c.gateway) {
81
+ return true
82
+ }
83
+ if tailnet.Contains(ip) {
84
+ return false
85
+ }
86
+ for _, network := range c.networks {
87
+ if network.Contains(ip) {
88
+ return false
89
+ }
90
+ }
91
+ return true
92
+ }
93
+
94
+ // readRoutes returns the default gateway and the directly connected networks
95
+ // from a /proc/net/route table.
96
+ func readRoutes(path string) (net.IP, []*net.IPNet, error) {
97
+ file, err := os.Open(path)
98
+ if err != nil {
99
+ return nil, nil, err
100
+ }
101
+ defer file.Close()
102
+ var gateway net.IP
103
+ var networks []*net.IPNet
104
+ scanner := bufio.NewScanner(file)
105
+ scanner.Scan() // header row
106
+ for scanner.Scan() {
107
+ fields := strings.Fields(scanner.Text())
108
+ if len(fields) < 8 {
109
+ continue
110
+ }
111
+ destination, errDestination := parseHexIPv4(fields[1])
112
+ via, errVia := parseHexIPv4(fields[2])
113
+ mask, errMask := parseHexIPv4(fields[7])
114
+ if errDestination != nil || errVia != nil || errMask != nil {
115
+ continue
116
+ }
117
+ switch {
118
+ case destination.Equal(net.IPv4zero) && !via.Equal(net.IPv4zero):
119
+ gateway = via
120
+ case via.Equal(net.IPv4zero):
121
+ networks = append(networks, &net.IPNet{IP: destination, Mask: net.IPMask(mask)})
122
+ }
123
+ }
124
+ if err := scanner.Err(); err != nil {
125
+ return nil, nil, err
126
+ }
127
+ if gateway == nil || len(networks) == 0 {
128
+ return nil, nil, fmt.Errorf("no default gateway and connected network in %s", path)
129
+ }
130
+ return gateway, networks, nil
131
+ }
132
+
133
+ // parseHexIPv4 decodes a /proc/net/route address, which is little-endian hex.
134
+ func parseHexIPv4(field string) (net.IP, error) {
135
+ raw, err := hex.DecodeString(field)
136
+ if err != nil {
137
+ return nil, err
138
+ }
139
+ if len(raw) != 4 {
140
+ return nil, fmt.Errorf("not an IPv4 route field: %q", field)
141
+ }
142
+ return net.IPv4(raw[3], raw[2], raw[1], raw[0]).To4(), nil
143
+ }
@@ -0,0 +1,118 @@
1
+ package traefik_localghost_client
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "path/filepath"
9
+ "testing"
10
+ )
11
+
12
+ // A Traefik container on one network, 172.19.0.0/16, whose host is 172.19.0.1.
13
+ const routes = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n" +
14
+ "eth0\t00000000\t010013AC\t0003\t0\t0\t0\t00000000\t0\t0\t0\n" +
15
+ "eth0\t000013AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0\n"
16
+
17
+ func writeRoutes(t *testing.T, content string) string {
18
+ t.Helper()
19
+ path := filepath.Join(t.TempDir(), "route")
20
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ return path
24
+ }
25
+
26
+ // serve runs one request through the middleware and returns what the next
27
+ // handler saw as the client address and X-Real-Ip.
28
+ func serve(t *testing.T, routesPath, remote, realIP string) (string, string) {
29
+ t.Helper()
30
+ var seenRemote, seenRealIP string
31
+ next := http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
32
+ seenRemote, seenRealIP = req.RemoteAddr, req.Header.Get("X-Real-Ip")
33
+ })
34
+ handler, err := New(context.Background(), next, &Config{RoutesPath: routesPath}, "localghost-client")
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ req := httptest.NewRequest("GET", "http://demo.localhost/", nil)
39
+ req.RemoteAddr = remote
40
+ if realIP != "" {
41
+ req.Header.Set("X-Real-Ip", realIP)
42
+ }
43
+ handler.ServeHTTP(httptest.NewRecorder(), req)
44
+ return seenRemote, seenRealIP
45
+ }
46
+
47
+ func TestRequestsFromThisMachineAreNamedLoopback(t *testing.T) {
48
+ path := writeRoutes(t, routes)
49
+ cases := []struct {
50
+ name, remote string
51
+ }{
52
+ {"native daemon's bridge gateway", "172.19.0.1:40226"},
53
+ {"Docker Desktop's VM gateway", "192.168.65.1:40226"},
54
+ }
55
+ for _, tc := range cases {
56
+ t.Run(tc.name, func(t *testing.T) {
57
+ host := tc.remote[:len(tc.remote)-len(":40226")]
58
+ remote, realIP := serve(t, path, tc.remote, host)
59
+ if remote != "127.0.0.1:40226" || realIP != "127.0.0.1" {
60
+ t.Fatalf("got %s / %s, want 127.0.0.1:40226 / 127.0.0.1", remote, realIP)
61
+ }
62
+ })
63
+ }
64
+ }
65
+
66
+ func TestOtherClientsKeepTheirAddress(t *testing.T) {
67
+ path := writeRoutes(t, routes)
68
+ cases := []struct {
69
+ name, remote, realIP string
70
+ }{
71
+ {"a container on Traefik's network, such as the tailnet gateway", "172.19.0.3:5000", "172.19.0.3"},
72
+ {"a tailnet device named by a PROXY header", "100.101.102.103:5000", "100.101.102.103"},
73
+ {"an IPv6 peer", "[fd7a:115c:a1e0::1]:5000", "fd7a:115c:a1e0::1"},
74
+ {"loopback itself", "127.0.0.1:5000", "127.0.0.1"},
75
+ }
76
+ for _, tc := range cases {
77
+ t.Run(tc.name, func(t *testing.T) {
78
+ remote, realIP := serve(t, path, tc.remote, tc.realIP)
79
+ if remote != tc.remote || realIP != tc.realIP {
80
+ t.Fatalf("got %s / %s, want %s / %s untouched", remote, realIP, tc.remote, tc.realIP)
81
+ }
82
+ })
83
+ }
84
+ }
85
+
86
+ func TestATrustedSendersRealIPIsNotOverwritten(t *testing.T) {
87
+ remote, realIP := serve(t, writeRoutes(t, routes), "172.19.0.1:40226", "10.9.9.9")
88
+ if remote != "127.0.0.1:40226" || realIP != "10.9.9.9" {
89
+ t.Fatalf("got %s / %s, want the connection renamed and the claim kept", remote, realIP)
90
+ }
91
+ }
92
+
93
+ func TestUnreadableRoutesLeaveEveryAddressAlone(t *testing.T) {
94
+ for name, path := range map[string]string{
95
+ "missing table": filepath.Join(t.TempDir(), "absent"),
96
+ "no default route": writeRoutes(t, "Iface\tDestination\tGateway\n"),
97
+ } {
98
+ t.Run(name, func(t *testing.T) {
99
+ remote, realIP := serve(t, path, "172.19.0.1:40226", "172.19.0.1")
100
+ if remote != "172.19.0.1:40226" || realIP != "172.19.0.1" {
101
+ t.Fatalf("got %s / %s, want the request passed through", remote, realIP)
102
+ }
103
+ })
104
+ }
105
+ }
106
+
107
+ func TestReadRoutesFindsTheHostAndTheLocalNetwork(t *testing.T) {
108
+ gateway, networks, err := readRoutes(writeRoutes(t, routes))
109
+ if err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ if gateway.String() != "172.19.0.1" {
113
+ t.Fatalf("gateway = %s, want 172.19.0.1", gateway)
114
+ }
115
+ if len(networks) != 1 || networks[0].String() != "172.19.0.0/16" {
116
+ t.Fatalf("networks = %v, want [172.19.0.0/16]", networks)
117
+ }
118
+ }
@@ -0,0 +1,3 @@
1
+ module github.com/SmileyChris/traefik-localghost-client
2
+
3
+ go 1.22
File without changes
File without changes