Compare commits

...

201 Commits

Author SHA1 Message Date
dependabot[bot]
72a597afb9 build(deps): bump docker/setup-qemu-action
Bumps the github-actions-dependencies group with 1 update: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action).


Updates `docker/setup-qemu-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](96fe6ef7f3...1f40c72289)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 21:05:08 +00:00
Owen Schwartz
81624509f8 Merge pull request #106 from Adityakk9031/fix/issue-82
fix(main): sanitize `remoteConfigURL` to prevent 400 Bad Request on bandwidth reports (#82)
2026-08-31 09:20:40 -04:00
Owen Schwartz
0058f0c7ee Merge pull request #111 from fosrl/dependabot/github_actions/github-actions-dependencies-9faabbfa31
build(deps): bump the github-actions-dependencies group with 2 updates
2026-08-31 09:20:23 -04:00
Owen Schwartz
a29075b8bd Merge pull request #113 from fosrl/dependabot/go_modules/go-dependencies-5efbcc367e
build(deps): bump the go-dependencies group across 1 directory with 7 updates
2026-08-31 09:19:48 -04:00
dependabot[bot]
b72f0ab05f build(deps): bump the go-dependencies group across 1 directory with 7 updates
Bumps the go-dependencies group with 3 updates in the / directory: [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go), [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc](https://github.com/open-telemetry/opentelemetry-go) and [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp](https://github.com/open-telemetry/opentelemetry-go).


Updates `go.opentelemetry.io/otel` from 1.45.0 to 1.46.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.45.0...v1.46.0)

Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc` from 1.45.0 to 1.46.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.45.0...v1.46.0)

Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp` from 1.45.0 to 1.46.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.45.0...v1.46.0)

Updates `go.opentelemetry.io/otel/metric` from 1.45.0 to 1.46.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.45.0...v1.46.0)

Updates `go.opentelemetry.io/otel/sdk` from 1.45.0 to 1.46.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.45.0...v1.46.0)

Updates `go.opentelemetry.io/otel/sdk/metric` from 1.45.0 to 1.46.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.45.0...v1.46.0)

Updates `golang.org/x/crypto` from 0.54.0 to 0.55.0
- [Commits](https://github.com/golang/crypto/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/metric
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk/metric
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/crypto
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 21:03:56 +00:00
Owen Schwartz
551eb18e61 Merge pull request #112 from fosrl/dev
Attempt to cap OOM kills of gerbil under sni load
2026-08-26 09:39:20 -04:00
Owen
2eb4090c20 Attempt to cap OOM kills of gerbil under sni load 2026-08-26 09:37:51 -04:00
dependabot[bot]
2297266a39 build(deps): bump the github-actions-dependencies group with 2 updates
Bumps the github-actions-dependencies group with 2 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [docker/login-action](https://github.com/docker/login-action).


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](bb05f3f551...37fe631027)

Updates `docker/login-action` from 4.5.1 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](abd2ef45e7...dbcb813823)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 21:06:41 +00:00
Owen Schwartz
5d70c76bfc Merge pull request #107 from fosrl/dependabot/github_actions/github-actions-dependencies-f85cf6e14d
build(deps): bump the github-actions-dependencies group across 1 directory with 3 updates
2026-08-19 09:12:07 -04:00
Owen Schwartz
c63be17369 Merge pull request #108 from fosrl/dependabot/go_modules/go-dependencies-989617e138
build(deps): bump the go-dependencies group across 1 directory with 9 updates
2026-08-19 09:11:52 -04:00
Owen Schwartz
939fbd5d2c Merge pull request #109 from fosrl/dev
1.5.0
2026-08-18 17:22:55 -04:00
dependabot[bot]
94594f40a5 build(deps): bump the go-dependencies group across 1 directory with 9 updates
Bumps the go-dependencies group with 4 updates in the / directory: [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang), [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go), [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc](https://github.com/open-telemetry/opentelemetry-go) and [go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp](https://github.com/open-telemetry/opentelemetry-go).


Updates `github.com/prometheus/client_golang` from 1.23.2 to 1.24.1
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.1)

Updates `go.opentelemetry.io/otel` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

Updates `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

Updates `go.opentelemetry.io/otel/metric` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

Updates `go.opentelemetry.io/otel/sdk` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

Updates `go.opentelemetry.io/otel/sdk/metric` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

Updates `golang.org/x/crypto` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/crypto/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/sync` from 0.21.0 to 0.22.0
- [Commits](https://github.com/golang/sync/compare/v0.21.0...v0.22.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/metric
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk/metric
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sync
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 21:04:04 +00:00
Owen
e1b47b69aa expose unwrap to allow for streaming 2026-08-06 17:14:35 -04:00
Owen
654a3c777a Basic routing working to targets 2026-08-06 15:48:40 -04:00
Owen
6f1851639a Process the scheme 2026-08-06 15:17:30 -04:00
Owen
74629a97f7 Add gp router endpoint for dowsteam http 2026-08-05 17:28:13 -04:00
Owen
5c697afcac Add exception to firewall to talk to traefik 2026-08-05 16:28:41 -04:00
dependabot[bot]
6a26078f9e build(deps): bump the github-actions-dependencies group across 1 directory with 3 updates
Bumps the github-actions-dependencies group with 3 updates in the / directory: [actions/checkout](https://github.com/actions/checkout), [docker/login-action](https://github.com/docker/login-action) and [actions/setup-go](https://github.com/actions/setup-go).


Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](9c091bb21b...3d3c42e5aa)

Updates `docker/login-action` from 4.4.0 to 4.5.1
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](af1e73f918...abd2ef45e7)

Updates `actions/setup-go` from 6.5.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](924ae3a1cd...b7ad1dad31)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-go
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 21:04:39 +00:00
Aditya kumar singh
f17612c5d9 fix: sanitize remoteConfigURL to prevent 400 Bad Request on bandwidth reports (#82)
When REMOTE_CONFIG was provided with a trailing slash (e.g. .../gerbil/get-config/
or .../gerbil/), the old TrimSuffix logic failed to strip /gerbil/get-config
due to the trailing slash, resulting in invalid endpoint URLs like
http://pangolin:3001/api/v1/gerbil/get-config/gerbil/receive-bandwidth.

This caused Express/Traefik to return 400 Bad Request every 10 seconds.

Fix: Robustly strip trailing slashes before and after removing legacy subpaths
(/gerbil/get-config, /gerbil/receive-bandwidth, /gerbil).

Fixes #82
2026-07-21 18:18:16 +05:30
Owen Schwartz
e25e5766df Merge pull request #102 from fosrl/dev
Change where heap is written to
2026-07-19 10:57:48 -04:00
Owen Schwartz
2264c1c7b4 Merge pull request #99 from fosrl/dependabot/docker/docker-dependencies-2adcbce36e
build(deps): bump alpine from 3.23 to 3.24 in the docker-dependencies group
2026-07-18 16:03:39 -04:00
Owen Schwartz
904199121d Merge pull request #100 from fosrl/dependabot/go_modules/go-dependencies-ae6daafbbe
build(deps): bump the go-dependencies group with 2 updates
2026-07-18 16:03:15 -04:00
Owen Schwartz
5b92bdeb1c Merge pull request #101 from fosrl/dependabot/github_actions/github-actions-dependencies-ad4479fa06
build(deps): bump the github-actions-dependencies group with 6 updates
2026-07-18 16:01:56 -04:00
Owen
daecacbd8e Change where heap is written to 2026-07-16 16:16:54 -04:00
dependabot[bot]
8efce78d94 build(deps): bump the github-actions-dependencies group with 6 updates
Bumps the github-actions-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `7.0.0` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `4.0.0` | `4.2.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.2.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.4.0` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `6.5.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` |


Updates `actions/checkout` from 6.0.2 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](de0fac2e45...9c091bb21b)

Updates `docker/setup-qemu-action` from 4.0.0 to 4.2.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](ce360397dd...96fe6ef7f3)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.2.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](4d04d5d948...bb05f3f551)

Updates `docker/login-action` from 4.1.0 to 4.4.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...af1e73f918)

Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4a3601121d...924ae3a1cd)

Updates `actions/upload-artifact` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](bbbca2ddaa...043fb46d1a)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:01:31 +00:00
dependabot[bot]
c29e6ec535 build(deps): bump the go-dependencies group with 2 updates
Bumps the go-dependencies group with 2 updates: [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/sync](https://github.com/golang/sync).


Updates `golang.org/x/crypto` from 0.52.0 to 0.53.0
- [Commits](https://github.com/golang/crypto/compare/v0.52.0...v0.53.0)

Updates `golang.org/x/sync` from 0.20.0 to 0.21.0
- [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sync
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:01:29 +00:00
dependabot[bot]
0a1496e8de build(deps): bump alpine in the docker-dependencies group
Bumps the docker-dependencies group with 1 update: alpine.


Updates `alpine` from 3.23 to 3.24

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.24'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: docker-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:01:17 +00:00
Owen Schwartz
4f325dd92f Merge pull request #92 from fosrl/chore/dependabot-single-pr-groups
chore(dependabot): limit grouped dependency updates to one PR per ecosystem
2026-07-07 15:00:19 -04:00
Owen Schwartz
8328a4751a Merge pull request #98 from fosrl/dev
1.4.2-s.1
2026-07-06 09:19:54 -04:00
Owen
860ba69a88 Try to control the sockets and size of bandwidth 2026-07-06 09:16:10 -04:00
Owen
e1c9836dc1 Disable firewall 2026-07-01 10:46:21 -04:00
Owen Schwartz
4f457db7b5 Merge pull request #97 from fosrl/dev
Add cache timeout of 2.5 seconds to record hp
2026-06-12 20:35:02 -07:00
Owen
6d548ea166 Add cache timeout of 2.5 seconds to record hp
Fixes #96
Closes #95
2026-06-12 20:32:43 -07:00
Marc Schäfer
d611eb2022 chore(dependabot): limit grouped dependency updates to one PR per ecosystem 2026-06-07 11:02:22 +02:00
Owen Schwartz
4d8f5923c3 Merge pull request #86 from fosrl/dev
Fix rebuilding WG sessions and handling HP out of hot path
2026-06-04 10:42:18 -07:00
Owen
6af41f2a6e Merge branch 'dev' of github.com:fosrl/gerbil into dev 2026-06-04 10:41:00 -07:00
Owen Schwartz
c638090ef7 Merge pull request #72 from fosrl/dependabot/github_actions/sigstore/cosign-installer-4.1.1
build(deps): bump sigstore/cosign-installer from 4.1.0 to 4.1.2
2026-06-04 10:40:39 -07:00
dependabot[bot]
cde79b5c50 build(deps): bump sigstore/cosign-installer from 4.1.0 to 4.1.2
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.0 to 4.1.2.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/v4.1.0...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 17:40:00 +00:00
Owen Schwartz
af42ae3448 Merge pull request #70 from fosrl/dependabot/github_actions/docker/setup-qemu-action-4.0.0
Bump docker/setup-qemu-action from 3.7.0 to 4.0.0
2026-06-04 10:39:41 -07:00
Owen Schwartz
f7d874cd78 Merge pull request #71 from fosrl/dependabot/github_actions/docker/setup-buildx-action-4.0.0
Bump docker/setup-buildx-action from 3.12.0 to 4.0.0
2026-06-04 10:39:33 -07:00
Owen Schwartz
f3f70fff14 Merge pull request #73 from fosrl/dependabot/github_actions/docker/login-action-4.1.0
Bump docker/login-action from 4.0.0 to 4.1.0
2026-06-04 10:38:54 -07:00
Owen Schwartz
95e83da5c1 Merge pull request #74 from fosrl/dependabot/github_actions/actions/setup-go-6.4.0
build(deps): bump actions/setup-go from 6.3.0 to 6.4.0
2026-06-04 10:38:36 -07:00
Owen Schwartz
19a71e7c0a Merge pull request #69 from fosrl/go-update
Update Go version to 1.26 and adjust workflows and Dockerfile
2026-06-04 10:37:32 -07:00
Owen
cf863702a0 Merge branch 'main' into go-update 2026-06-04 10:36:47 -07:00
Owen
b8bb1249c9 Merge branch 'main' of github.com:fosrl/gerbil into dev 2026-06-04 10:36:02 -07:00
Owen Schwartz
18078e0e06 Merge pull request #85 from fosrl/fix/security-update-go-deps
Update Go dependencies for security fixes
2026-06-04 10:18:47 -07:00
Marc Schäfer
44622a883f fix(deps): update Go dependencies for security fixes 2026-06-04 16:01:19 +02:00
miloschwartz
3926dcc6be update issue template 2026-05-25 21:42:00 -07:00
Owen Schwartz
65bb3fc101 Merge pull request #83 from fosrl/github-action-cosign
Upgrade cosign installer to v4.1.2 and pin cosign version
2026-05-16 14:55:01 -07:00
Marc Schäfer
8683a62b4b fix(security): update cosign to v3.0.6 and installer to 4.1.2
Updated cosign installer to version 4.1.2 and specified cosign release version 3.0.6.
2026-05-16 16:34:40 +02:00
Owen
7d1f963820 Speeed up parsing 2026-05-14 16:20:16 -07:00
Owen
775def1a97 Fix rebuilding sessions 2026-05-14 15:55:54 -07:00
Owen
180b5fe768 Reduce latency calling http endpoint in hot path 2026-05-14 15:39:29 -07:00
Owen
e5241751fc Update go installed version 2026-05-03 15:46:36 -07:00
dependabot[bot]
34f8abfd37 build(deps): bump actions/setup-go from 6.3.0 to 6.4.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4b73464bb3...4a3601121d)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 22:31:01 +00:00
Owen Schwartz
77b386ecac Merge pull request #80 from fosrl/dev
1.4.0
2026-05-03 15:29:58 -07:00
Owen Schwartz
1b869c20de Merge pull request #79 from fosrl/observability-update
Enhance observability with OTLP timeout and API unification
2026-05-03 15:27:44 -07:00
Owen
375cb8b0ba Update CODEOWNERS 2026-05-03 15:26:52 -07:00
Marc Schäfer
3f95e2da25 fix(otel): revert semconv version and correct deployment environment attribute
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-05-04 00:21:10 +02:00
Marc Schäfer
cda6fa6772 feat(cli/proxy): add OTLP timeout flag and make proxy metrics resilient 2026-05-04 00:12:24 +02:00
Marc Schäfer
191b4fa26a feat(prometheus): robust label validation and registration handling 2026-05-04 00:12:24 +02:00
Marc Schäfer
73d4d4d37c feat(observability): unify backend APIs and harden OTel handling 2026-05-04 00:12:23 +02:00
Marc Schäfer
bcb5cc4746 refactor(metrics): safe initialization and instrument factory 2026-05-04 00:12:23 +02:00
Marc Schäfer
f130a7cdb8 chore(deps): update OpenTelemetry and related modules 2026-05-04 00:12:22 +02:00
Marc Schäfer
4a322a436b chore(docs): add OTLP timeout docs and minor fixes 2026-05-04 00:12:16 +02:00
Owen
b642df3e1e Add CODEOWNERS 2026-04-07 11:33:51 -04:00
dependabot[bot]
65e599f833 Bump docker/login-action from 4.0.0 to 4.1.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](b45d80f862...4907a6ddec)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 21:15:47 +00:00
dependabot[bot]
cae46757dd Bump docker/setup-buildx-action from 3.12.0 to 4.0.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.0.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](8d2750c68a...4d04d5d948)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 21:15:40 +00:00
dependabot[bot]
4b575016f3 Bump docker/setup-qemu-action from 3.7.0 to 4.0.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.7.0 to 4.0.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](c7c5346462...ce360397dd)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 21:15:37 +00:00
Marc Schäfer
9e5539a5ba Update Go version to 1.26 and adjust workflows and Dockerfile accordingly
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-04-04 02:00:13 +02:00
Owen Schwartz
279fc427a8 Merge pull request #68 from fosrl/go
Bump go version
2026-04-03 19:54:02 -04:00
Owen
127ded4203 Bump go version 2026-04-03 19:50:37 -04:00
Marc Schäfer
6c91e99497 Merge pull request #63 from fosrl/proxy-perf-improvements
perf(proxy): Add HTTP client reuse and buffer pooling
2026-04-04 01:47:55 +02:00
Marc Schäfer
58415dee7e refactor: remove redundant HTTP client instantiation in getRoute method 2026-04-04 01:44:59 +02:00
Marc Schäfer
c3ed355127 Merge branch 'dev' into proxy-perf-improvements 2026-04-04 01:37:09 +02:00
Marc Schäfer
c6e1881e6a Merge pull request #62 from LaurenceJJones/split/upstream-dev-relay-session-index
perf(relay): index WireGuard sessions by receiver index
2026-04-04 01:24:45 +02:00
Marc Schäfer
eedd813e2f Update Go version in GitHub Actions workflow 2026-04-04 01:19:47 +02:00
Marc Schäfer
3cf2ccdc54 Merge branch 'dev' into split/upstream-dev-relay-session-index 2026-04-04 01:13:09 +02:00
Owen
726b6b171c Merge branch 'main' into dev 2026-04-03 15:53:13 -04:00
Owen Schwartz
037618acbc Merge pull request #55 from fosrl/dependabot/docker/minor-updates-f918e77f61
Bump golang from 1.25-alpine to 1.26-alpine in the minor-updates group across 1 directory
2026-04-03 15:35:45 -04:00
Owen Schwartz
1a6bc81ddd Merge pull request #67 from fosrl/metrics
Integrate OpenTelemetry and Prometheus metrics across services
2026-04-03 15:35:23 -04:00
Owen Schwartz
a3dbdef7cc Merge pull request #66 from fosrl/dependabot/go_modules/prod-minor-updates-16c924d84c
Bump the prod-minor-updates group with 2 updates
2026-04-03 14:25:21 -04:00
Marc Schäfer
f07c83fde4 Update Go version to 1.25.0 and add gRPC dependency 2026-04-03 18:41:40 +02:00
Marc Schäfer
652d9c5c68 Add metrics tracking for UDP packet handling and session management 2026-04-03 18:15:58 +02:00
Marc Schäfer
e47a57cb4f Enhance metrics tracking in SNIProxy connection handling 2026-04-03 18:15:41 +02:00
Marc Schäfer
4357ddf64b Integrate metrics instrumentation across core services 2026-04-03 15:57:53 +02:00
Marc Schäfer
f322b4c921 Add OpenTelemetry and Prometheus metrics infrastructure 2026-04-03 15:57:47 +02:00
dependabot[bot]
56f72d6643 Bump golang from 1.25-alpine to 1.26-alpine in the minor-updates group
Bumps the minor-updates group with 1 update: golang.


Updates `golang` from 1.25-alpine to 1.26-alpine

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.26-alpine
  dependency-type: direct:production
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 21:06:41 +00:00
dependabot[bot]
367e5bfa08 Bump the prod-minor-updates group with 2 updates
Bumps the prod-minor-updates group with 2 updates: [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/sync](https://github.com/golang/sync).


Updates `golang.org/x/crypto` from 0.47.0 to 0.49.0
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.49.0)

Updates `golang.org/x/sync` from 0.19.0 to 0.20.0
- [Commits](https://github.com/golang/sync/compare/v0.19.0...v0.20.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: golang.org/x/sync
  dependency-version: 0.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 21:06:24 +00:00
Marc Schäfer
aeb8b7c56f Merge pull request #52 from fosrl/dependabot/go_modules/prod-minor-updates-4320415815
Bump the prod-minor-updates group with 2 updates
2026-04-02 20:47:29 +02:00
Marc Schäfer
f5c77d7df8 Merge pull request #56 from fosrl/dependabot/github_actions/actions/upload-artifact-7.0.0
Bump actions/upload-artifact from 6.0.0 to 7.0.0
2026-04-02 20:42:41 +02:00
Marc Schäfer
a37aadddb5 Merge pull request #53 from fosrl/dependabot/github_actions/actions/checkout-6.0.2
Bump actions/checkout from 6.0.1 to 6.0.2
2026-04-02 20:42:07 +02:00
Marc Schäfer
80747bf98b Merge pull request #57 from fosrl/dependabot/github_actions/actions/setup-go-6.3.0
Bump actions/setup-go from 6.2.0 to 6.3.0
2026-04-02 20:41:20 +02:00
Marc Schäfer
69418a439c Merge pull request #58 from fosrl/dependabot/github_actions/sigstore/cosign-installer-4.1.0
Bump sigstore/cosign-installer from 4.0.0 to 4.1.0
2026-04-02 20:40:30 +02:00
Marc Schäfer
d065897c4d Merge pull request #59 from fosrl/dependabot/github_actions/docker/login-action-4.0.0
Bump docker/login-action from 3.6.0 to 4.0.0
2026-04-02 20:39:56 +02:00
Owen
b57574cc4b IFB ingress limiting 2026-03-31 21:56:41 -07:00
Owen
a3862260c9 Add var for b limit 2026-03-31 20:35:05 -07:00
Owen
40da38708c Update logging 2026-03-20 16:11:10 -07:00
Owen
3af64d8bd3 Merge branch 'dev' of github.com:fosrl/gerbil into dev 2026-03-20 16:04:39 -07:00
Owen
fcead8cc15 Add rate limit to hole punch 2026-03-20 16:02:58 -07:00
Owen Schwartz
20dad7bb8e Merge pull request #60 from LaurenceJJones/split/upstream-dev-relay-worker-scaling
perf(relay): scale packet workers and queue depth for throughput
2026-03-18 15:56:46 -07:00
Owen Schwartz
a955aa6169 Merge pull request #61 from LaurenceJJones/split/upstream-dev-relay-addr-cache
perf(relay): cache resolved UDP destinations with TTL
2026-03-18 15:56:32 -07:00
Laurence
c7d9c72f29 Add HTTP client reuse and buffer pooling for performance
- Add reusable HTTP client with connection pooling for API requests
- Add sync.Pool for 32KB buffers used in connection piping
- Clear buffers before returning to pool to prevent data leakage
- Reduces GC pressure and improves throughput under load
2026-03-13 15:28:04 +00:00
Laurence
abc744c647 perf(relay): index WireGuard sessions by receiver index 2026-03-12 12:55:49 +00:00
Laurence
b118fef265 perf(relay): cache resolved UDP destinations with TTL 2026-03-12 12:54:59 +00:00
Laurence
7985f97eb6 perf(relay): scale packet workers and queue depth for throughput 2026-03-12 12:54:02 +00:00
dependabot[bot]
986a2c6bb6 Bump docker/login-action from 3.6.0 to 4.0.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.6.0 to 4.0.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](5e57cd1181...b45d80f862)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 21:59:21 +00:00
dependabot[bot]
58674ec025 Bump sigstore/cosign-installer from 4.0.0 to 4.1.0
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](faadad0cce...ba7bc0a3fe)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 21:59:17 +00:00
dependabot[bot]
5dbe3dbb84 Bump actions/setup-go from 6.2.0 to 6.3.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](7a3fe6cf4c...4b73464bb3)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 22:48:25 +00:00
dependabot[bot]
32d7af44ca Bump actions/upload-artifact from 6.0.0 to 7.0.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](b7c566a772...bbbca2ddaa)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 22:48:17 +00:00
Owen
b9261b8fea Add optional tc 2026-02-27 15:45:17 -08:00
dependabot[bot]
fdc398eb9c Bump the prod-minor-updates group with 2 updates
Bumps the prod-minor-updates group with 2 updates: [golang.org/x/crypto](https://github.com/golang/crypto) and [golang.org/x/sync](https://github.com/golang/sync).


Updates `golang.org/x/crypto` from 0.46.0 to 0.47.0
- [Commits](https://github.com/golang/crypto/compare/v0.46.0...v0.47.0)

Updates `golang.org/x/sync` from 0.1.0 to 0.19.0
- [Commits](https://github.com/golang/sync/compare/v0.1.0...v0.19.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: golang.org/x/sync
  dependency-version: 0.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 21:06:47 +00:00
Owen
c3e73d0189 Merge branch 'main' of github.com:fosrl/gerbil 2026-01-26 15:22:20 -08:00
dependabot[bot]
df2fbdf160 Bump golang.org/x/crypto in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 15:22:08 -08:00
dependabot[bot]
cb4ac8199d Bump actions/checkout from 6.0.0 to 6.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](1af3b93b68...8e8c483db8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 15:22:08 -08:00
dependabot[bot]
dd4b86b3e5 Bump actions/upload-artifact from 5.0.0 to 6.0.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](330a01c490...b7c566a772)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 15:22:08 -08:00
dependabot[bot]
bad290aa4e Bump docker/setup-buildx-action from 3.11.1 to 3.12.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.11.1 to 3.12.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](e468171a9d...8d2750c68a)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 15:22:08 -08:00
dependabot[bot]
8c27d5e3bf Bump actions/setup-go from 6.1.0 to 6.2.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4dc6199c7b...7a3fe6cf4c)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 15:22:08 -08:00
Owen
7e7a37d49c We dont really support the config file anymore
Ref #30
2026-01-26 15:22:08 -08:00
Owen
d44aa97f32 Try to fix mem leak 2026-01-26 15:22:08 -08:00
Owen
b57ad74589 Quiet normal log message
Fixes #2057
2026-01-26 15:22:08 -08:00
Owen
82256a3f6f Add healthcheck route 2026-01-26 15:22:08 -08:00
Owen
9e140a94db Add regex to runs on 2026-01-26 15:22:08 -08:00
Owen
d0c9ea5a57 Fix docker username issue 2026-01-26 15:22:08 -08:00
Owen
c88810ef24 Restrict inbound traffic 2026-01-26 15:21:28 -08:00
dependabot[bot]
fcd290272f Bump actions/checkout from 6.0.1 to 6.0.2
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](8e8c483db8...de0fac2e45)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 22:34:44 +00:00
dependabot[bot]
463a4eea79 Bump golang.org/x/crypto in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 17:30:51 -08:00
dependabot[bot]
4576a2e8a7 Bump actions/checkout from 6.0.0 to 6.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](1af3b93b68...8e8c483db8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 17:30:42 -08:00
dependabot[bot]
69c13adcdb Bump actions/upload-artifact from 5.0.0 to 6.0.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](330a01c490...b7c566a772)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 17:30:34 -08:00
dependabot[bot]
3886c1a8c1 Bump docker/setup-buildx-action from 3.11.1 to 3.12.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.11.1 to 3.12.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](e468171a9d...8d2750c68a)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 17:30:26 -08:00
dependabot[bot]
06eb4d4310 Bump actions/setup-go from 6.1.0 to 6.2.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4dc6199c7b...7a3fe6cf4c)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 17:30:18 -08:00
Owen
247c47b27f We dont really support the config file anymore
Ref #30
2026-01-19 17:29:04 -08:00
Owen
060038c29b Try to fix mem leak 2025-12-12 22:14:13 -05:00
Owen
5414d21dcd Quiet normal log message
Fixes #2057
2025-12-12 18:40:33 -05:00
Owen
364fa020aa Add healthcheck route 2025-12-12 11:47:04 -05:00
Owen
b96ee16fbf Add regex to runs on 2025-12-11 16:19:08 -05:00
Owen
467d69aa7c Fix docker username issue 2025-12-08 14:51:13 -05:00
Owen
7c7762ebc5 Merge branch 'main' into dev 2025-12-06 12:16:15 -05:00
Owen
526f9c8b4e Remove diff 2025-12-06 12:16:03 -05:00
Owen
905983cf61 Merge branch 'main' into dev 2025-12-06 12:15:34 -05:00
Owen
a0879114e2 Merge branch 'LaurenceJJones-enhancement/errgroup-context-propagation' 2025-12-06 12:15:09 -05:00
Owen
0d54a07973 Merge branch 'enhancement/errgroup-context-propagation' of github.com:LaurenceJJones/gerbil into LaurenceJJones-enhancement/errgroup-context-propagation 2025-12-06 12:14:58 -05:00
Owen Schwartz
4cb2fde961 Merge pull request #36 from LaurenceJJones/fix-wg-session-race-condition
fix: relay race condition in WireGuard session management
2025-12-06 12:12:04 -05:00
Owen Schwartz
9602599565 Merge pull request #38 from LaurenceJJones/fix/relay-buffer-leak
fix: relay buffer leak on UDP read error
2025-12-06 12:07:31 -05:00
Owen Schwartz
11f858b341 Merge pull request #39 from LaurenceJJones/fix/calcpeerbandwidth-optimization
feat: optimize calculatePeerBandwidth to avoid nested loops
2025-12-06 11:59:00 -05:00
Owen Schwartz
29b2cb33a2 Merge pull request #42 from fosrl/dependabot/github_actions/actions/setup-go-6.1.0
Bump actions/setup-go from 6.0.0 to 6.1.0
2025-12-06 11:48:22 -05:00
Owen Schwartz
34290ffe09 Merge pull request #43 from fosrl/dependabot/github_actions/actions/checkout-6.0.0
Bump actions/checkout from 5.0.0 to 6.0.0
2025-12-06 11:48:14 -05:00
Owen Schwartz
1013d0591e Merge pull request #44 from fosrl/dependabot/docker/minor-updates-60be0b6e22
Bump alpine from 3.22 to 3.23 in the minor-updates group
2025-12-06 11:48:07 -05:00
dependabot[bot]
2f6d62ab45 Bump alpine from 3.22 to 3.23 in the minor-updates group
Bumps the minor-updates group with 1 update: alpine.


Updates `alpine` from 3.22 to 3.23

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.23'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 21:23:35 +00:00
Owen
8d6ba79408 Send public key 2025-12-01 16:19:57 -05:00
Owen Schwartz
208b434cb7 Merge pull request #41 from fosrl/dependabot/go_modules/prod-minor-updates-dd7da38a6b
Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in the prod-minor-updates group
2025-11-29 13:03:37 -05:00
dependabot[bot]
39ce0ac407 Bump actions/checkout from 5.0.0 to 6.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](08c6903cd8...1af3b93b68)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 22:19:10 +00:00
dependabot[bot]
72bee56412 Bump actions/setup-go from 6.0.0 to 6.1.0
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](4469467582...4dc6199c7b)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 22:19:05 +00:00
dependabot[bot]
b32da3a714 Bump golang.org/x/crypto in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-19 21:25:00 +00:00
Laurence
971452e5d3 revert: drop logger formatting changes from calcpeerbandwidth optimization branch 2025-11-16 08:42:57 +00:00
Laurence
bba4345b0f main: optimize calculatePeerBandwidth to avoid nested peer scans
Build a set of current peer public keys during the primary iteration and
prune lastReadings in a single pass, removing the O(n^2) nested loop.

No behavior change; improves efficiency when peer lists and lastReadings
grow large.
2025-11-16 08:40:26 +00:00
Laurence
b2392fb250 relay: fix buffer leak on UDP read error by returning buffer to pool
When ReadFromUDP fails in readPackets, the buffer was not returned to the
sync.Pool, causing a small but persistent leak under error conditions.
Return the buffer before continuing to ensure reuse and stable memory.

Scope: minimal hotfix (no broader refactors).
2025-11-16 06:07:48 +00:00
Laurence
697f4131e7 enhancement: base context + errgroup; propagate cancellation; graceful shutdown
- main: add base context via signal.NotifyContext; establish errgroup and use it to supervise background tasks; convert ticker to context-aware periodicBandwidthCheck; run HTTP server under errgroup and add graceful shutdown; treat context.Canceled as normal exit
- relay: thread parent context through UDPProxyServer; add cancel func; make packet reader, workers, and cleanup tickers exit on ctx.Done; Stop cancels, closes listener and downstream UDP connections, and closes packet channel to drain workers
- proxy: drop earlier parent context hook for SNI proxy per review; rely on existing Stop() for graceful shutdown

Benefits:
- unified lifecycle and deterministic shutdown across components
- prevents leaked goroutines/tickers and closes sockets cleanly
- consolidated error handling via g.Wait(), with context cancellation treated as non-error
- sets foundation for child errgroups and future structured concurrency
2025-11-16 06:00:32 +00:00
Laurence Jones
e282715251 Merge branch 'main' into fix-wg-session-race-condition 2025-11-16 05:29:46 +00:00
Owen Schwartz
709df6db3e Merge pull request #33 from fosrl/dependabot/github_actions/actions/upload-artifact-5.0.0
Bump actions/upload-artifact from 4.6.2 to 5.0.0
2025-11-13 15:53:58 -05:00
Owen Schwartz
cf2b436470 Merge pull request #34 from fosrl/dependabot/github_actions/docker/setup-qemu-action-3.7.0
Bump docker/setup-qemu-action from 3.6.0 to 3.7.0
2025-11-13 15:53:00 -05:00
Owen Schwartz
2a29021572 Merge pull request #35 from fosrl/dependabot/go_modules/prod-minor-updates-c94709d3c3
Bump golang.org/x/crypto from 0.43.0 to 0.44.0 in the prod-minor-updates group
2025-11-13 15:52:54 -05:00
Laurence
a3f9a89079 Refactor WireGuard session locking and remove unused methods
- Remove unused methods: UpdateLastSeen, GetSenderIndex, MatchesSenderIndex
  (replaced by simpler direct usage in Range callbacks)
- Simplify session access pattern: check GetSenderIndex in Range callback,
  then call GetDestAddr and UpdateLastSeen when match found
- Optimize UpdateLastSeen usage: only use for existing sessions already
  in sync.Map; use direct assignment in struct literals for new sessions
  (safe since no concurrent access during creation)

This simplifies the code while maintaining thread-safety for concurrent
access to existing sessions.
2025-11-13 06:43:31 +00:00
Laurence
ee27bf3153 Fix race condition in WireGuard session management
The race condition existed because while sync.Map is thread-safe for map
operations (Load, Store, Delete, Range), it does not provide thread-safety
for the data stored within it. When WireGuardSession structs were stored as
pointers in the sync.Map, multiple goroutines could:

1. Retrieve the same session pointer from the map concurrently
2. Access and modify the session's fields (particularly LastSeen) without
   synchronization
3. Cause data races when one goroutine reads LastSeen while another updates it

This fix adds a sync.RWMutex to each WireGuardSession struct to protect
concurrent access to its fields. All field access now goes through
thread-safe methods that properly acquire/release the mutex.

Changes:
- Added sync.RWMutex to WireGuardSession struct
- Added thread-safe accessor methods (GetLastSeen, GetDestAddr, etc.)
- Added atomic CheckAndUpdateIfMatch method for efficient check-and-update
- Updated all session field accesses to use thread-safe methods
- Removed redundant Store call after updating LastSeen (pointer update is
  atomic in Go, but field access within pointer was not)
2025-11-13 06:26:09 +00:00
dependabot[bot]
a90f681957 Bump golang.org/x/crypto in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-11 21:19:30 +00:00
dependabot[bot]
3afc82ef9a Bump docker/setup-qemu-action from 3.6.0 to 3.7.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.6.0 to 3.7.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](29109295f8...c7c5346462)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 21:51:03 +00:00
dependabot[bot]
d3a16f4c59 Bump actions/upload-artifact from 4.6.2 to 5.0.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](ea165f8d65...330a01c490)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 22:24:20 +00:00
Owen
2a1911a66f Update runner to amd64-runner 2025-10-20 16:43:18 -07:00
Owen Schwartz
08341b2385 Merge pull request #32 from marcschaeferger/gh-action
Adding GHCR to CI/CD Release Workflow & further improvements
2025-10-20 16:39:54 -07:00
Marc Schäfer
6cde07d479 ci(actions): add GHCR mirroring and cosign signing for Docker images
- mirror images from Docker Hub to GHCR using skopeo (preserves multi-arch manifests)
- login to GHCR via docker/login-action for signing/pushing
- install cosign and perform dual signing: keyless (OIDC) + key-based; verify signatures
- add required permissions for id-token/packages and reference necessary secrets
2025-10-21 01:30:51 +02:00
Marc Schäfer
06b1e84f99 feat(ci): add step to update version in main.go during CI/CD pipeline 2025-10-21 01:20:08 +02:00
Marc Schäfer
2b7e93ec92 ci(actions): add permissions section to CI/CD and test workflows 2025-10-21 01:19:36 +02:00
Marc Schäfer
ca23ae7a30 ci(actions): pin action versions to commit SHAs for security
- Pin actions/checkout to SHA for v5.0.0
- Pin docker/setup-qemu-action to SHA for v3.6.0
- Pin docker/setup-buildx-action to SHA for v3.11.1
- Pin docker/login-action to SHA for v3.6.0
- Pin actions/setup-go to SHA for v6.0.0
- Pin actions/upload-artifact to SHA for v4.6.2
2025-10-21 01:18:33 +02:00
Owen
661fd86305 Update to use gerbil and not newt 2025-10-20 12:59:17 -07:00
Owen Schwartz
594a499b95 Merge pull request #31 from marcschaeferger/ghcr
feat(actions): Sync Images from Docker to GHCR
2025-10-20 12:57:53 -07:00
Marc Schäfer
44aed84827 feat(actions): Sync Images from Docker to GHCR 2025-10-20 21:56:04 +02:00
Owen
bf038eb4a2 Update domain 2025-10-19 15:12:56 -07:00
Owen
6da3129b4e Update port 2025-10-18 15:07:50 -07:00
Owen
ac0f9b6a82 Update cicd 2025-10-16 14:09:30 -07:00
Owen
16aef10cca Merge branch 'main' of github.com:fosrl/gerbil 2025-10-16 13:41:42 -07:00
Owen
19031ebdfd Move to gen the port in the right place 2025-10-16 13:40:01 -07:00
Owen
0eebbc51d5 Deprecate --listen 2025-10-16 10:38:47 -07:00
Owen
d321a8ba7e Dont require proxy protocol from known hosts 2025-10-14 21:05:30 -07:00
Owen Schwartz
3ea86222ca Merge pull request #29 from fosrl/dependabot/go_modules/prod-minor-updates-ce64870c5e
Bump golang.org/x/crypto from 0.42.0 to 0.43.0 in the prod-minor-updates group
2025-10-11 09:41:08 -07:00
dependabot[bot]
c3ebe930d9 Bump golang.org/x/crypto in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.42.0 to 0.43.0
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-08 21:24:05 +00:00
Owen Schwartz
f2b96f2a38 Merge pull request #28 from SigmaSquadron/push-unypywyqkkrl
Change default port to 3004 to avoid a conflict with Pangolin's integration API.
2025-10-05 17:41:21 -07:00
Owen
9038239bbe Accept proxy protocol from other nodes 2025-09-29 21:56:15 -07:00
miloschwartz
3e64eb9c4f add templates 2025-09-29 16:41:29 -07:00
Owen
92992b8c14 Merge branch 'main' into dev 2025-09-28 16:28:07 -07:00
Owen
4ee9d77532 Rebuild sessions 2025-09-28 15:31:34 -07:00
Owen Schwartz
bd7a5bd4b0 Merge pull request #26 from fosrl/dependabot/github_actions/actions/setup-go-6
Bump actions/setup-go from 5 to 6
2025-09-15 14:43:53 -07:00
Owen Schwartz
1cd49f8ee3 Merge pull request #27 from fosrl/dependabot/go_modules/prod-minor-updates-237ba4726d
Bump golang.org/x/crypto from 0.41.0 to 0.42.0 in the prod-minor-updates group
2025-09-15 14:43:41 -07:00
Fernando Rodrigues
7a919d867b Change default port to 3004 to avoid a conflict with Pangolin's integration API.
Signed-off-by: Fernando Rodrigues <alpha@sigmasquadron.net>
2025-09-14 23:19:21 +10:00
dependabot[bot]
ce50c627a7 Bump golang.org/x/crypto in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 21:53:42 +00:00
dependabot[bot]
691d5f0271 Bump actions/setup-go from 5 to 6
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 21:52:09 +00:00
Owen Schwartz
56151089e3 Merge pull request #24 from Lokowitz/dockerfile-update
changed docker image from ubuntu to alpine
2025-08-31 09:56:28 -07:00
Lokowitz
af7c1caf98 changed docker image from ubuntu to alpine 2025-08-31 11:19:16 +00:00
Owen Schwartz
dd208ab67c Merge pull request #22 from fosrl/dependabot/go_modules/prod-minor-updates-d1569a22cb
Bump golang.org/x/crypto from 0.36.0 to 0.41.0 in the prod-minor-updates group
2025-08-30 15:14:02 -07:00
Owen Schwartz
8189d41a45 Merge pull request #21 from fosrl/dependabot/github_actions/actions/checkout-5
Bump actions/checkout from 4 to 5
2025-08-30 15:13:54 -07:00
dependabot[bot]
ea3477c8ce Bump golang.org/x/crypto in the prod-minor-updates group
Bumps the prod-minor-updates group with 1 update: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.36.0 to 0.41.0
- [Commits](https://github.com/golang/crypto/compare/v0.36.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-27 21:35:30 +00:00
dependabot[bot]
b03f8911a5 Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-23 04:37:59 +00:00
37 changed files with 6303 additions and 341 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @oschwartz10612 @miloschwartz

View File

@@ -0,0 +1,47 @@
body:
- type: textarea
attributes:
label: Summary
description: A clear and concise summary of the requested feature.
validations:
required: true
- type: textarea
attributes:
label: Motivation
description: |
Why is this feature important?
Explain the problem this feature would solve or what use case it would enable.
validations:
required: true
- type: textarea
attributes:
label: Proposed Solution
description: |
How would you like to see this feature implemented?
Provide as much detail as possible about the desired behavior, configuration, or changes.
validations:
required: true
- type: textarea
attributes:
label: Alternatives Considered
description: Describe any alternative solutions or workarounds you've thought about.
validations:
required: false
- type: textarea
attributes:
label: Additional Context
description: Add any other context, mockups, or screenshots about the feature request here.
validations:
required: false
- type: markdown
attributes:
value: |
Before submitting, please:
- Check if there is an existing issue for this feature.
- Clearly explain the benefit and use case.
- Be as specific as possible to help contributors evaluate and implement.

52
.github/ISSUE_TEMPLATE/1.bug_report.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Bug Report
description: Create a bug report
labels: []
body:
- type: textarea
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Environment
description: Please fill out the relevant details below for your environment.
value: |
- OS Type & Version:
- Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version:
- Traefik Version:
- Newt Version:
- Client Version:
validations:
required: true
- type: textarea
attributes:
label: To Reproduce
description: |
Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below.
If using code blocks, make sure syntax highlighting is correct and double-check that the rendered preview is not broken.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: markdown
attributes:
value: |
Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear.
- type: markdown
attributes:
value: |
Contributors should be able to follow the steps provided in order to reproduce the bug.

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Need help or have questions?
url: https://github.com/orgs/fosrl/discussions
about: Ask questions, get help, and discuss with other community members
- name: Request a Feature
url: https://github.com/orgs/fosrl/discussions/new?category=feature-requests
about: Feature requests should be opened as discussions so others can upvote and comment

View File

@@ -1,40 +1,32 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "gomod" - package-ecosystem: "gomod"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
dev-patch-updates: go-dependencies:
dependency-type: "development" patterns:
update-types: - "*"
- "patch"
dev-minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
- package-ecosystem: "docker" - package-ecosystem: "docker"
directory: "/" directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 1
groups: groups:
patch-updates: docker-dependencies:
update-types: patterns:
- "patch" - "*"
minor-updates:
update-types:
- "minor"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 1
groups:
github-actions-dependencies:
patterns:
- "*"

View File

@@ -1,52 +1,162 @@
name: CI/CD Pipeline name: CI/CD Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
permissions:
contents: read
packages: write # for GHCR push
id-token: write # for Cosign Keyless (OIDC) Signing
# Required secrets:
# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub
# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing
# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing
on: on:
push: push:
tags: tags:
- "*" - "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+.rc.[0-9]+"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
release: release:
name: Build and Release name: Build and Release
runs-on: ubuntu-latest runs-on: amd64-runner
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract tag name - name: Extract tag name
id: get-tag id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Install Go - name: Install Go
uses: actions/setup-go@v5 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: 1.25 go-version: 1.26
- name: Build and push Docker images - name: Update version in main.go
run: |
TAG=${{ env.TAG }}
if [ -f main.go ]; then
sed -i 's/version_replaceme/'"$TAG"'/' main.go
echo "Updated main.go with version $TAG"
else
echo "main.go not found"
fi
shell: bash
- name: Build and push Docker images (Docker Hub)
run: | run: |
TAG=${{ env.TAG }} TAG=${{ env.TAG }}
make docker-build-release tag=$TAG make docker-build-release tag=$TAG
echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
shell: bash
- name: Login in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install skopeo + jq
# skopeo: copy/inspect images between registries
# jq: JSON parsing tool used to extract digest values
run: |
sudo apt-get update -y
sudo apt-get install -y skopeo jq
skopeo --version
shell: bash
- name: Copy tag from Docker Hub to GHCR
# Mirror the already-built image (all architectures) to GHCR so we can sign it
run: |
set -euo pipefail
TAG=${{ env.TAG }}
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:$TAG \
docker://$GHCR_IMAGE:$TAG
shell: bash
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
- name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each image by digest using keyless (OIDC) and key-based signing,
# then verify both the public key signature and the keyless OIDC signature.
env:
TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true"
run: |
set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
echo "Processing ${IMAGE}:${TAG}"
DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')"
REF="${IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
echo "==> cosign verify (public key) ${REF}"
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
echo "==> cosign verify (keyless policy) ${REF}"
cosign verify \
--certificate-oidc-issuer "${issuer}" \
--certificate-identity-regexp "${id_regex}" \
"${REF}" -o text
done
shell: bash
- name: Build binaries - name: Build binaries
run: | run: |
make go-build-release make go-build-release
shell: bash
- name: Upload artifacts from /bin - name: Upload artifacts from /bin
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: binaries name: binaries
path: bin/ path: bin/

132
.github/workflows/mirror.yaml vendored Normal file
View File

@@ -0,0 +1,132 @@
name: Mirror & Sign (Docker Hub to GHCR)
on:
workflow_dispatch: {}
permissions:
contents: read
packages: write
id-token: write # for keyless OIDC
env:
SOURCE_IMAGE: docker.io/fosrl/gerbil
DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
jobs:
mirror-and-dual-sign:
runs-on: amd64-runner
steps:
- name: Install skopeo + jq
run: |
sudo apt-get update -y
sudo apt-get install -y skopeo jq
skopeo --version
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Input check
run: |
test -n "${SOURCE_IMAGE}" || (echo "SOURCE_IMAGE is empty" && exit 1)
echo "Source : ${SOURCE_IMAGE}"
echo "Target : ${DEST_IMAGE}"
# Auth for skopeo (containers-auth)
- name: Skopeo login to GHCR
run: |
skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
# Auth for cosign (docker-config)
- name: Docker login to GHCR (for cosign)
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: List source tags
run: |
set -euo pipefail
skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \
| jq -r '.Tags[]' | sort -u > src-tags.txt
echo "Found source tags: $(wc -l < src-tags.txt)"
head -n 20 src-tags.txt || true
- name: List destination tags (skip existing)
run: |
set -euo pipefail
if skopeo list-tags --retry-times 3 docker://"${DEST_IMAGE}" >/tmp/dst.json 2>/dev/null; then
jq -r '.Tags[]' /tmp/dst.json | sort -u > dst-tags.txt
else
: > dst-tags.txt
fi
echo "Existing destination tags: $(wc -l < dst-tags.txt)"
- name: Mirror, dual-sign, and verify
env:
# keyless
COSIGN_YES: "true"
# key-based
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
# verify
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
run: |
set -euo pipefail
copied=0; skipped=0; v_ok=0; errs=0
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+"
while read -r tag; do
[ -z "$tag" ] && continue
if grep -Fxq "$tag" dst-tags.txt; then
echo "::notice ::Skip (exists) ${DEST_IMAGE}:${tag}"
skipped=$((skipped+1))
continue
fi
echo "==> Copy ${SOURCE_IMAGE}:${tag} → ${DEST_IMAGE}:${tag}"
if ! skopeo copy --all --retry-times 3 \
docker://"${SOURCE_IMAGE}:${tag}" docker://"${DEST_IMAGE}:${tag}"; then
echo "::warning title=Copy failed::${SOURCE_IMAGE}:${tag}"
errs=$((errs+1)); continue
fi
copied=$((copied+1))
digest="$(skopeo inspect --retry-times 3 docker://"${DEST_IMAGE}:${tag}" | jq -r '.Digest')"
ref="${DEST_IMAGE}@${digest}"
echo "==> cosign sign (keyless) --recursive ${ref}"
if ! cosign sign --recursive "${ref}"; then
echo "::warning title=Keyless sign failed::${ref}"
errs=$((errs+1))
fi
echo "==> cosign sign (key) --recursive ${ref}"
if ! cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${ref}"; then
echo "::warning title=Key sign failed::${ref}"
errs=$((errs+1))
fi
echo "==> cosign verify (public key) ${ref}"
if ! cosign verify --key env://COSIGN_PUBLIC_KEY "${ref}" -o text; then
echo "::warning title=Verify(pubkey) failed::${ref}"
errs=$((errs+1))
fi
echo "==> cosign verify (keyless policy) ${ref}"
if ! cosign verify \
--certificate-oidc-issuer "${issuer}" \
--certificate-identity-regexp "${id_regex}" \
"${ref}" -o text; then
echo "::warning title=Verify(keyless) failed::${ref}"
errs=$((errs+1))
else
v_ok=$((v_ok+1))
fi
done < src-tags.txt
echo "---- Summary ----"
echo "Copied : $copied"
echo "Skipped : $skipped"
echo "Verified OK : $v_ok"
echo "Errors : $errs"

View File

@@ -1,5 +1,8 @@
name: Run Tests name: Run Tests
permissions:
contents: read
on: on:
pull_request: pull_request:
branches: branches:
@@ -8,15 +11,19 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: amd64-runner
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Read go version
id: goversion
run: echo "version=$(cat .go-version)" >> $GITHUB_OUTPUT
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version: '1.25' go-version: ${{ steps.goversion.outputs.version }}
- name: Build go - name: Build go
run: go build run: go build

View File

@@ -1 +1 @@
1.25 1.26

View File

@@ -4,11 +4,7 @@ Contributions are welcome!
Please see the contribution and local development guide on the docs page before getting started: Please see the contribution and local development guide on the docs page before getting started:
https://docs.fossorial.io/development https://docs.pangolin.net/development/contributing
For ideas about what features to work on and our future plans, please see the roadmap:
https://docs.fossorial.io/roadmap
### Licensing Considerations ### Licensing Considerations

View File

@@ -1,4 +1,5 @@
FROM golang:1.25-alpine AS builder ARG GO_VERSION=1.26
FROM golang:${GO_VERSION}-alpine AS builder
# Set the working directory inside the container # Set the working directory inside the container
WORKDIR /app WORKDIR /app
@@ -16,18 +17,13 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /gerbil RUN CGO_ENABLED=0 GOOS=linux go build -o /gerbil
# Start a new stage from scratch # Start a new stage from scratch
FROM ubuntu:24.04 AS runner FROM alpine:3.24 AS runner
RUN apt-get update && apt-get install -y iptables iproute2 && rm -rf /var/lib/apt/lists/* RUN apk add --no-cache iptables iproute2
# Copy the pre-built binary file from the previous stage and the entrypoint script
COPY --from=builder /gerbil /usr/local/bin/ COPY --from=builder /gerbil /usr/local/bin/
COPY entrypoint.sh / COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
# Copy the entrypoint script
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
# Command to run the executable
CMD ["gerbil"] CMD ["gerbil"]

View File

@@ -1,4 +1,6 @@
GO_VERSION := $(shell cat .go-version 2>/dev/null)
all: build push all: build push
docker-build-release: docker-build-release:
@@ -7,10 +9,10 @@ docker-build-release:
exit 1; \ exit 1; \
fi fi
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/gerbil:latest -f Dockerfile --push . docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/gerbil:latest -f Dockerfile --push .
docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/gerbil:$(tag) -f Dockerfile --push . docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/gerbil:$(tag) -f Dockerfile --build-arg GO_VERSION=$(GO_VERSION) --push .
build: build:
docker build -t fosrl/gerbil:latest . docker build -t fosrl/gerbil:latest --build-arg GO_VERSION=$(GO_VERSION) .
push: push:
docker push fosrl/gerbil:latest docker push fosrl/gerbil:latest

View File

@@ -6,7 +6,7 @@ Gerbil is a simple [WireGuard](https://www.wireguard.com/) interface management
Gerbil works with Pangolin, Newt, and Olm as part of the larger system. See documentation below: Gerbil works with Pangolin, Newt, and Olm as part of the larger system. See documentation below:
- [Full Documentation](https://docs.fossorial.io) - [Full Documentation](https://docs.pangolin.net)
## Key Functions ## Key Functions
@@ -20,7 +20,7 @@ Gerbil will create the peers defined in the config on the WireGuard interface. T
### Report Bandwidth ### Report Bandwidth
Bytes transmitted in and out of each peer are collected every 10 seconds, and incremental usage is reported via the "reportBandwidthTo" endpoint. This can be used to track data usage of each peer on the remote server. Bytes transmitted in and out of each peer are collected every 10 seconds, and incremental usage is reported via the api endpoint. This can be used to track data usage of each peer on the remote server.
### Handle client relaying ### Handle client relaying
@@ -40,18 +40,35 @@ The PROXY protocol allows downstream proxies to know the real client IP address
In single node (self hosted) Pangolin deployments this can be bypassed by using port 443:443 to route to Traefik instead of the SNI proxy at 8443. In single node (self hosted) Pangolin deployments this can be bypassed by using port 443:443 to route to Traefik instead of the SNI proxy at 8443.
### Observability with OpenTelemetry
Gerbil includes comprehensive OpenTelemetry metrics instrumentation for monitoring and observability. Metrics can be exported via:
- **Prometheus**: Pull-based metrics at the `/metrics` endpoint (enabled by default)
- **OTLP**: Push-based metrics to any OpenTelemetry-compatible collector
Key metrics include:
- WireGuard interface and peer status
- Bandwidth usage per peer
- Active relay sessions and proxy connections
- Handshake success/failure rates
- Route lookup cache hit/miss ratios
- Go runtime metrics (GC, goroutines, memory)
See [docs/observability.md](docs/observability.md) for complete documentation, metrics reference, and examples.
## CLI Args ## CLI Args
Important:
- `reachableAt`: How should the remote server reach Gerbil's API? - `reachableAt`: How should the remote server reach Gerbil's API?
- `generateAndSaveKeyTo`: Where to save the generated WireGuard private key to persist across restarts. - `generateAndSaveKeyTo`: Where to save the generated WireGuard private key to persist across restarts.
- `remoteConfig` (optional): Remote config location to HTTP get the JSON based config from. See `example_config.json` - `remoteConfig`: Remote config location to HTTP get the JSON based config from.
- `config` (optional): Local JSON file path to load config. Used if remote config is not supplied. See `example_config.json`
Note: You must use either `config` or `remoteConfig` to configure WireGuard.
Others:
- `reportBandwidthTo` (optional): **DEPRECATED** - Use `remoteConfig` instead. Remote HTTP endpoint to send peer bandwidth data - `reportBandwidthTo` (optional): **DEPRECATED** - Use `remoteConfig` instead. Remote HTTP endpoint to send peer bandwidth data
- `interface` (optional): Name of the WireGuard interface created by Gerbil. Default: `wg0` - `interface` (optional): Name of the WireGuard interface created by Gerbil. Default: `wg0`
- `listen` (optional): Port to listen on for HTTP server. Default: `:3003` - `listen` (optional): Port to listen on for HTTP server. Default: `:3004`
- `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: `INFO` - `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: `INFO`
- `mtu` (optional): MTU of the WireGuard interface. Default: `1280` - `mtu` (optional): MTU of the WireGuard interface. Default: `1280`
- `notify` (optional): URL to notify on peer changes - `notify` (optional): URL to notify on peer changes
@@ -66,7 +83,6 @@ Note: You must use either `config` or `remoteConfig` to configure WireGuard.
All CLI arguments can also be provided via environment variables: All CLI arguments can also be provided via environment variables:
- `INTERFACE`: Name of the WireGuard interface - `INTERFACE`: Name of the WireGuard interface
- `CONFIG`: Path to local configuration file
- `REMOTE_CONFIG`: URL of the remote config server - `REMOTE_CONFIG`: URL of the remote config server
- `LISTEN`: Address to listen on for HTTP server - `LISTEN`: Address to listen on for HTTP server
- `GENERATE_AND_SAVE_KEY_TO`: Path to save generated private key - `GENERATE_AND_SAVE_KEY_TO`: Path to save generated private key
@@ -84,7 +100,7 @@ Example:
```bash ```bash
./gerbil \ ./gerbil \
--reachableAt=http://gerbil:3003 \ --reachableAt=http://gerbil:3004 \
--generateAndSaveKeyTo=/var/config/key \ --generateAndSaveKeyTo=/var/config/key \
--remoteConfig=http://pangolin:3001/api/v1/ --remoteConfig=http://pangolin:3001/api/v1/
``` ```
@@ -96,7 +112,7 @@ services:
container_name: gerbil container_name: gerbil
restart: unless-stopped restart: unless-stopped
command: command:
- --reachableAt=http://gerbil:3003 - --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key - --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/ - --remoteConfig=http://pangolin:3001/api/v1/
volumes: volumes:
@@ -122,7 +138,7 @@ make
### Binary ### Binary
Make sure to have Go 1.23.1 installed. Make sure to have Go 1.26 installed.
```bash ```bash
make local make local

View File

@@ -3,7 +3,7 @@
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us: If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk. 1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
2. Send a detailed report to [security@fossorial.io](mailto:security@fossorial.io) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include: 2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
- Description and location of the vulnerability. - Description and location of the vulnerability.
- Potential impact of the vulnerability. - Potential impact of the vulnerability.

View File

@@ -1,23 +0,0 @@
{
"privateKey": "kBGTgk7c+zncEEoSnMl+jsLjVh5ZVoL/HwBSQem+d1M=",
"listenPort": 51820,
"ipAddress": "10.0.0.1/24",
"peers": [
{
"publicKey": "5UzzoeveFVSzuqK3nTMS5bA1jIMs1fQffVQzJ8MXUQM=",
"allowedIps": ["10.0.0.0/28"]
},
{
"publicKey": "kYrZpuO2NsrFoBh1GMNgkhd1i9Rgtu1rAjbJ7qsfngU=",
"allowedIps": ["10.0.0.16/28"]
},
{
"publicKey": "1YfPUVr9ZF4zehkbI2BQhCxaRLz+Vtwa4vJwH+mpK0A=",
"allowedIps": ["10.0.0.32/28"]
},
{
"publicKey": "2/U4oyZ+sai336Dal/yExCphL8AxyqvIxMk4qsUy4iI=",
"allowedIps": ["10.0.0.48/28"]
}
]
}

273
docs/observability.md Normal file
View File

@@ -0,0 +1,273 @@
<!-- markdownlint-disable MD036 MD060 -->
# Gerbil Observability Architecture
This document describes the metrics subsystem for Gerbil, explains the design
decisions, and shows how to configure each backend.
---
## Architecture Overview
Gerbil's metrics subsystem uses a **pluggable backend** design:
```text
main.go ─── internal/metrics ─── internal/observability ─── backend
(facade) (interface) Prometheus
OR OTel/OTLP
OR Noop (disabled)
```
Application code (main, relay, proxy) calls only the `metrics.Record*`
functions in `internal/metrics`. That package delegates to whichever backend
was selected at startup via `internal/observability.Backend`.
### Why Prometheus-native and OTel are mutually exclusive
**Exactly one** metrics backend may be active at runtime:
| Mode | What happens |
|------|-------------|
| `prometheus` | Native Prometheus client registers metrics on a dedicated registry and exposes `/metrics`. No OTel SDK is initialised. |
| `otel` | OTel SDK pushes metrics via OTLP/gRPC or OTLP/HTTP to an external collector. No `/metrics` endpoint is exposed. |
| `none` | A safe noop backend is used. All `Record*` calls are discarded. |
Running both simultaneously would mean every metric is recorded twice through
two different code paths, with differing semantics (pull vs. push, different
naming rules, different cardinality handling). The design enforces a single
source of truth.
### Future OTel tracing and logging
The `internal/observability/otel/` package is designed so that tracing and
logging support can be added **beside** the existing metrics code without
touching the Prometheus-native path:
```bash
internal/observability/otel/
backend.go ← metrics
exporter.go ← OTLP exporter creation
resource.go ← OTel resource
trace.go ← future: TracerProvider setup
log.go ← future: LoggerProvider setup
```
---
## Configuration
### Config precedence
1. CLI flags (highest priority)
2. Environment variables
3. Defaults
### Config struct
```go
type MetricsConfig struct {
Enabled bool
Backend string // "prometheus" | "otel" | "none"
Prometheus PrometheusConfig
OTel OTelConfig
ServiceName string
ServiceVersion string
DeploymentEnvironment string
}
type PrometheusConfig struct {
Path string // default: "/metrics"
}
type OTelConfig struct {
Protocol string // "grpc" (default) or "http"
Endpoint string // default: "localhost:4317"
Insecure bool // default: true
ExportInterval time.Duration // default: 60s
Timeout time.Duration // default: 10s
}
```
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `METRICS_ENABLED` | `true` | Enable/disable metrics |
| `METRICS_BACKEND` | `prometheus` | Backend: `prometheus`, `otel`, or `none` |
| `METRICS_PATH` | `/metrics` | HTTP path for Prometheus endpoint |
| `OTEL_METRICS_PROTOCOL` | `grpc` | OTLP transport: `grpc` or `http` |
| `OTEL_METRICS_ENDPOINT` | `localhost:4317` | OTLP collector address |
| `OTEL_METRICS_INSECURE` | `true` | Disable TLS for OTLP |
| `OTEL_METRICS_EXPORT_INTERVAL` | `60s` | Push interval (e.g. `10s`, `1m`) |
| `OTEL_METRICS_TIMEOUT` | `10s` | Timeout for OTLP exporter connection setup |
| `DEPLOYMENT_ENVIRONMENT` | _(unset)_ | OTel deployment.environment attribute |
### CLI flags
```bash
--metrics-enabled bool (default: true)
--metrics-backend string (default: prometheus)
--metrics-path string (default: /metrics)
--otel-metrics-protocol string (default: grpc)
--otel-metrics-endpoint string (default: localhost:4317)
--otel-metrics-insecure bool (default: true)
--otel-metrics-export-interval duration (default: 60s)
--otel-metrics-timeout duration (default: 10s)
```
---
## When to choose each backend
| Criterion | Prometheus | OTel/OTLP |
|-----------|-----------|-----------|
| Existing Prometheus/Grafana stack | ✅ | |
| Pull-based scraping | ✅ | |
| No external collector required | ✅ | |
| Vendor-neutral telemetry | | ✅ |
| Push-based export | | ✅ |
| Grafana Cloud / managed OTLP | | ✅ |
| Future traces + logs via same pipeline | | ✅ |
---
## Enabling Prometheus-native mode
### Environment variables
```bash
METRICS_ENABLED=true
METRICS_BACKEND=prometheus
METRICS_PATH=/metrics
```
### CLI
```bash
./gerbil --metrics-enabled --metrics-backend=prometheus --metrics-path=/metrics \
--config=/etc/gerbil/config.json
```
The metrics config is supplied separately via env/flags; it is not embedded
in the WireGuard config file.
The Prometheus `/metrics` endpoint is registered only when
`--metrics-backend=prometheus`. All gerbil_* metrics plus Go runtime metrics
are available.
---
## Enabling OTel mode
### Environment variables
```bash
export METRICS_ENABLED=true
export METRICS_BACKEND=otel
export OTEL_METRICS_PROTOCOL=grpc
export OTEL_METRICS_ENDPOINT=otel-collector:4317
export OTEL_METRICS_INSECURE=true
export OTEL_METRICS_EXPORT_INTERVAL=10s
export OTEL_METRICS_TIMEOUT=10s
export DEPLOYMENT_ENVIRONMENT=production
```
### CLI
```bash
./gerbil --metrics-enabled \
--metrics-backend=otel \
--otel-metrics-protocol=grpc \
--otel-metrics-endpoint=otel-collector:4317 \
--otel-metrics-insecure \
--otel-metrics-export-interval=10s \
--otel-metrics-timeout=10s \
--config=/etc/gerbil/config.json
```
### HTTP mode (OTLP/HTTP)
```bash
export OTEL_METRICS_PROTOCOL=http
export OTEL_METRICS_ENDPOINT=otel-collector:4318
```
---
## Disabling metrics
```bash
export METRICS_ENABLED=false
# or
./gerbil --metrics-enabled=false
# or
./gerbil --metrics-backend=none
```
When disabled, all `Record*` calls are directed to a safe noop backend that
discards observations without allocating or locking.
---
## Metric catalog
All metrics use the prefix `gerbil_<component>_<name>`.
### WireGuard metrics
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `gerbil_wg_interface_up` | Gauge | `ifname`, `instance` | 1=up, 0=down |
| `gerbil_wg_peers_total` | UpDownCounter | `ifname` | Configured peers |
| `gerbil_wg_peer_connected` | Gauge | `ifname`, `peer` | 1=connected, 0=disconnected |
| `gerbil_wg_bytes_received_total` | Counter | `ifname`, `peer` | Bytes received |
| `gerbil_wg_bytes_transmitted_total` | Counter | `ifname`, `peer` | Bytes transmitted |
| `gerbil_wg_handshakes_total` | Counter | `ifname`, `peer`, `result` | Handshake attempts |
| `gerbil_wg_handshake_latency_seconds` | Histogram | `ifname`, `peer` | Handshake duration |
| `gerbil_wg_peer_rtt_seconds` | Histogram | `ifname`, `peer` | Peer round-trip time |
### Relay metrics
| Metric | Type | Labels |
|--------|------|--------|
| `gerbil_proxy_mapping_active` | UpDownCounter | `ifname` |
| `gerbil_active_sessions` | UpDownCounter | `ifname` |
| `gerbil_udp_packets_total` | Counter | `ifname`, `type`, `direction` |
| `gerbil_hole_punch_events_total` | Counter | `ifname`, `result` |
### SNI proxy metrics
| Metric | Type | Labels |
|--------|------|--------|
| `gerbil_sni_connections_total` | Counter | `result` |
| `gerbil_sni_active_connections` | UpDownCounter | _(none)_ |
| `gerbil_sni_route_cache_hits_total` | Counter | `result` |
| `gerbil_sni_route_api_requests_total` | Counter | `result` |
| `gerbil_proxy_route_lookups_total` | Counter | `result`, `hostname` |
### HTTP metrics
| Metric | Type | Labels |
|--------|------|--------|
| `gerbil_http_requests_total` | Counter | `endpoint`, `method`, `status_code` |
| `gerbil_http_request_duration_seconds` | Histogram | `endpoint`, `method` |
---
## Using Docker Compose
The `docker-compose.metrics.yml` provides a complete observability stack.
**Prometheus mode:**
```bash
METRICS_BACKEND=prometheus docker-compose -f docker compose.metrics.yml up -d
# Scrape at http://localhost:3003/metrics
# Grafana at http://localhost:3000 (admin/admin)
```
**OTel mode:**
```bash
METRICS_BACKEND=otel OTEL_METRICS_ENDPOINT=otel-collector:4317 \
docker compose -f docker-compose.metrics.yml up -d
```

View File

@@ -0,0 +1,47 @@
file_format: '1.0'
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 10s
send_batch_size: 1024
# Add resource attributes
resource:
attributes:
- key: service.environment
value: "development"
action: insert
exporters:
# Prometheus exporter for scraping
prometheus:
endpoint: "0.0.0.0:8889"
namespace: "gerbil"
send_timestamps: true
metric_expiration: 5m
resource_to_telemetry_conversion:
enabled: true
# Prometheus remote write (optional)
prometheusremotewrite:
endpoint: "http://prometheus:9090/api/v1/write"
tls:
insecure: true
# Debug exporter for debugging
debug:
verbosity: normal
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch, resource]
exporters: [prometheus, prometheusremotewrite, debug]

24
examples/prometheus.yml Normal file
View File

@@ -0,0 +1,24 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
cluster: 'gerbil-dev'
scrape_configs:
# Scrape Gerbil's /metrics endpoint directly
- job_name: 'gerbil'
static_configs:
- targets: ['gerbil:3003']
labels:
service: 'gerbil'
environment: 'development'
# Scrape OpenTelemetry Collector metrics
- job_name: 'otel-collector'
static_configs:
- targets: ['otel-collector:8888']
labels:
service: 'otel-collector'
- targets: ['otel-collector:8889']
labels:
service: 'otel-collector-prometheus-exporter'

40
go.mod
View File

@@ -1,23 +1,49 @@
module github.com/fosrl/gerbil module github.com/fosrl/gerbil
go 1.25 go 1.26.0
require ( require (
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/prometheus/client_golang v1.24.1
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
golang.org/x/crypto v0.36.0 go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0
go.opentelemetry.io/otel/metric v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/sdk/metric v1.46.0
golang.org/x/crypto v0.55.0
golang.org/x/sync v0.22.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
) )
require ( require (
github.com/google/go-cmp v0.5.9 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/josharian/native v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect github.com/mdlayher/socket v0.4.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/net v0.38.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
golang.org/x/sync v0.1.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect
golang.org/x/sys v0.31.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.12 // indirect
) )

91
go.sum
View File

@@ -1,7 +1,28 @@
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
@@ -10,23 +31,73 @@ github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo= golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo=
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4= golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

921
internal/metrics/metrics.go Normal file
View File

@@ -0,0 +1,921 @@
// Package metrics provides the application-level metrics facade for Gerbil.
//
// Application code (main, relay, proxy) uses only the Record* functions in this
// package. The actual recording is delegated to the backend selected in
// internal/observability. Neither Prometheus nor OTel packages are imported here.
package metrics
import (
"context"
"fmt"
"net/http"
"sync"
"github.com/fosrl/gerbil/internal/observability"
)
// Config is the metrics configuration type. It is an alias for
// observability.MetricsConfig so callers do not need to import observability.
type Config = observability.MetricsConfig
// PrometheusConfig is re-exported for convenience.
type PrometheusConfig = observability.PrometheusConfig
// OTelConfig is re-exported for convenience.
type OTelConfig = observability.OTelConfig
var (
backend observability.Backend
initMu sync.Mutex
// Interface and peer metrics
wgInterfaceUp observability.Int64Gauge
wgPeersTotal observability.UpDownCounter
wgPeerConnected observability.Int64Gauge
wgHandshakesTotal observability.Counter
wgHandshakeLatency observability.Histogram
wgPeerRTT observability.Histogram
wgBytesReceived observability.Counter
wgBytesTransmitted observability.Counter
allowedIPsCount observability.UpDownCounter
keyRotationTotal observability.Counter
// System and proxy metrics
netlinkEventsTotal observability.Counter
netlinkErrorsTotal observability.Counter
syncDuration observability.Histogram
workqueueDepth observability.UpDownCounter
kernelModuleLoads observability.Counter
firewallRulesApplied observability.Counter
activeSessions observability.UpDownCounter
activeProxyConnections observability.UpDownCounter
proxyRouteLookups observability.Counter
proxyTLSHandshake observability.Histogram
proxyBytesTransmitted observability.Counter
// UDP Relay / Proxy Metrics
udpPacketsTotal observability.Counter
udpPacketSizeBytes observability.Histogram
holePunchEventsTotal observability.Counter
proxyMappingActive observability.UpDownCounter
relayUDPConnectionsActive observability.UpDownCounter
sessionRebuiltTotal observability.Counter
commPatternActive observability.UpDownCounter
proxyCleanupRemovedTotal observability.Counter
proxyConnectionErrorsTotal observability.Counter
proxyInitialMappingsTotal observability.Int64Gauge
proxyMappingUpdatesTotal observability.Counter
proxyIdleCleanupDuration observability.Histogram
// SNI Proxy Metrics
sniConnectionsTotal observability.Counter
sniConnectionDuration observability.Histogram
sniActiveConnections observability.UpDownCounter
sniRouteCacheHitsTotal observability.Counter
sniRouteAPIRequestsTotal observability.Counter
sniRouteAPILatency observability.Histogram
sniLocalOverrideTotal observability.Counter
sniTrustedProxyEventsTotal observability.Counter
sniProxyProtocolParseErrorsTotal observability.Counter
sniDataBytesTotal observability.Counter
sniTunnelTerminationsTotal observability.Counter
// HTTP API & Peer Management Metrics
httpRequestsTotal observability.Counter
httpRequestDuration observability.Histogram
peerOperationsTotal observability.Counter
proxyMappingUpdateRequestsTotal observability.Counter
destinationsUpdateRequestsTotal observability.Counter
// Remote Configuration, Reporting & Housekeeping
remoteConfigFetchesTotal observability.Counter
bandwidthReportsTotal observability.Counter
peerBandwidthBytesTotal observability.Counter
memorySpikeTotal observability.Counter
heapProfilesWrittenTotal observability.Counter
// Operational metrics
configReloadsTotal observability.Counter
restartTotal observability.Counter
authFailuresTotal observability.Counter
aclDeniedTotal observability.Counter
certificateExpiryDays observability.Float64Gauge
)
// DefaultConfig returns a default metrics configuration.
func DefaultConfig() Config {
return observability.DefaultMetricsConfig()
}
// Initialize sets up the metrics system using the selected backend.
// It returns the /metrics HTTP handler (non-nil only for Prometheus backend).
func Initialize(cfg Config) (http.Handler, error) {
initMu.Lock()
defer initMu.Unlock()
if backend != nil {
return backend.HTTPHandler(), nil
}
b, err := observability.New(cfg)
if err != nil {
return nil, err
}
backend = b
if err := createInstruments(); err != nil {
backend = nil
return nil, err
}
return backend.HTTPHandler(), nil
}
// Shutdown gracefully shuts down the metrics backend.
func Shutdown(ctx context.Context) error {
initMu.Lock()
b := backend
backend = nil
initMu.Unlock()
if b != nil {
return b.Shutdown(ctx)
}
return nil
}
func createInstruments() error {
durationBuckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}
sizeBuckets := []float64{512, 1024, 4096, 16384, 65536, 262144, 1048576}
sniDurationBuckets := []float64{0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120}
b := backend
newCounter := func(name, desc string, labelNames ...string) (observability.Counter, error) {
c, err := b.NewCounter(name, desc, labelNames...)
if err != nil {
return nil, fmt.Errorf("create counter %q: %w", name, err)
}
return c, nil
}
newUpDownCounter := func(name, desc string, labelNames ...string) (observability.UpDownCounter, error) {
c, err := b.NewUpDownCounter(name, desc, labelNames...)
if err != nil {
return nil, fmt.Errorf("create updown counter %q: %w", name, err)
}
return c, nil
}
newInt64Gauge := func(name, desc string, labelNames ...string) (observability.Int64Gauge, error) {
g, err := b.NewInt64Gauge(name, desc, labelNames...)
if err != nil {
return nil, fmt.Errorf("create int64 gauge %q: %w", name, err)
}
return g, nil
}
newFloat64Gauge := func(name, desc string, labelNames ...string) (observability.Float64Gauge, error) {
g, err := b.NewFloat64Gauge(name, desc, labelNames...)
if err != nil {
return nil, fmt.Errorf("create float64 gauge %q: %w", name, err)
}
return g, nil
}
newHistogram := func(name, desc string, buckets []float64, labelNames ...string) (observability.Histogram, error) {
h, err := b.NewHistogram(name, desc, buckets, labelNames...)
if err != nil {
return nil, fmt.Errorf("create histogram %q: %w", name, err)
}
return h, nil
}
var err error
wgInterfaceUp, err = newInt64Gauge("gerbil_wg_interface_up",
"Operational state of a WireGuard interface (1=up, 0=down)", "ifname", "instance")
if err != nil {
return err
}
wgPeersTotal, err = newUpDownCounter("gerbil_wg_peers_total",
"Total number of configured peers per interface", "ifname")
if err != nil {
return err
}
wgPeerConnected, err = newInt64Gauge("gerbil_wg_peer_connected",
"Whether a specific peer is connected (1=connected, 0=disconnected)", "ifname", "peer")
if err != nil {
return err
}
allowedIPsCount, err = newUpDownCounter("gerbil_allowed_ips_count",
"Number of allowed IPs configured per peer", "ifname", "peer")
if err != nil {
return err
}
keyRotationTotal, err = newCounter("gerbil_key_rotation_total",
"Key rotation events", "ifname", "reason")
if err != nil {
return err
}
wgHandshakesTotal, err = newCounter("gerbil_wg_handshakes_total",
"Count of handshake attempts with their result status", "ifname", "peer", "result")
if err != nil {
return err
}
wgHandshakeLatency, err = newHistogram("gerbil_wg_handshake_latency_seconds",
"Distribution of handshake latencies in seconds", durationBuckets, "ifname", "peer")
if err != nil {
return err
}
wgPeerRTT, err = newHistogram("gerbil_wg_peer_rtt_seconds",
"Observed round-trip time to a peer in seconds", durationBuckets, "ifname", "peer")
if err != nil {
return err
}
wgBytesReceived, err = newCounter("gerbil_wg_bytes_received_total",
"Number of bytes received from a peer", "ifname", "peer")
if err != nil {
return err
}
wgBytesTransmitted, err = newCounter("gerbil_wg_bytes_transmitted_total",
"Number of bytes transmitted to a peer", "ifname", "peer")
if err != nil {
return err
}
netlinkEventsTotal, err = newCounter("gerbil_netlink_events_total",
"Number of netlink events processed", "event_type")
if err != nil {
return err
}
netlinkErrorsTotal, err = newCounter("gerbil_netlink_errors_total",
"Count of netlink or kernel errors", "component", "error_type")
if err != nil {
return err
}
syncDuration, err = newHistogram("gerbil_sync_duration_seconds",
"Duration of reconciliation/sync loops in seconds", durationBuckets, "component")
if err != nil {
return err
}
workqueueDepth, err = newUpDownCounter("gerbil_workqueue_depth",
"Current length of internal work queues", "queue")
if err != nil {
return err
}
kernelModuleLoads, err = newCounter("gerbil_kernel_module_loads_total",
"Count of kernel module load attempts", "result")
if err != nil {
return err
}
firewallRulesApplied, err = newCounter("gerbil_firewall_rules_applied_total",
"IPTables/NFT rules applied", "result", "chain")
if err != nil {
return err
}
activeSessions, err = newUpDownCounter("gerbil_active_sessions",
"Number of active UDP relay sessions", "ifname")
if err != nil {
return err
}
activeProxyConnections, err = newUpDownCounter("gerbil_active_proxy_connections",
"Active SNI proxy connections")
if err != nil {
return err
}
proxyRouteLookups, err = newCounter("gerbil_proxy_route_lookups_total",
"Number of route lookups", "result")
if err != nil {
return err
}
proxyTLSHandshake, err = newHistogram("gerbil_proxy_tls_handshake_seconds",
"TLS handshake duration for SNI proxy in seconds", durationBuckets)
if err != nil {
return err
}
proxyBytesTransmitted, err = newCounter("gerbil_proxy_bytes_transmitted_total",
"Bytes sent/received by the SNI proxy", "direction")
if err != nil {
return err
}
configReloadsTotal, err = newCounter("gerbil_config_reloads_total",
"Number of configuration reloads", "result")
if err != nil {
return err
}
restartTotal, err = newCounter("gerbil_restart_total",
"Process restart count")
if err != nil {
return err
}
authFailuresTotal, err = newCounter("gerbil_auth_failures_total",
"Count of authentication or peer validation failures", "peer", "reason")
if err != nil {
return err
}
aclDeniedTotal, err = newCounter("gerbil_acl_denied_total",
"Access control denied events", "ifname", "peer", "policy")
if err != nil {
return err
}
certificateExpiryDays, err = newFloat64Gauge("gerbil_certificate_expiry_days",
"Days until certificate expiry", "cert_name", "ifname")
if err != nil {
return err
}
udpPacketsTotal, err = newCounter("gerbil_udp_packets_total",
"Count of UDP packets processed by relay workers", "ifname", "type", "direction")
if err != nil {
return err
}
udpPacketSizeBytes, err = newHistogram("gerbil_udp_packet_size_bytes",
"Size distribution of packets forwarded through relay", sizeBuckets, "ifname", "type")
if err != nil {
return err
}
holePunchEventsTotal, err = newCounter("gerbil_hole_punch_events_total",
"Count of hole punch messages processed", "ifname", "result")
if err != nil {
return err
}
proxyMappingActive, err = newUpDownCounter("gerbil_proxy_mapping_active",
"Number of active proxy mappings", "ifname")
if err != nil {
return err
}
relayUDPConnectionsActive, err = newUpDownCounter("gerbil_relay_udp_connections_active",
"Number of open per-peer outbound UDP sockets held by the relay connection pool", "ifname")
if err != nil {
return err
}
sessionRebuiltTotal, err = newCounter("gerbil_session_rebuilt_total",
"Count of sessions rebuilt from communication patterns", "ifname")
if err != nil {
return err
}
commPatternActive, err = newUpDownCounter("gerbil_comm_pattern_active",
"Number of active communication patterns", "ifname")
if err != nil {
return err
}
proxyCleanupRemovedTotal, err = newCounter("gerbil_proxy_cleanup_removed_total",
"Count of items removed during cleanup routines", "ifname", "component")
if err != nil {
return err
}
proxyConnectionErrorsTotal, err = newCounter("gerbil_proxy_connection_errors_total",
"Count of connection errors in proxy operations", "ifname", "error_type")
if err != nil {
return err
}
proxyInitialMappingsTotal, err = newInt64Gauge("gerbil_proxy_initial_mappings",
"Number of initial proxy mappings loaded", "ifname")
if err != nil {
return err
}
proxyMappingUpdatesTotal, err = newCounter("gerbil_proxy_mapping_updates_total",
"Count of proxy mapping updates", "ifname")
if err != nil {
return err
}
proxyIdleCleanupDuration, err = newHistogram("gerbil_proxy_idle_cleanup_duration_seconds",
"Duration of cleanup cycles", durationBuckets, "ifname", "component")
if err != nil {
return err
}
sniConnectionsTotal, err = newCounter("gerbil_sni_connections_total",
"Count of connections processed by SNI proxy", "result")
if err != nil {
return err
}
sniConnectionDuration, err = newHistogram("gerbil_sni_connection_duration_seconds",
"Lifetime distribution of proxied TLS connections", sniDurationBuckets)
if err != nil {
return err
}
sniActiveConnections, err = newUpDownCounter("gerbil_sni_active_connections",
"Number of active SNI tunnels")
if err != nil {
return err
}
sniRouteCacheHitsTotal, err = newCounter("gerbil_sni_route_cache_hits_total",
"Count of route cache hits and misses", "result")
if err != nil {
return err
}
sniRouteAPIRequestsTotal, err = newCounter("gerbil_sni_route_api_requests_total",
"Count of route API requests", "result")
if err != nil {
return err
}
sniRouteAPILatency, err = newHistogram("gerbil_sni_route_api_latency_seconds",
"Distribution of route API call latencies", durationBuckets)
if err != nil {
return err
}
sniLocalOverrideTotal, err = newCounter("gerbil_sni_local_override_total",
"Count of routes using local overrides", "hit")
if err != nil {
return err
}
sniTrustedProxyEventsTotal, err = newCounter("gerbil_sni_trusted_proxy_events_total",
"Count of PROXY protocol events", "event")
if err != nil {
return err
}
sniProxyProtocolParseErrorsTotal, err = newCounter("gerbil_sni_proxy_protocol_parse_errors_total",
"Count of PROXY protocol parse failures")
if err != nil {
return err
}
sniDataBytesTotal, err = newCounter("gerbil_sni_data_bytes_total",
"Count of bytes proxied through SNI tunnels", "direction")
if err != nil {
return err
}
sniTunnelTerminationsTotal, err = newCounter("gerbil_sni_tunnel_terminations_total",
"Count of tunnel terminations by reason", "reason")
if err != nil {
return err
}
httpRequestsTotal, err = newCounter("gerbil_http_requests_total",
"Count of HTTP requests to management API", "endpoint", "method", "status_code")
if err != nil {
return err
}
httpRequestDuration, err = newHistogram("gerbil_http_request_duration_seconds",
"Distribution of HTTP request handling time", durationBuckets, "endpoint", "method")
if err != nil {
return err
}
peerOperationsTotal, err = newCounter("gerbil_peer_operations_total",
"Count of peer lifecycle operations", "operation", "result")
if err != nil {
return err
}
proxyMappingUpdateRequestsTotal, err = newCounter("gerbil_proxy_mapping_update_requests_total",
"Count of proxy mapping update API calls", "result")
if err != nil {
return err
}
destinationsUpdateRequestsTotal, err = newCounter("gerbil_destinations_update_requests_total",
"Count of destinations update API calls", "result")
if err != nil {
return err
}
remoteConfigFetchesTotal, err = newCounter("gerbil_remote_config_fetches_total",
"Count of remote configuration fetch attempts", "result")
if err != nil {
return err
}
bandwidthReportsTotal, err = newCounter("gerbil_bandwidth_reports_total",
"Count of bandwidth report transmissions", "result")
if err != nil {
return err
}
peerBandwidthBytesTotal, err = newCounter("gerbil_peer_bandwidth_bytes_total",
"Bytes per peer tracked by bandwidth calculation", "peer", "direction")
if err != nil {
return err
}
memorySpikeTotal, err = newCounter("gerbil_memory_spike_total",
"Count of memory spikes detected", "severity")
if err != nil {
return err
}
heapProfilesWrittenTotal, err = newCounter("gerbil_heap_profiles_written_total",
"Count of heap profile files generated")
if err != nil {
return err
}
return nil
}
func RecordInterfaceUp(ifname, instance string, up bool) {
if wgInterfaceUp == nil {
return
}
value := int64(0)
if up {
value = 1
}
wgInterfaceUp.Record(context.Background(), value, observability.Labels{"ifname": ifname, "instance": instance})
}
func RecordPeersTotal(ifname string, delta int64) {
if wgPeersTotal == nil {
return
}
wgPeersTotal.Add(context.Background(), delta, observability.Labels{"ifname": ifname})
}
func RecordPeerConnected(ifname, peer string, connected bool) {
if wgPeerConnected == nil {
return
}
value := int64(0)
if connected {
value = 1
}
wgPeerConnected.Record(context.Background(), value, observability.Labels{"ifname": ifname, "peer": peer})
}
func RecordHandshake(ifname, peer, result string) {
if wgHandshakesTotal == nil {
return
}
wgHandshakesTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname, "peer": peer, "result": result})
}
func RecordHandshakeLatency(ifname, peer string, seconds float64) {
if wgHandshakeLatency == nil {
return
}
wgHandshakeLatency.Record(context.Background(), seconds, observability.Labels{"ifname": ifname, "peer": peer})
}
func RecordPeerRTT(ifname, peer string, seconds float64) {
if wgPeerRTT == nil {
return
}
wgPeerRTT.Record(context.Background(), seconds, observability.Labels{"ifname": ifname, "peer": peer})
}
func RecordBytesReceived(ifname, peer string, bytes int64) {
if wgBytesReceived == nil {
return
}
wgBytesReceived.Add(context.Background(), bytes, observability.Labels{"ifname": ifname, "peer": peer})
}
func RecordBytesTransmitted(ifname, peer string, bytes int64) {
if wgBytesTransmitted == nil {
return
}
wgBytesTransmitted.Add(context.Background(), bytes, observability.Labels{"ifname": ifname, "peer": peer})
}
func RecordAllowedIPsCount(ifname, peer string, delta int64) {
if allowedIPsCount == nil {
return
}
allowedIPsCount.Add(context.Background(), delta, observability.Labels{"ifname": ifname, "peer": peer})
}
func RecordKeyRotation(ifname, reason string) {
if keyRotationTotal == nil {
return
}
keyRotationTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname, "reason": reason})
}
func RecordNetlinkEvent(eventType string) {
if netlinkEventsTotal == nil {
return
}
netlinkEventsTotal.Add(context.Background(), 1, observability.Labels{"event_type": eventType})
}
func RecordNetlinkError(component, errorType string) {
if netlinkErrorsTotal == nil {
return
}
netlinkErrorsTotal.Add(context.Background(), 1, observability.Labels{"component": component, "error_type": errorType})
}
func RecordSyncDuration(component string, seconds float64) {
if syncDuration == nil {
return
}
syncDuration.Record(context.Background(), seconds, observability.Labels{"component": component})
}
func RecordWorkqueueDepth(queue string, delta int64) {
if workqueueDepth == nil {
return
}
workqueueDepth.Add(context.Background(), delta, observability.Labels{"queue": queue})
}
func RecordKernelModuleLoad(result string) {
if kernelModuleLoads == nil {
return
}
kernelModuleLoads.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordFirewallRuleApplied(result, chain string) {
if firewallRulesApplied == nil {
return
}
firewallRulesApplied.Add(context.Background(), 1, observability.Labels{"result": result, "chain": chain})
}
func RecordActiveSession(ifname string, delta int64) {
if activeSessions == nil {
return
}
activeSessions.Add(context.Background(), delta, observability.Labels{"ifname": ifname})
}
func RecordActiveProxyConnection(delta int64) {
if activeProxyConnections == nil {
return
}
activeProxyConnections.Add(context.Background(), delta, nil)
}
func RecordProxyRouteLookup(result string) {
if proxyRouteLookups == nil {
return
}
proxyRouteLookups.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordProxyTLSHandshake(seconds float64) {
if proxyTLSHandshake == nil {
return
}
proxyTLSHandshake.Record(context.Background(), seconds, nil)
}
func RecordProxyBytesTransmitted(direction string, bytes int64) {
if proxyBytesTransmitted == nil {
return
}
proxyBytesTransmitted.Add(context.Background(), bytes, observability.Labels{"direction": direction})
}
func RecordConfigReload(result string) {
if configReloadsTotal == nil {
return
}
configReloadsTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordRestart() {
if restartTotal == nil {
return
}
restartTotal.Add(context.Background(), 1, nil)
}
func RecordAuthFailure(peer, reason string) {
if authFailuresTotal == nil {
return
}
authFailuresTotal.Add(context.Background(), 1, observability.Labels{"peer": peer, "reason": reason})
}
func RecordACLDenied(ifname, peer, policy string) {
if aclDeniedTotal == nil {
return
}
aclDeniedTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname, "peer": peer, "policy": policy})
}
func RecordCertificateExpiry(certName, ifname string, days float64) {
if certificateExpiryDays == nil {
return
}
certificateExpiryDays.Record(context.Background(), days, observability.Labels{"cert_name": certName, "ifname": ifname})
}
func RecordUDPPacket(ifname, packetType, direction string) {
if udpPacketsTotal == nil {
return
}
udpPacketsTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname, "type": packetType, "direction": direction})
}
func RecordUDPPacketSize(ifname, packetType string, bytes float64) {
if udpPacketSizeBytes == nil {
return
}
udpPacketSizeBytes.Record(context.Background(), bytes, observability.Labels{"ifname": ifname, "type": packetType})
}
func RecordHolePunchEvent(ifname, result string) {
if holePunchEventsTotal == nil {
return
}
holePunchEventsTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname, "result": result})
}
func RecordProxyMapping(ifname string, delta int64) {
if proxyMappingActive == nil {
return
}
proxyMappingActive.Add(context.Background(), delta, observability.Labels{"ifname": ifname})
}
func RecordSession(ifname string, delta int64) {
if activeSessions == nil {
return
}
activeSessions.Add(context.Background(), delta, observability.Labels{"ifname": ifname})
}
func RecordUDPConnection(ifname string, delta int64) {
if relayUDPConnectionsActive == nil {
return
}
relayUDPConnectionsActive.Add(context.Background(), delta, observability.Labels{"ifname": ifname})
}
func RecordSessionRebuilt(ifname string) {
if sessionRebuiltTotal == nil {
return
}
sessionRebuiltTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname})
}
func RecordCommPattern(ifname string, delta int64) {
if commPatternActive == nil {
return
}
commPatternActive.Add(context.Background(), delta, observability.Labels{"ifname": ifname})
}
func RecordProxyCleanupRemoved(ifname, component string, count int64) {
if proxyCleanupRemovedTotal == nil {
return
}
proxyCleanupRemovedTotal.Add(context.Background(), count, observability.Labels{"ifname": ifname, "component": component})
}
func RecordProxyConnectionError(ifname, errorType string) {
if proxyConnectionErrorsTotal == nil {
return
}
proxyConnectionErrorsTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname, "error_type": errorType})
}
func RecordProxyInitialMappings(ifname string, count int64) {
if proxyInitialMappingsTotal == nil {
return
}
proxyInitialMappingsTotal.Record(context.Background(), count, observability.Labels{"ifname": ifname})
}
func RecordProxyMappingUpdate(ifname string) {
if proxyMappingUpdatesTotal == nil {
return
}
proxyMappingUpdatesTotal.Add(context.Background(), 1, observability.Labels{"ifname": ifname})
}
func RecordProxyIdleCleanupDuration(ifname, component string, seconds float64) {
if proxyIdleCleanupDuration == nil {
return
}
proxyIdleCleanupDuration.Record(context.Background(), seconds, observability.Labels{"ifname": ifname, "component": component})
}
func RecordSNIConnection(result string) {
if sniConnectionsTotal == nil {
return
}
sniConnectionsTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordSNIConnectionDuration(seconds float64) {
if sniConnectionDuration == nil {
return
}
sniConnectionDuration.Record(context.Background(), seconds, nil)
}
func RecordSNIActiveConnection(delta int64) {
if sniActiveConnections == nil {
return
}
sniActiveConnections.Add(context.Background(), delta, nil)
}
func RecordSNIRouteCacheHit(result string) {
if sniRouteCacheHitsTotal == nil {
return
}
sniRouteCacheHitsTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordSNIRouteAPIRequest(result string) {
if sniRouteAPIRequestsTotal == nil {
return
}
sniRouteAPIRequestsTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordSNIRouteAPILatency(seconds float64) {
if sniRouteAPILatency == nil {
return
}
sniRouteAPILatency.Record(context.Background(), seconds, nil)
}
func RecordSNILocalOverride(hit string) {
if sniLocalOverrideTotal == nil {
return
}
sniLocalOverrideTotal.Add(context.Background(), 1, observability.Labels{"hit": hit})
}
func RecordSNITrustedProxyEvent(event string) {
if sniTrustedProxyEventsTotal == nil {
return
}
sniTrustedProxyEventsTotal.Add(context.Background(), 1, observability.Labels{"event": event})
}
func RecordSNIProxyProtocolParseError() {
if sniProxyProtocolParseErrorsTotal == nil {
return
}
sniProxyProtocolParseErrorsTotal.Add(context.Background(), 1, nil)
}
func RecordSNIDataBytes(direction string, bytes int64) {
if sniDataBytesTotal == nil {
return
}
sniDataBytesTotal.Add(context.Background(), bytes, observability.Labels{"direction": direction})
}
func RecordSNITunnelTermination(reason string) {
if sniTunnelTerminationsTotal == nil {
return
}
sniTunnelTerminationsTotal.Add(context.Background(), 1, observability.Labels{"reason": reason})
}
func RecordHTTPRequest(endpoint, method, statusCode string) {
if httpRequestsTotal == nil {
return
}
httpRequestsTotal.Add(context.Background(), 1, observability.Labels{"endpoint": endpoint, "method": method, "status_code": statusCode})
}
func RecordHTTPRequestDuration(endpoint, method string, seconds float64) {
if httpRequestDuration == nil {
return
}
httpRequestDuration.Record(context.Background(), seconds, observability.Labels{"endpoint": endpoint, "method": method})
}
func RecordPeerOperation(operation, result string) {
if peerOperationsTotal == nil {
return
}
peerOperationsTotal.Add(context.Background(), 1, observability.Labels{"operation": operation, "result": result})
}
func RecordProxyMappingUpdateRequest(result string) {
if proxyMappingUpdateRequestsTotal == nil {
return
}
proxyMappingUpdateRequestsTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordDestinationsUpdateRequest(result string) {
if destinationsUpdateRequestsTotal == nil {
return
}
destinationsUpdateRequestsTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordRemoteConfigFetch(result string) {
if remoteConfigFetchesTotal == nil {
return
}
remoteConfigFetchesTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordBandwidthReport(result string) {
if bandwidthReportsTotal == nil {
return
}
bandwidthReportsTotal.Add(context.Background(), 1, observability.Labels{"result": result})
}
func RecordPeerBandwidthBytes(peer, direction string, bytes int64) {
if peerBandwidthBytesTotal == nil {
return
}
peerBandwidthBytesTotal.Add(context.Background(), bytes, observability.Labels{"peer": peer, "direction": direction})
}
func RecordMemorySpike(severity string) {
if memorySpikeTotal == nil {
return
}
memorySpikeTotal.Add(context.Background(), 1, observability.Labels{"severity": severity})
}
func RecordHeapProfileWritten() {
if heapProfilesWrittenTotal == nil {
return
}
heapProfilesWrittenTotal.Add(context.Background(), 1, nil)
}

View File

@@ -0,0 +1,262 @@
package metrics_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/fosrl/gerbil/internal/metrics"
"github.com/fosrl/gerbil/internal/observability"
)
const exampleHostname = "example.com"
func initPrometheus(t *testing.T) http.Handler {
t.Helper()
cfg := metrics.DefaultConfig()
cfg.Enabled = true
cfg.Backend = "prometheus"
cfg.Prometheus.Path = "/metrics"
h, err := metrics.Initialize(cfg)
if err != nil {
t.Fatalf("Initialize failed: %v", err)
}
t.Cleanup(func() {
metrics.Shutdown(context.Background()) //nolint:errcheck
})
return h
}
func initNoop(t *testing.T) {
t.Helper()
cfg := metrics.DefaultConfig()
cfg.Enabled = false
_, err := metrics.Initialize(cfg)
if err != nil {
t.Fatalf("Initialize noop failed: %v", err)
}
t.Cleanup(func() {
metrics.Shutdown(context.Background()) //nolint:errcheck
})
}
func scrape(t *testing.T, h http.Handler) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/metrics", http.NoBody)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("scrape returned %d", rr.Code)
}
b, _ := io.ReadAll(rr.Body)
return string(b)
}
func assertContains(t *testing.T, body, substr string) {
t.Helper()
if !strings.Contains(body, substr) {
t.Errorf("expected %q in output\nbody:\n%s", substr, body)
}
}
// --- Tests ---
func TestInitializePrometheus(t *testing.T) {
h := initPrometheus(t)
if h == nil {
t.Error("expected non-nil HTTP handler for prometheus backend")
}
}
func TestInitializeNoop(t *testing.T) {
initNoop(t)
// All Record* functions must not panic when noop backend is active.
metrics.RecordRestart()
metrics.RecordHTTPRequest("/test", "GET", "200")
metrics.RecordSNIConnection("accepted")
metrics.RecordPeersTotal("wg0", 1)
}
func TestDefaultConfig(t *testing.T) {
cfg := metrics.DefaultConfig()
if cfg.Backend != "prometheus" {
t.Errorf("expected prometheus default backend, got %q", cfg.Backend)
}
}
func TestShutdownNoInit(t *testing.T) {
// Ensure a known clean global state before testing no-init shutdown behavior.
_ = metrics.Shutdown(context.Background())
// Shutdown without Initialize should not panic or error.
if err := metrics.Shutdown(context.Background()); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestRecordHTTPRequest(t *testing.T) {
h := initPrometheus(t)
metrics.RecordHTTPRequest("/peers", "POST", "201")
body := scrape(t, h)
assertContains(t, body, "gerbil_http_requests_total")
}
func TestRecordHTTPRequestDuration(t *testing.T) {
h := initPrometheus(t)
metrics.RecordHTTPRequestDuration("/peers", "POST", 0.05)
body := scrape(t, h)
assertContains(t, body, "gerbil_http_request_duration_seconds")
}
func TestRecordInterfaceUp(t *testing.T) {
h := initPrometheus(t)
metrics.RecordInterfaceUp("wg0", "host1", true)
metrics.RecordInterfaceUp("wg0", "host1", false)
body := scrape(t, h)
assertContains(t, body, "gerbil_wg_interface_up")
}
func TestRecordPeersTotal(t *testing.T) {
h := initPrometheus(t)
metrics.RecordPeersTotal("wg0", 3)
body := scrape(t, h)
assertContains(t, body, "gerbil_wg_peers_total")
}
func TestRecordBytesReceivedTransmitted(t *testing.T) {
h := initPrometheus(t)
metrics.RecordBytesReceived("wg0", "peer1", 1024)
metrics.RecordBytesTransmitted("wg0", "peer1", 512)
body := scrape(t, h)
assertContains(t, body, "gerbil_wg_bytes_received_total")
assertContains(t, body, "gerbil_wg_bytes_transmitted_total")
}
func TestRecordSNI(t *testing.T) {
h := initPrometheus(t)
metrics.RecordSNIConnection("accepted")
metrics.RecordSNIActiveConnection(1)
metrics.RecordSNIConnectionDuration(1.5)
metrics.RecordSNIRouteCacheHit("hit")
metrics.RecordSNIRouteAPIRequest("success")
metrics.RecordSNIRouteAPILatency(0.01)
metrics.RecordSNILocalOverride("yes")
metrics.RecordSNITrustedProxyEvent("proxy_protocol_parsed")
metrics.RecordSNIProxyProtocolParseError()
metrics.RecordSNIDataBytes("client_to_target", 2048)
metrics.RecordSNITunnelTermination("eof")
body := scrape(t, h)
assertContains(t, body, "gerbil_sni_connections_total")
assertContains(t, body, "gerbil_sni_active_connections")
}
func TestRecordRelay(t *testing.T) {
h := initPrometheus(t)
metrics.RecordUDPPacket("relay", "data", "in")
metrics.RecordUDPPacketSize("relay", "data", 256)
metrics.RecordHolePunchEvent("relay", "success")
metrics.RecordProxyMapping("relay", 1)
metrics.RecordSession("relay", 1)
metrics.RecordSessionRebuilt("relay")
metrics.RecordCommPattern("relay", 1)
metrics.RecordProxyCleanupRemoved("relay", "session", 2)
metrics.RecordProxyConnectionError("relay", "dial_udp")
metrics.RecordProxyInitialMappings("relay", 5)
metrics.RecordProxyMappingUpdate("relay")
metrics.RecordProxyIdleCleanupDuration("relay", "conn", 0.1)
body := scrape(t, h)
assertContains(t, body, "gerbil_udp_packets_total")
assertContains(t, body, "gerbil_proxy_mapping_active")
assertContains(t, body, "gerbil_active_sessions")
}
func TestRecordWireGuard(t *testing.T) {
h := initPrometheus(t)
metrics.RecordHandshake("wg0", "peer1", "success")
metrics.RecordHandshakeLatency("wg0", "peer1", 0.02)
metrics.RecordPeerRTT("wg0", "peer1", 0.005)
metrics.RecordPeerConnected("wg0", "peer1", true)
metrics.RecordAllowedIPsCount("wg0", "peer1", 2)
metrics.RecordKeyRotation("wg0", "scheduled")
body := scrape(t, h)
assertContains(t, body, "gerbil_wg_handshakes_total")
assertContains(t, body, "gerbil_wg_peer_connected")
}
func TestRecordHousekeeping(t *testing.T) {
h := initPrometheus(t)
metrics.RecordRemoteConfigFetch("success")
metrics.RecordBandwidthReport("success")
metrics.RecordPeerBandwidthBytes("peer1", "rx", 512)
metrics.RecordMemorySpike("warning")
metrics.RecordHeapProfileWritten()
body := scrape(t, h)
assertContains(t, body, "gerbil_remote_config_fetches_total")
assertContains(t, body, "gerbil_memory_spike_total")
}
func TestRecordOperational(t *testing.T) {
h := initPrometheus(t)
metrics.RecordConfigReload("success")
metrics.RecordRestart()
metrics.RecordAuthFailure("peer1", "bad_key")
metrics.RecordACLDenied("wg0", "peer1", "default-deny")
metrics.RecordCertificateExpiry(exampleHostname, "wg0", 90.0)
body := scrape(t, h)
assertContains(t, body, "gerbil_config_reloads_total")
assertContains(t, body, "gerbil_restart_total")
}
func TestRecordNetlink(t *testing.T) {
h := initPrometheus(t)
metrics.RecordNetlinkEvent("link_up")
metrics.RecordNetlinkError("wg", "timeout")
metrics.RecordSyncDuration("config", 0.1)
metrics.RecordWorkqueueDepth("main", 3)
metrics.RecordKernelModuleLoad("success")
metrics.RecordFirewallRuleApplied("success", "INPUT")
metrics.RecordActiveSession("wg0", 1)
metrics.RecordActiveProxyConnection(1)
metrics.RecordProxyRouteLookup("hit")
metrics.RecordProxyTLSHandshake(0.05)
metrics.RecordProxyBytesTransmitted("tx", 1024)
body := scrape(t, h)
assertContains(t, body, "gerbil_netlink_events_total")
assertContains(t, body, "gerbil_active_sessions")
}
func TestRecordPeerOperation(t *testing.T) {
h := initPrometheus(t)
metrics.RecordPeerOperation("add", "success")
metrics.RecordProxyMappingUpdateRequest("success")
metrics.RecordDestinationsUpdateRequest("success")
body := scrape(t, h)
assertContains(t, body, "gerbil_peer_operations_total")
}
func TestInitializeInvalidBackend(t *testing.T) {
cfg := observability.MetricsConfig{Enabled: true, Backend: "invalid"}
_, err := metrics.Initialize(cfg)
if err == nil {
t.Error("expected error for invalid backend")
}
}
func TestInitializeBackendNone(t *testing.T) {
cfg := metrics.DefaultConfig()
cfg.Backend = "none"
h, err := metrics.Initialize(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h != nil {
t.Error("none backend should return nil handler")
}
// All Record* calls should be noop
metrics.RecordRestart()
metrics.Shutdown(context.Background()) //nolint:errcheck
}

View File

@@ -0,0 +1,129 @@
// Package observability provides a backend-neutral metrics abstraction for Gerbil.
//
// Exactly one metrics backend may be enabled at runtime:
// - "prometheus" native Prometheus client; exposes /metrics (no OTel SDK required)
// - "otel" OpenTelemetry metrics pushed via OTLP (gRPC or HTTP)
// - "none" metrics disabled; a safe noop implementation is used
//
// Future OTel tracing and logging can be added to this package alongside the
// existing otel sub-package without touching the Prometheus-native path.
package observability
import (
"fmt"
"time"
)
// MetricsConfig is the top-level metrics configuration.
type MetricsConfig struct {
// Enabled controls whether any metrics backend is started.
// When false the noop backend is used regardless of Backend.
Enabled bool
// Backend selects the active backend: "prometheus", "otel", or "none".
Backend string
// Prometheus holds settings used only by the Prometheus-native backend.
Prometheus PrometheusConfig
// OTel holds settings used only by the OTel backend.
OTel OTelConfig
// ServiceName is propagated to OTel resource attributes.
ServiceName string
// ServiceVersion is propagated to OTel resource attributes.
ServiceVersion string
// DeploymentEnvironment is an optional OTel resource attribute.
DeploymentEnvironment string
}
// PrometheusConfig holds Prometheus-native backend settings.
type PrometheusConfig struct {
// Path is the HTTP path to expose the /metrics endpoint.
// Defaults to "/metrics".
Path string
}
// OTelConfig holds OpenTelemetry backend settings.
type OTelConfig struct {
// Protocol is the OTLP transport: "grpc" (default) or "http".
Protocol string
// Endpoint is the OTLP collector address (e.g. "localhost:4317").
Endpoint string
// Insecure disables TLS for the OTLP connection.
Insecure bool
// ExportInterval is how often metrics are pushed to the collector.
// Defaults to 60 s.
ExportInterval time.Duration
// Timeout bounds OTLP exporter construction calls.
// Defaults to 10 s.
Timeout time.Duration
}
// DefaultMetricsConfig returns a MetricsConfig with sensible defaults.
func DefaultMetricsConfig() MetricsConfig {
return MetricsConfig{
Enabled: true,
Backend: "prometheus",
Prometheus: PrometheusConfig{
Path: "/metrics",
},
OTel: OTelConfig{
Protocol: "grpc",
Endpoint: "localhost:4317",
Insecure: true,
ExportInterval: 60 * time.Second,
Timeout: 10 * time.Second,
},
ServiceName: "gerbil",
ServiceVersion: "1.0.0",
}
}
// Validate checks the configuration for logical errors.
func (c *MetricsConfig) Validate() error {
if !c.Enabled {
return nil
}
switch c.Backend {
case "prometheus", "none":
// valid
case "":
return fmt.Errorf("metrics: enabled requires a non-empty backend")
case "otel":
if c.OTel.Endpoint == "" {
return fmt.Errorf("metrics: backend=otel requires a non-empty OTel endpoint")
}
if c.OTel.Protocol != "grpc" && c.OTel.Protocol != "http" {
return fmt.Errorf("metrics: otel protocol must be \"grpc\" or \"http\", got %q", c.OTel.Protocol)
}
if c.OTel.ExportInterval <= 0 {
return fmt.Errorf("metrics: otel export interval must be positive")
}
if c.OTel.Timeout <= 0 {
return fmt.Errorf("metrics: otel timeout must be positive")
}
default:
return fmt.Errorf("metrics: unknown backend %q (must be \"prometheus\", \"otel\", or \"none\")", c.Backend)
}
return nil
}
// effectiveBackend resolves the backend string, treating "" and "none" as noop.
func (c *MetricsConfig) effectiveBackend() string {
if !c.Enabled {
return "none"
}
if c.Backend == "" {
return "none"
}
return c.Backend
}

View File

@@ -0,0 +1,153 @@
package observability
import (
"context"
"fmt"
"net/http"
obsotel "github.com/fosrl/gerbil/internal/observability/otel"
obsprom "github.com/fosrl/gerbil/internal/observability/prometheus"
)
// Labels is a set of key-value pairs attached to a metric observation.
// Use only stable, bounded-cardinality label values.
type Labels = map[string]string
// Counter is a monotonically increasing instrument.
type Counter interface {
Add(ctx context.Context, value int64, labels Labels)
}
// UpDownCounter is a bidirectional integer instrument (can go up or down).
type UpDownCounter interface {
Add(ctx context.Context, value int64, labels Labels)
}
// Int64Gauge records a snapshot integer value.
type Int64Gauge interface {
Record(ctx context.Context, value int64, labels Labels)
}
// Float64Gauge records a snapshot float value.
type Float64Gauge interface {
Record(ctx context.Context, value float64, labels Labels)
}
// Histogram records a distribution of values.
type Histogram interface {
Record(ctx context.Context, value float64, labels Labels)
}
// Backend is the single interface that each metrics implementation must satisfy.
// Application code must not import backend-specific packages (prometheus, otel).
type Backend interface {
// NewCounter creates a counter metric.
// labelNames declares the set of label keys that will be passed at observation time.
NewCounter(name, desc string, labelNames ...string) (Counter, error)
// NewUpDownCounter creates an up-down counter metric.
NewUpDownCounter(name, desc string, labelNames ...string) (UpDownCounter, error)
// NewInt64Gauge creates an integer gauge metric.
NewInt64Gauge(name, desc string, labelNames ...string) (Int64Gauge, error)
// NewFloat64Gauge creates a float gauge metric.
NewFloat64Gauge(name, desc string, labelNames ...string) (Float64Gauge, error)
// NewHistogram creates a histogram metric.
// buckets are the explicit upper-bound bucket boundaries.
NewHistogram(name, desc string, buckets []float64, labelNames ...string) (Histogram, error)
// HTTPHandler returns the /metrics HTTP handler.
// Implementations that do not expose an HTTP endpoint return nil.
HTTPHandler() http.Handler
// Shutdown performs a graceful flush / shutdown of the backend.
Shutdown(ctx context.Context) error
}
// New creates the backend selected by cfg and returns it.
// Exactly one backend is created; the selection is mutually exclusive.
func New(cfg MetricsConfig) (Backend, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
switch cfg.effectiveBackend() {
case "prometheus":
b, err := obsprom.New(obsprom.Config{
Path: cfg.Prometheus.Path,
})
if err != nil {
return nil, err
}
return &promAdapter{b: b}, nil
case "otel":
b, err := obsotel.New(obsotel.Config{
Protocol: cfg.OTel.Protocol,
Endpoint: cfg.OTel.Endpoint,
Insecure: cfg.OTel.Insecure,
ExportInterval: cfg.OTel.ExportInterval,
Timeout: cfg.OTel.Timeout,
ServiceName: cfg.ServiceName,
ServiceVersion: cfg.ServiceVersion,
DeploymentEnvironment: cfg.DeploymentEnvironment,
})
if err != nil {
return nil, err
}
return &otelAdapter{b: b}, nil
case "none":
return &NoopBackend{}, nil
default:
return nil, fmt.Errorf("observability: unknown backend %q", cfg.effectiveBackend())
}
}
// promAdapter wraps obsprom.Backend to implement the observability.Backend interface.
// The concrete instrument types from the prometheus sub-package satisfy the instrument
// interfaces via Go's structural (duck) typing without importing this package.
type promAdapter struct {
b *obsprom.Backend
}
func (a *promAdapter) NewCounter(name, desc string, labelNames ...string) (Counter, error) {
return a.b.NewCounter(name, desc, labelNames...)
}
func (a *promAdapter) NewUpDownCounter(name, desc string, labelNames ...string) (UpDownCounter, error) {
return a.b.NewUpDownCounter(name, desc, labelNames...)
}
func (a *promAdapter) NewInt64Gauge(name, desc string, labelNames ...string) (Int64Gauge, error) {
return a.b.NewInt64Gauge(name, desc, labelNames...)
}
func (a *promAdapter) NewFloat64Gauge(name, desc string, labelNames ...string) (Float64Gauge, error) {
return a.b.NewFloat64Gauge(name, desc, labelNames...)
}
func (a *promAdapter) NewHistogram(name, desc string, buckets []float64, labelNames ...string) (Histogram, error) {
return a.b.NewHistogram(name, desc, buckets, labelNames...)
}
func (a *promAdapter) HTTPHandler() http.Handler { return a.b.HTTPHandler() }
func (a *promAdapter) Shutdown(ctx context.Context) error { return a.b.Shutdown(ctx) }
// otelAdapter wraps obsotel.Backend to implement the observability.Backend interface.
type otelAdapter struct {
b *obsotel.Backend
}
func (a *otelAdapter) NewCounter(name, desc string, labelNames ...string) (Counter, error) {
return a.b.NewCounter(name, desc, labelNames...)
}
func (a *otelAdapter) NewUpDownCounter(name, desc string, labelNames ...string) (UpDownCounter, error) {
return a.b.NewUpDownCounter(name, desc, labelNames...)
}
func (a *otelAdapter) NewInt64Gauge(name, desc string, labelNames ...string) (Int64Gauge, error) {
return a.b.NewInt64Gauge(name, desc, labelNames...)
}
func (a *otelAdapter) NewFloat64Gauge(name, desc string, labelNames ...string) (Float64Gauge, error) {
return a.b.NewFloat64Gauge(name, desc, labelNames...)
}
func (a *otelAdapter) NewHistogram(name, desc string, buckets []float64, labelNames ...string) (Histogram, error) {
return a.b.NewHistogram(name, desc, buckets, labelNames...)
}
func (a *otelAdapter) HTTPHandler() http.Handler { return a.b.HTTPHandler() }
func (a *otelAdapter) Shutdown(ctx context.Context) error { return a.b.Shutdown(ctx) }

View File

@@ -0,0 +1,263 @@
package observability_test
import (
"context"
"net"
"os"
"testing"
"time"
"github.com/fosrl/gerbil/internal/observability"
)
const (
defaultMetricsPath = "/metrics"
otelGRPCEndpoint = "localhost:4317"
errUnexpectedFmt = "unexpected error: %v"
)
func TestDefaultMetricsConfig(t *testing.T) {
cfg := observability.DefaultMetricsConfig()
if !cfg.Enabled {
t.Error("default config should have Enabled=true")
}
if cfg.Backend != "prometheus" {
t.Errorf("default backend should be prometheus, got %q", cfg.Backend)
}
if cfg.Prometheus.Path != defaultMetricsPath {
t.Errorf("default prometheus path should be %s, got %q", defaultMetricsPath, cfg.Prometheus.Path)
}
if cfg.OTel.Protocol != "grpc" {
t.Errorf("default otel protocol should be grpc, got %q", cfg.OTel.Protocol)
}
if cfg.OTel.ExportInterval != 60*time.Second {
t.Errorf("default otel export interval should be 60s, got %v", cfg.OTel.ExportInterval)
}
}
func TestValidateValidConfigs(t *testing.T) {
tests := []struct {
name string
cfg observability.MetricsConfig
}{
{name: "disabled", cfg: observability.MetricsConfig{Enabled: false}},
{name: "backend none", cfg: observability.MetricsConfig{Enabled: true, Backend: "none"}},
{name: "prometheus", cfg: observability.MetricsConfig{Enabled: true, Backend: "prometheus"}},
{
name: "otel grpc",
cfg: observability.MetricsConfig{
Enabled: true, Backend: "otel",
OTel: observability.OTelConfig{Protocol: "grpc", Endpoint: otelGRPCEndpoint, ExportInterval: 10 * time.Second, Timeout: 2 * time.Second},
},
},
{
name: "otel http",
cfg: observability.MetricsConfig{
Enabled: true, Backend: "otel",
OTel: observability.OTelConfig{Protocol: "http", Endpoint: "localhost:4318", ExportInterval: 30 * time.Second, Timeout: 2 * time.Second},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.cfg.Validate(); err != nil {
t.Errorf("unexpected validation error: %v", err)
}
})
}
}
func TestValidateInvalidConfigs(t *testing.T) {
tests := []struct {
name string
cfg observability.MetricsConfig
}{
{name: "unknown backend", cfg: observability.MetricsConfig{Enabled: true, Backend: "datadog"}},
{
name: "backend empty while enabled",
cfg: observability.MetricsConfig{Enabled: true, Backend: ""},
},
{
name: "otel missing endpoint",
cfg: observability.MetricsConfig{
Enabled: true, Backend: "otel",
OTel: observability.OTelConfig{Protocol: "grpc", Endpoint: "", ExportInterval: 10 * time.Second, Timeout: 2 * time.Second},
},
},
{
name: "otel invalid protocol",
cfg: observability.MetricsConfig{
Enabled: true, Backend: "otel",
OTel: observability.OTelConfig{Protocol: "tcp", Endpoint: otelGRPCEndpoint, ExportInterval: 10 * time.Second, Timeout: 2 * time.Second},
},
},
{
name: "otel zero interval",
cfg: observability.MetricsConfig{
Enabled: true, Backend: "otel",
OTel: observability.OTelConfig{Protocol: "grpc", Endpoint: otelGRPCEndpoint, ExportInterval: 0, Timeout: 2 * time.Second},
},
},
{
name: "otel zero timeout",
cfg: observability.MetricsConfig{
Enabled: true, Backend: "otel",
OTel: observability.OTelConfig{Protocol: "grpc", Endpoint: otelGRPCEndpoint, ExportInterval: 10 * time.Second, Timeout: 0},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.cfg.Validate(); err == nil {
t.Error("expected validation error but got nil")
}
})
}
}
func TestNewNoopBackend(t *testing.T) {
b, err := observability.New(observability.MetricsConfig{Enabled: false})
if err != nil {
t.Fatalf(errUnexpectedFmt, err)
}
if b.HTTPHandler() != nil {
t.Error("noop backend HTTPHandler should return nil")
}
}
func TestNewNoneBackend(t *testing.T) {
b, err := observability.New(observability.MetricsConfig{Enabled: true, Backend: "none"})
if err != nil {
t.Fatalf(errUnexpectedFmt, err)
}
if b.HTTPHandler() != nil {
t.Error("none backend HTTPHandler should return nil")
}
}
func TestNewPrometheusBackend(t *testing.T) {
cfg := observability.MetricsConfig{
Enabled: true, Backend: "prometheus",
Prometheus: observability.PrometheusConfig{Path: defaultMetricsPath},
}
b, err := observability.New(cfg)
if err != nil {
t.Fatalf(errUnexpectedFmt, err)
}
if b.HTTPHandler() == nil {
t.Error("prometheus backend HTTPHandler should not be nil")
}
if err := b.Shutdown(context.Background()); err != nil {
t.Errorf("prometheus shutdown error: %v", err)
}
}
func TestNewInvalidBackend(t *testing.T) {
_, err := observability.New(observability.MetricsConfig{Enabled: true, Backend: "invalid"})
if err == nil {
t.Error("expected error for invalid backend")
}
}
func TestPrometheusAdapterAllInstruments(t *testing.T) {
b, err := observability.New(observability.MetricsConfig{
Enabled: true, Backend: "prometheus",
Prometheus: observability.PrometheusConfig{Path: defaultMetricsPath},
})
if err != nil {
t.Fatalf("failed to create backend: %v", err)
}
ctx := context.Background()
labels := observability.Labels{"k": "v"}
c, err := b.NewCounter("prom_adapter_counter_total", "desc", "k")
if err != nil {
t.Fatalf("NewCounter error: %v", err)
}
u, err := b.NewUpDownCounter("prom_adapter_updown", "desc", "k")
if err != nil {
t.Fatalf("NewUpDownCounter error: %v", err)
}
ig, err := b.NewInt64Gauge("prom_adapter_int_gauge", "desc", "k")
if err != nil {
t.Fatalf("NewInt64Gauge error: %v", err)
}
fg, err := b.NewFloat64Gauge("prom_adapter_float_gauge", "desc", "k")
if err != nil {
t.Fatalf("NewFloat64Gauge error: %v", err)
}
h, err := b.NewHistogram("prom_adapter_histogram", "desc", []float64{0.1, 1.0}, "k")
if err != nil {
t.Fatalf("NewHistogram error: %v", err)
}
c.Add(ctx, 1, labels)
u.Add(ctx, 2, labels)
ig.Record(ctx, 99, labels)
fg.Record(ctx, 1.23, labels)
h.Record(ctx, 0.5, labels)
if b.HTTPHandler() == nil {
t.Error("prometheus adapter HTTPHandler should not be nil")
}
if err := b.Shutdown(ctx); err != nil {
t.Errorf("Shutdown error: %v", err)
}
}
func TestOtelAdapterAllInstruments(t *testing.T) {
if os.Getenv("SKIP_OTEL_INTEGRATION") != "" {
t.Skip("skipping OTel integration test because SKIP_OTEL_INTEGRATION is set")
}
dialTimeout := 300 * time.Millisecond
conn, err := net.DialTimeout("tcp", otelGRPCEndpoint, dialTimeout)
if err != nil {
t.Skipf("skipping OTel integration test; collector %s not reachable: %v", otelGRPCEndpoint, err)
}
_ = conn.Close()
b, err := observability.New(observability.MetricsConfig{
Enabled: true, Backend: "otel",
OTel: observability.OTelConfig{Protocol: "grpc", Endpoint: otelGRPCEndpoint, Insecure: true, ExportInterval: 100 * time.Millisecond, Timeout: 2 * time.Second},
})
if err != nil {
t.Fatalf("failed to create otel backend: %v", err)
}
ctx := context.Background()
labels := observability.Labels{"k": "v"}
c, err := b.NewCounter("otel_adapter_counter_total", "desc", "k")
if err != nil {
t.Fatalf("NewCounter error: %v", err)
}
u, err := b.NewUpDownCounter("otel_adapter_updown", "desc", "k")
if err != nil {
t.Fatalf("NewUpDownCounter error: %v", err)
}
ig, err := b.NewInt64Gauge("otel_adapter_int_gauge", "desc", "k")
if err != nil {
t.Fatalf("NewInt64Gauge error: %v", err)
}
fg, err := b.NewFloat64Gauge("otel_adapter_float_gauge", "desc", "k")
if err != nil {
t.Fatalf("NewFloat64Gauge error: %v", err)
}
h, err := b.NewHistogram("otel_adapter_histogram", "desc", []float64{0.1, 1.0}, "k")
if err != nil {
t.Fatalf("NewHistogram error: %v", err)
}
c.Add(ctx, 1, labels)
u.Add(ctx, 2, labels)
ig.Record(ctx, 99, labels)
fg.Record(ctx, 1.23, labels)
h.Record(ctx, 0.5, labels)
if b.HTTPHandler() != nil {
t.Error("OTel adapter HTTPHandler should be nil")
}
shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
b.Shutdown(shutdownCtx) //nolint:errcheck
}

View File

@@ -0,0 +1,64 @@
package observability
import (
"context"
"net/http"
)
// NoopBackend is a Backend that discards all observations.
// It is used when metrics are disabled (Enabled=false or Backend="none").
// All methods are safe to call concurrently.
type NoopBackend struct{}
// Compile-time interface check.
var _ Backend = (*NoopBackend)(nil)
func (n *NoopBackend) NewCounter(_ string, _ string, _ ...string) (Counter, error) {
return noopCounter{}, nil
}
func (n *NoopBackend) NewUpDownCounter(_ string, _ string, _ ...string) (UpDownCounter, error) {
return noopUpDownCounter{}, nil
}
func (n *NoopBackend) NewInt64Gauge(_ string, _ string, _ ...string) (Int64Gauge, error) {
return noopInt64Gauge{}, nil
}
func (n *NoopBackend) NewFloat64Gauge(_ string, _ string, _ ...string) (Float64Gauge, error) {
return noopFloat64Gauge{}, nil
}
func (n *NoopBackend) NewHistogram(_ string, _ string, _ []float64, _ ...string) (Histogram, error) {
return noopHistogram{}, nil
}
func (n *NoopBackend) HTTPHandler() http.Handler {
return nil
}
func (n *NoopBackend) Shutdown(_ context.Context) error {
return nil
}
// --- noop instrument types ---
type noopCounter struct{}
func (noopCounter) Add(_ context.Context, _ int64, _ Labels) { /* intentionally no-op */ }
type noopUpDownCounter struct{}
func (noopUpDownCounter) Add(_ context.Context, _ int64, _ Labels) { /* intentionally no-op */ }
type noopInt64Gauge struct{}
func (noopInt64Gauge) Record(_ context.Context, _ int64, _ Labels) { /* intentionally no-op */ }
type noopFloat64Gauge struct{}
func (noopFloat64Gauge) Record(_ context.Context, _ float64, _ Labels) { /* intentionally no-op */ }
type noopHistogram struct{}
func (noopHistogram) Record(_ context.Context, _ float64, _ Labels) { /* intentionally no-op */ }

View File

@@ -0,0 +1,102 @@
package observability_test
import (
"context"
"testing"
"github.com/fosrl/gerbil/internal/observability"
)
func TestNoopBackendAllInstruments(t *testing.T) {
n := &observability.NoopBackend{}
ctx := context.Background()
labels := observability.Labels{"k": "v"}
t.Run("Counter", func(t *testing.T) {
c, _ := n.NewCounter("test_counter", "desc")
c.Add(ctx, 1, labels)
c.Add(ctx, 0, nil)
})
t.Run("UpDownCounter", func(t *testing.T) {
u, _ := n.NewUpDownCounter("test_updown", "desc")
u.Add(ctx, 1, labels)
u.Add(ctx, -1, nil)
})
t.Run("Int64Gauge", func(t *testing.T) {
g, _ := n.NewInt64Gauge("test_int64gauge", "desc")
g.Record(ctx, 42, labels)
g.Record(ctx, 0, nil)
})
t.Run("Float64Gauge", func(t *testing.T) {
g, _ := n.NewFloat64Gauge("test_float64gauge", "desc")
g.Record(ctx, 3.14, labels)
g.Record(ctx, 0, nil)
})
t.Run("Histogram", func(t *testing.T) {
h, _ := n.NewHistogram("test_histogram", "desc", []float64{1, 5, 10})
h.Record(ctx, 2.5, labels)
h.Record(ctx, 0, nil)
})
t.Run("HTTPHandler", func(t *testing.T) {
if n.HTTPHandler() != nil {
t.Error("noop HTTPHandler should be nil")
}
})
t.Run("Shutdown", func(t *testing.T) {
if err := n.Shutdown(ctx); err != nil {
t.Errorf("noop Shutdown should not error: %v", err)
}
})
}
func TestNoopBackendLabelNames(t *testing.T) {
// Verify that label names passed at creation time are accepted without panic.
n := &observability.NoopBackend{}
assertNoPanic := func(t *testing.T, constructor string, fn func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Fatalf("%s panicked: %v", constructor, r)
}
}()
fn()
}
t.Run("NewCounter", func(t *testing.T) {
assertNoPanic(t, "NewCounter", func() {
_, _ = n.NewCounter("c", "d", "label1", "label2")
})
})
t.Run("NewUpDownCounter", func(t *testing.T) {
assertNoPanic(t, "NewUpDownCounter", func() {
_, _ = n.NewUpDownCounter("u", "d", "l1")
})
})
t.Run("NewInt64Gauge", func(t *testing.T) {
assertNoPanic(t, "NewInt64Gauge", func() {
_, _ = n.NewInt64Gauge("g1", "d", "l1", "l2", "l3")
})
})
t.Run("NewFloat64Gauge", func(t *testing.T) {
assertNoPanic(t, "NewFloat64Gauge", func() {
_, _ = n.NewFloat64Gauge("g2", "d")
})
})
t.Run("NewHistogram", func(t *testing.T) {
assertNoPanic(t, "NewHistogram", func() {
_, _ = n.NewHistogram("h", "d", []float64{0.1, 1.0}, "l1")
})
})
}

View File

@@ -0,0 +1,309 @@
// Package otel implements the OpenTelemetry metrics backend for Gerbil.
//
// Metrics are exported via OTLP (gRPC or HTTP) to an external collector.
// No Prometheus /metrics endpoint is exposed in this mode.
// Future OTel tracing and logging can be added alongside this package
// without touching the Prometheus-native path.
package otel
import (
"context"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)
var metricLabelNameRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// Config holds OTel backend configuration.
type Config struct {
// Protocol is "grpc" (default) or "http".
Protocol string
// Endpoint is the OTLP collector address.
Endpoint string
// Insecure disables TLS.
Insecure bool
// ExportInterval is the period between pushes to the collector.
ExportInterval time.Duration
// Timeout bounds exporter construction calls.
Timeout time.Duration
ServiceName string
ServiceVersion string
DeploymentEnvironment string
}
// Backend is the OTel metrics backend.
type Backend struct {
cfg Config
provider *sdkmetric.MeterProvider
meter metric.Meter
}
// New creates and initialises an OTel backend.
//
// cfg.Protocol must be "grpc" (default) or "http".
// cfg.Endpoint is the OTLP collector address (e.g. "localhost:4317").
// cfg.ExportInterval sets the push period (defaults to 60 s if ≤ 0).
// cfg.Insecure disables TLS on the OTLP connection.
//
// Connection to the collector is established lazily; New only validates cfg
// and creates the SDK components. It returns an error only if the OTel resource
// or exporter cannot be constructed.
func New(cfg Config) (*Backend, error) {
if cfg.Protocol == "" {
cfg.Protocol = "grpc"
}
if strings.TrimSpace(cfg.Endpoint) == "" {
return nil, fmt.Errorf("otel backend: empty cfg.Endpoint")
}
if cfg.ExportInterval <= 0 {
cfg.ExportInterval = 60 * time.Second
}
if cfg.Timeout <= 0 {
cfg.Timeout = 10 * time.Second
}
if cfg.ServiceName == "" {
cfg.ServiceName = "gerbil"
}
res, err := newResource(cfg.ServiceName, cfg.ServiceVersion, cfg.DeploymentEnvironment)
if err != nil {
return nil, fmt.Errorf("otel backend: build resource: %w", err)
}
exp, err := newExporter(context.Background(), cfg)
if err != nil {
return nil, fmt.Errorf("otel backend: create exporter: %w", err)
}
reader := sdkmetric.NewPeriodicReader(exp,
sdkmetric.WithInterval(cfg.ExportInterval),
)
provider := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(reader),
)
meter := provider.Meter("github.com/fosrl/gerbil")
return &Backend{cfg: cfg, provider: provider, meter: meter}, nil
}
// HTTPHandler returns nil the OTel backend does not expose an HTTP endpoint.
func (b *Backend) HTTPHandler() http.Handler {
_ = b
return nil
}
// Shutdown flushes pending metrics and shuts down the MeterProvider.
func (b *Backend) Shutdown(ctx context.Context) error {
return b.provider.Shutdown(ctx)
}
// NewCounter creates an OTel Int64Counter.
func (b *Backend) NewCounter(name, desc string, labelNames ...string) (*Counter, error) {
normalizedLabelNames, err := validateLabelNames(labelNames)
if err != nil {
return nil, fmt.Errorf("otel: create counter %q: %w", name, err)
}
c, err := b.meter.Int64Counter(name, metric.WithDescription(desc))
if err != nil {
return nil, fmt.Errorf("otel: create counter %q: %w", name, err)
}
return &Counter{c: c, labelNames: normalizedLabelNames}, nil
}
// NewUpDownCounter creates an OTel Int64UpDownCounter.
func (b *Backend) NewUpDownCounter(name, desc string, labelNames ...string) (*UpDownCounter, error) {
normalizedLabelNames, err := validateLabelNames(labelNames)
if err != nil {
return nil, fmt.Errorf("otel: create up-down counter %q: %w", name, err)
}
c, err := b.meter.Int64UpDownCounter(name, metric.WithDescription(desc))
if err != nil {
return nil, fmt.Errorf("otel: create up-down counter %q: %w", name, err)
}
return &UpDownCounter{c: c, labelNames: normalizedLabelNames}, nil
}
// NewInt64Gauge creates an OTel Int64Gauge.
func (b *Backend) NewInt64Gauge(name, desc string, labelNames ...string) (*Int64Gauge, error) {
normalizedLabelNames, err := validateLabelNames(labelNames)
if err != nil {
return nil, fmt.Errorf("otel: create int64 gauge %q: %w", name, err)
}
g, err := b.meter.Int64Gauge(name, metric.WithDescription(desc))
if err != nil {
return nil, fmt.Errorf("otel: create int64 gauge %q: %w", name, err)
}
return &Int64Gauge{g: g, labelNames: normalizedLabelNames}, nil
}
// NewFloat64Gauge creates an OTel Float64Gauge.
func (b *Backend) NewFloat64Gauge(name, desc string, labelNames ...string) (*Float64Gauge, error) {
normalizedLabelNames, err := validateLabelNames(labelNames)
if err != nil {
return nil, fmt.Errorf("otel: create float64 gauge %q: %w", name, err)
}
g, err := b.meter.Float64Gauge(name, metric.WithDescription(desc))
if err != nil {
return nil, fmt.Errorf("otel: create float64 gauge %q: %w", name, err)
}
return &Float64Gauge{g: g, labelNames: normalizedLabelNames}, nil
}
// NewHistogram creates an OTel Float64Histogram with explicit bucket boundaries.
func (b *Backend) NewHistogram(name, desc string, buckets []float64, labelNames ...string) (*Histogram, error) {
normalizedLabelNames, err := validateLabelNames(labelNames)
if err != nil {
return nil, fmt.Errorf("otel: create histogram %q: %w", name, err)
}
h, err := b.meter.Float64Histogram(name,
metric.WithDescription(desc),
metric.WithExplicitBucketBoundaries(buckets...),
)
if err != nil {
return nil, fmt.Errorf("otel: create histogram %q: %w", name, err)
}
return &Histogram{h: h, labelNames: normalizedLabelNames}, nil
}
func validateLabelNames(labelNames []string) ([]string, error) {
if len(labelNames) == 0 {
return nil, nil
}
normalized := make([]string, len(labelNames))
seen := make(map[string]struct{}, len(labelNames))
for i, name := range labelNames {
if !metricLabelNameRE.MatchString(name) {
return nil, fmt.Errorf("invalid label name %q", name)
}
if _, exists := seen[name]; exists {
return nil, fmt.Errorf("duplicate label name %q", name)
}
seen[name] = struct{}{}
normalized[i] = name
}
return normalized, nil
}
func labelsToAttrs(labelNames []string, labels map[string]string) []attribute.KeyValue {
if len(labelNames) == 0 {
if len(labels) > 0 {
log.Printf("WARN: dropping otel metric sample due to unexpected labels: got=%v expected=none", labels)
return nil
}
return []attribute.KeyValue{}
}
attrs := make([]attribute.KeyValue, 0, len(labelNames))
for _, labelName := range labelNames {
attrs = append(attrs, attribute.String(labelName, labels[labelName]))
}
for got := range labels {
found := false
for _, expected := range labelNames {
if got == expected {
found = true
break
}
}
if !found {
log.Printf("WARN: dropping otel metric sample due to unexpected label key %q (expected=%v)", got, labelNames)
return nil
}
}
return attrs
}
// Counter wraps an OTel Int64Counter.
type Counter struct {
c metric.Int64Counter
labelNames []string
}
// Add increments the counter by value.
func (c *Counter) Add(ctx context.Context, value int64, labels map[string]string) {
attrs := labelsToAttrs(c.labelNames, labels)
if attrs == nil {
return
}
c.c.Add(ctx, value, metric.WithAttributes(attrs...))
}
// UpDownCounter wraps an OTel Int64UpDownCounter.
type UpDownCounter struct {
c metric.Int64UpDownCounter
labelNames []string
}
// Add adjusts the up-down counter by value.
func (u *UpDownCounter) Add(ctx context.Context, value int64, labels map[string]string) {
attrs := labelsToAttrs(u.labelNames, labels)
if attrs == nil {
return
}
u.c.Add(ctx, value, metric.WithAttributes(attrs...))
}
// Int64Gauge wraps an OTel Int64Gauge.
type Int64Gauge struct {
g metric.Int64Gauge
labelNames []string
}
// Record sets the gauge to value.
func (g *Int64Gauge) Record(ctx context.Context, value int64, labels map[string]string) {
attrs := labelsToAttrs(g.labelNames, labels)
if attrs == nil {
return
}
g.g.Record(ctx, value, metric.WithAttributes(attrs...))
}
// Float64Gauge wraps an OTel Float64Gauge.
type Float64Gauge struct {
g metric.Float64Gauge
labelNames []string
}
// Record sets the gauge to value.
func (g *Float64Gauge) Record(ctx context.Context, value float64, labels map[string]string) {
attrs := labelsToAttrs(g.labelNames, labels)
if attrs == nil {
return
}
g.g.Record(ctx, value, metric.WithAttributes(attrs...))
}
// Histogram wraps an OTel Float64Histogram.
type Histogram struct {
h metric.Float64Histogram
labelNames []string
}
// Record observes value in the histogram.
func (h *Histogram) Record(ctx context.Context, value float64, labels map[string]string) {
attrs := labelsToAttrs(h.labelNames, labels)
if attrs == nil {
return
}
h.h.Record(ctx, value, metric.WithAttributes(attrs...))
}

View File

@@ -0,0 +1,175 @@
package otel_test
import (
"context"
"testing"
"time"
obsotel "github.com/fosrl/gerbil/internal/observability/otel"
)
const (
defaultGRPCEndpoint = "localhost:4317"
defaultServiceName = "gerbil-test"
)
func newInMemoryBackend(t *testing.T) *obsotel.Backend {
t.Helper()
// Use a very short export interval; an in-process collector (noop exporter)
// is used by pointing to a non-existent endpoint with insecure mode.
// The backend itself should initialise without error since connection is lazy.
b, err := obsotel.New(obsotel.Config{
Protocol: "grpc",
Endpoint: defaultGRPCEndpoint,
Insecure: true,
ExportInterval: 100 * time.Millisecond,
ServiceName: defaultServiceName,
ServiceVersion: "0.0.1",
})
if err != nil {
t.Fatalf("failed to create otel backend: %v", err)
}
return b
}
func TestOtelBackendHTTPHandlerIsNil(t *testing.T) {
b := newInMemoryBackend(t)
defer b.Shutdown(context.Background()) //nolint:errcheck
if b.HTTPHandler() != nil {
t.Error("OTel backend HTTPHandler should return nil")
}
}
func TestOtelBackendShutdown(t *testing.T) {
b := newInMemoryBackend(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := b.Shutdown(ctx); err != nil {
// Shutdown with unreachable collector may fail to flush; that's acceptable.
// What matters is that Shutdown does not panic.
t.Logf("Shutdown returned (expected with no collector): %v", err)
}
}
func TestOtelBackendCounter(t *testing.T) {
b := newInMemoryBackend(t)
defer b.Shutdown(context.Background()) //nolint:errcheck
c, err := b.NewCounter("gerbil_test_counter_total", "test counter", "result")
if err != nil {
t.Fatalf("NewCounter returned error: %v", err)
}
// Should not panic
c.Add(context.Background(), 1, map[string]string{"result": "ok"})
c.Add(context.Background(), 5, nil)
}
func TestOtelBackendUpDownCounter(t *testing.T) {
b := newInMemoryBackend(t)
defer b.Shutdown(context.Background()) //nolint:errcheck
u, err := b.NewUpDownCounter("gerbil_test_updown", "test updown", "state")
if err != nil {
t.Fatalf("NewUpDownCounter returned error: %v", err)
}
u.Add(context.Background(), 3, map[string]string{"state": "active"})
u.Add(context.Background(), -1, map[string]string{"state": "active"})
}
func TestOtelBackendInt64Gauge(t *testing.T) {
b := newInMemoryBackend(t)
defer b.Shutdown(context.Background()) //nolint:errcheck
g, err := b.NewInt64Gauge("gerbil_test_int_gauge", "test gauge")
if err != nil {
t.Fatalf("NewInt64Gauge returned error: %v", err)
}
g.Record(context.Background(), 42, nil)
}
func TestOtelBackendFloat64Gauge(t *testing.T) {
b := newInMemoryBackend(t)
defer b.Shutdown(context.Background()) //nolint:errcheck
g, err := b.NewFloat64Gauge("gerbil_test_float_gauge", "test float gauge")
if err != nil {
t.Fatalf("NewFloat64Gauge returned error: %v", err)
}
g.Record(context.Background(), 3.14, nil)
}
func TestOtelBackendHistogram(t *testing.T) {
b := newInMemoryBackend(t)
defer b.Shutdown(context.Background()) //nolint:errcheck
h, err := b.NewHistogram("gerbil_test_duration_seconds", "test histogram",
[]float64{0.1, 0.5, 1.0}, "method")
if err != nil {
t.Fatalf("NewHistogram returned error: %v", err)
}
h.Record(context.Background(), 0.3, map[string]string{"method": "GET"})
}
func TestOtelBackendHTTPProtocol(t *testing.T) {
b, err := obsotel.New(obsotel.Config{
Protocol: "http",
Endpoint: "localhost:4318",
Insecure: true,
ExportInterval: 100 * time.Millisecond,
ServiceName: defaultServiceName,
})
if err != nil {
t.Fatalf("failed to create otel http backend: %v", err)
}
defer b.Shutdown(context.Background()) //nolint:errcheck
if b.HTTPHandler() != nil {
t.Error("OTel HTTP backend should not expose a /metrics endpoint")
}
}
func TestOtelBackendInvalidProtocol(t *testing.T) {
_, err := obsotel.New(obsotel.Config{
Protocol: "tcp",
Endpoint: defaultGRPCEndpoint,
ExportInterval: 10 * time.Second,
})
if err == nil {
t.Error("expected error for invalid protocol")
}
}
func TestOtelBackendDeploymentEnvironment(t *testing.T) {
b, err := obsotel.New(obsotel.Config{
Protocol: "grpc",
Endpoint: defaultGRPCEndpoint,
Insecure: true,
ExportInterval: 100 * time.Millisecond,
ServiceName: defaultServiceName,
ServiceVersion: "1.2.3",
DeploymentEnvironment: "staging",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer b.Shutdown(context.Background()) //nolint:errcheck
}
func TestOtelBackendRejectsInvalidLabelNames(t *testing.T) {
b := newInMemoryBackend(t)
defer b.Shutdown(context.Background()) //nolint:errcheck
t.Run("duplicate labels", func(t *testing.T) {
_, err := b.NewCounter("gerbil_test_invalid_labels_total", "test counter", "result", "result")
if err == nil {
t.Fatal("expected error for duplicate label names")
}
})
t.Run("invalid label name", func(t *testing.T) {
_, err := b.NewHistogram("gerbil_test_invalid_histogram", "test histogram", []float64{0.1, 1.0}, "status-code")
if err == nil {
t.Fatal("expected error for invalid label name")
}
})
}

View File

@@ -0,0 +1,68 @@
package otel
import (
"context"
"fmt"
"net/url"
"strings"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
)
// newExporter creates the appropriate OTLP exporter based on cfg.Protocol.
func newExporter(ctx context.Context, cfg Config) (sdkmetric.Exporter, error) {
if strings.TrimSpace(cfg.Endpoint) == "" {
return nil, fmt.Errorf("otel: cfg.Endpoint is empty")
}
switch cfg.Protocol {
case "grpc", "":
return newGRPCExporter(ctx, cfg)
case "http":
return newHTTPExporter(ctx, cfg)
default:
return nil, fmt.Errorf("otel: unknown protocol %q (must be \"grpc\" or \"http\")", cfg.Protocol)
}
}
func newGRPCExporter(ctx context.Context, cfg Config) (sdkmetric.Exporter, error) {
opts := []otlpmetricgrpc.Option{
otlpmetricgrpc.WithEndpoint(cfg.Endpoint),
}
if cfg.Insecure {
opts = append(opts, otlpmetricgrpc.WithInsecure())
}
exp, err := otlpmetricgrpc.New(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("otlp grpc exporter: %w", err)
}
return exp, nil
}
func newHTTPExporter(ctx context.Context, cfg Config) (sdkmetric.Exporter, error) {
endpoint := strings.TrimSpace(cfg.Endpoint)
opts := make([]otlpmetrichttp.Option, 0, 3)
if strings.Contains(endpoint, "://") {
parsed, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("otlp http exporter: parse endpoint URL %q: %w", endpoint, err)
}
opts = append(opts, otlpmetrichttp.WithEndpointURL(parsed.String()))
} else {
opts = append(opts,
otlpmetrichttp.WithEndpoint(endpoint),
otlpmetrichttp.WithURLPath("/v1/metrics"),
)
}
if cfg.Insecure {
opts = append(opts, otlpmetrichttp.WithInsecure())
}
exp, err := otlpmetrichttp.New(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("otlp http exporter: %w", err)
}
return exp, nil
}

View File

@@ -0,0 +1,25 @@
package otel
import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.18.0"
)
// newResource builds an OTel resource for the Gerbil service.
func newResource(serviceName, serviceVersion, deploymentEnv string) (*resource.Resource, error) {
attrs := []attribute.KeyValue{
semconv.ServiceName(serviceName),
}
if serviceVersion != "" {
attrs = append(attrs, semconv.ServiceVersion(serviceVersion))
}
if deploymentEnv != "" {
attrs = append(attrs, semconv.DeploymentEnvironment(deploymentEnv))
}
return resource.Merge(
resource.Default(),
resource.NewSchemaless(attrs...),
)
}

View File

@@ -0,0 +1,310 @@
// Package prometheus implements the native Prometheus metrics backend for Gerbil.
//
// This backend uses the Prometheus Go client directly; it does NOT depend on the
// OpenTelemetry SDK. A dedicated Prometheus registry is used so that default
// Go/process metrics are not unintentionally included unless the caller opts in.
package prometheus
import (
"context"
"log"
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Config holds Prometheus-backend configuration.
type Config struct {
// Path is the HTTP endpoint path (e.g. "/metrics").
Path string
// IncludeGoMetrics controls whether the standard Go runtime and process
// collectors are registered on the dedicated registry.
// Defaults to true if not explicitly set.
IncludeGoMetrics *bool
}
// Backend is the native Prometheus metrics backend.
// Metric instruments are created via the New* family of methods and stored
// in the backend-specific instrument types that implement the observability
// instrument interfaces.
type Backend struct {
cfg Config
registry *prometheus.Registry
handler http.Handler
droppedSamplesCounter prometheus.Counter
}
// New creates and initialises a Prometheus backend.
//
// cfg.Path sets the HTTP endpoint path (defaults to "/metrics" if empty).
// cfg.IncludeGoMetrics controls whether standard Go runtime and process metrics
// are included; defaults to true when nil.
//
// Returns an error if the registry cannot be created.
func New(cfg Config) (*Backend, error) {
if cfg.Path == "" {
cfg.Path = "/metrics"
}
registry := prometheus.NewRegistry()
droppedSamplesCounter := prometheus.NewCounter(prometheus.CounterOpts{
Name: "gerbil_dropped_metric_samples_total",
Help: "Total number of metric samples dropped due to invalid labels or unsupported label sets",
})
registry.MustRegister(droppedSamplesCounter)
// Include Go and process metrics by default.
includeGo := cfg.IncludeGoMetrics == nil || *cfg.IncludeGoMetrics
if includeGo {
registry.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
}
handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{
EnableOpenMetrics: false,
})
return &Backend{cfg: cfg, registry: registry, handler: handler, droppedSamplesCounter: droppedSamplesCounter}, nil
}
// HTTPHandler returns the Prometheus /metrics HTTP handler.
func (b *Backend) HTTPHandler() http.Handler {
return b.handler
}
// Shutdown is a no-op for the Prometheus backend.
// The registry does not maintain background goroutines.
func (b *Backend) Shutdown(_ context.Context) error {
_ = b
return nil
}
// NewCounter creates a Prometheus CounterVec registered on the backend's registry.
func (b *Backend) NewCounter(name, desc string, labelNames ...string) (*Counter, error) {
vec := prometheus.NewCounterVec(prometheus.CounterOpts{
Name: name,
Help: desc,
}, labelNames)
if err := b.registry.Register(vec); err != nil {
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
existing, ok := are.ExistingCollector.(*prometheus.CounterVec)
if !ok {
return nil, err
}
return &Counter{vec: existing, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
return nil, err
}
return &Counter{vec: vec, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
// NewUpDownCounter creates a Prometheus GaugeVec (Prometheus gauges are
// bidirectional) registered on the backend's registry.
func (b *Backend) NewUpDownCounter(name, desc string, labelNames ...string) (*UpDownCounter, error) {
vec := prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: name,
Help: desc,
}, labelNames)
if err := b.registry.Register(vec); err != nil {
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
existing, ok := are.ExistingCollector.(*prometheus.GaugeVec)
if !ok {
return nil, err
}
return &UpDownCounter{vec: existing, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
return nil, err
}
return &UpDownCounter{vec: vec, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
// NewInt64Gauge creates a Prometheus GaugeVec registered on the backend's registry.
func (b *Backend) NewInt64Gauge(name, desc string, labelNames ...string) (*Int64Gauge, error) {
vec := prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: name,
Help: desc,
}, labelNames)
if err := b.registry.Register(vec); err != nil {
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
existing, ok := are.ExistingCollector.(*prometheus.GaugeVec)
if !ok {
return nil, err
}
return &Int64Gauge{vec: existing, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
return nil, err
}
return &Int64Gauge{vec: vec, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
// NewFloat64Gauge creates a Prometheus GaugeVec registered on the backend's registry.
func (b *Backend) NewFloat64Gauge(name, desc string, labelNames ...string) (*Float64Gauge, error) {
vec := prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: name,
Help: desc,
}, labelNames)
if err := b.registry.Register(vec); err != nil {
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
existing, ok := are.ExistingCollector.(*prometheus.GaugeVec)
if !ok {
return nil, err
}
return &Float64Gauge{vec: existing, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
return nil, err
}
return &Float64Gauge{vec: vec, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
// NewHistogram creates a Prometheus HistogramVec registered on the backend's registry.
func (b *Backend) NewHistogram(name, desc string, buckets []float64, labelNames ...string) (*Histogram, error) {
vec := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: name,
Help: desc,
Buckets: buckets,
}, labelNames)
if err := b.registry.Register(vec); err != nil {
if are, ok := err.(prometheus.AlreadyRegisteredError); ok {
existing, ok := are.ExistingCollector.(*prometheus.HistogramVec)
if !ok {
return nil, err
}
return &Histogram{vec: existing, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
return nil, err
}
return &Histogram{vec: vec, labelNames: append([]string(nil), labelNames...), droppedSamplesCounter: b.droppedSamplesCounter}, nil
}
// Counter is a native Prometheus counter instrument.
type Counter struct {
vec *prometheus.CounterVec
labelNames []string
droppedSamplesCounter prometheus.Counter
}
// Add increments the counter by value for the given labels.
//
// value must be non-negative. Negative values are ignored.
func (c *Counter) Add(_ context.Context, value int64, labels map[string]string) {
if value < 0 {
log.Printf("WARN: counter add called with negative value=%d labels=%v expected_labels=%v", value, labels, c.labelNames)
return
}
normalized, ok := normalizeLabels(c.labelNames, labels, c.droppedSamplesCounter)
if !ok {
return
}
defer guardMetricPanic("counter", c.labelNames, labels)
c.vec.With(normalized).Add(float64(value))
}
// UpDownCounter is a native Prometheus gauge used as a bidirectional counter.
type UpDownCounter struct {
vec *prometheus.GaugeVec
labelNames []string
droppedSamplesCounter prometheus.Counter
}
// Add adjusts the gauge by value for the given labels.
func (u *UpDownCounter) Add(_ context.Context, value int64, labels map[string]string) {
normalized, ok := normalizeLabels(u.labelNames, labels, u.droppedSamplesCounter)
if !ok {
return
}
defer guardMetricPanic("updown", u.labelNames, labels)
u.vec.With(normalized).Add(float64(value))
}
// Int64Gauge is a native Prometheus gauge recording integer snapshot values.
type Int64Gauge struct {
vec *prometheus.GaugeVec
labelNames []string
droppedSamplesCounter prometheus.Counter
}
// Record sets the gauge to value for the given labels.
func (g *Int64Gauge) Record(_ context.Context, value int64, labels map[string]string) {
normalized, ok := normalizeLabels(g.labelNames, labels, g.droppedSamplesCounter)
if !ok {
return
}
defer guardMetricPanic("int64-gauge", g.labelNames, labels)
g.vec.With(normalized).Set(float64(value))
}
// Float64Gauge is a native Prometheus gauge recording float snapshot values.
type Float64Gauge struct {
vec *prometheus.GaugeVec
labelNames []string
droppedSamplesCounter prometheus.Counter
}
// Record sets the gauge to value for the given labels.
func (g *Float64Gauge) Record(_ context.Context, value float64, labels map[string]string) {
normalized, ok := normalizeLabels(g.labelNames, labels, g.droppedSamplesCounter)
if !ok {
return
}
defer guardMetricPanic("float64-gauge", g.labelNames, labels)
g.vec.With(normalized).Set(value)
}
// Histogram is a native Prometheus histogram instrument.
type Histogram struct {
vec *prometheus.HistogramVec
labelNames []string
droppedSamplesCounter prometheus.Counter
}
// Record observes value for the given labels.
func (h *Histogram) Record(_ context.Context, value float64, labels map[string]string) {
normalized, ok := normalizeLabels(h.labelNames, labels, h.droppedSamplesCounter)
if !ok {
return
}
defer guardMetricPanic("histogram", h.labelNames, labels)
h.vec.With(normalized).Observe(value)
}
func normalizeLabels(labelNames []string, labels map[string]string, droppedSamplesCounter prometheus.Counter) (prometheus.Labels, bool) {
if len(labelNames) == 0 {
if len(labels) > 0 {
if droppedSamplesCounter != nil {
droppedSamplesCounter.Inc()
}
log.Printf("WARN: dropping metric sample due to unexpected labels: got=%v expected=none", labels)
return nil, false
}
return nil, true
}
normalized := make(prometheus.Labels, len(labelNames))
for _, name := range labelNames {
normalized[name] = ""
}
for k, v := range labels {
if _, ok := normalized[k]; !ok {
if droppedSamplesCounter != nil {
droppedSamplesCounter.Inc()
}
log.Printf("WARN: dropping metric sample due to unexpected label key %q (expected=%v)", k, labelNames)
return nil, false
}
normalized[k] = v
}
return normalized, true
}
func guardMetricPanic(kind string, expected []string, labels map[string]string) {
if recovered := recover(); recovered != nil {
log.Printf("WARN: dropped %s metric sample due to label panic: expected=%v got=%v err=%v", kind, expected, labels, recovered)
}
}

View File

@@ -0,0 +1,231 @@
package prometheus_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
obsprom "github.com/fosrl/gerbil/internal/observability/prometheus"
)
func newTestBackend(t *testing.T) *obsprom.Backend {
t.Helper()
b, err := obsprom.New(obsprom.Config{Path: "/metrics"})
if err != nil {
t.Fatalf("failed to create prometheus backend: %v", err)
}
return b
}
func TestPrometheusBackendHTTPHandler(t *testing.T) {
b := newTestBackend(t)
if b.HTTPHandler() == nil {
t.Error("HTTPHandler should not be nil")
}
}
func TestPrometheusBackendShutdown(t *testing.T) {
b := newTestBackend(t)
if err := b.Shutdown(context.Background()); err != nil {
t.Errorf("Shutdown returned error: %v", err)
}
}
func TestPrometheusBackendCounter(t *testing.T) {
b := newTestBackend(t)
c, err := b.NewCounter("test_counter_total", "A test counter", "result")
if err != nil {
t.Fatalf("NewCounter returned error: %v", err)
}
c.Add(context.Background(), 3, map[string]string{"result": "ok"})
body := scrapeMetrics(t, b)
assertMetricPresent(t, body, `test_counter_total{result="ok"} 3`)
}
func TestPrometheusBackendUpDownCounter(t *testing.T) {
b := newTestBackend(t)
u, err := b.NewUpDownCounter("test_gauge_total", "A test up-down counter", "state")
if err != nil {
t.Fatalf("NewUpDownCounter returned error: %v", err)
}
u.Add(context.Background(), 5, map[string]string{"state": "active"})
u.Add(context.Background(), -2, map[string]string{"state": "active"})
body := scrapeMetrics(t, b)
assertMetricPresent(t, body, `test_gauge_total{state="active"} 3`)
}
func TestPrometheusBackendInt64Gauge(t *testing.T) {
b := newTestBackend(t)
g, err := b.NewInt64Gauge("test_int_gauge", "An integer gauge", "ifname")
if err != nil {
t.Fatalf("NewInt64Gauge returned error: %v", err)
}
g.Record(context.Background(), 42, map[string]string{"ifname": "wg0"})
body := scrapeMetrics(t, b)
assertMetricPresent(t, body, `test_int_gauge{ifname="wg0"} 42`)
}
func TestPrometheusBackendFloat64Gauge(t *testing.T) {
b := newTestBackend(t)
g, err := b.NewFloat64Gauge("test_float_gauge", "A float gauge", "cert")
if err != nil {
t.Fatalf("NewFloat64Gauge returned error: %v", err)
}
g.Record(context.Background(), 7.5, map[string]string{"cert": "example.com"})
body := scrapeMetrics(t, b)
assertMetricPresent(t, body, `test_float_gauge{cert="example.com"} 7.5`)
}
func TestPrometheusBackendHistogram(t *testing.T) {
b := newTestBackend(t)
buckets := []float64{0.1, 0.5, 1.0, 5.0}
h, err := b.NewHistogram("test_duration_seconds", "A test histogram", buckets, "method")
if err != nil {
t.Fatalf("NewHistogram returned error: %v", err)
}
h.Record(context.Background(), 0.3, map[string]string{"method": "GET"})
body := scrapeMetrics(t, b)
if !strings.Contains(body, "test_duration_seconds") {
t.Errorf("expected histogram metric in output, body:\n%s", body)
}
}
func TestPrometheusBackendMultipleLabels(t *testing.T) {
b := newTestBackend(t)
c, err := b.NewCounter("multi_label_total", "Multi-label counter", "method", "route", "status_code")
if err != nil {
t.Fatalf("NewCounter returned error: %v", err)
}
c.Add(context.Background(), 1, map[string]string{
"method": "POST",
"route": "/api/peers",
"status_code": "200",
})
body := scrapeMetrics(t, b)
if !strings.Contains(body, "multi_label_total") {
t.Errorf("expected multi_label_total in output, body:\n%s", body)
}
}
func TestPrometheusBackendGoMetrics(t *testing.T) {
b := newTestBackend(t)
body := scrapeMetrics(t, b)
// Default backend includes Go runtime metrics.
if !strings.Contains(body, "go_goroutines") {
t.Error("expected go_goroutines in default backend output")
}
}
func TestPrometheusBackendNoGoMetrics(t *testing.T) {
f := false
b, err := obsprom.New(obsprom.Config{IncludeGoMetrics: &f})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := scrapeMetrics(t, b)
if strings.Contains(body, "go_goroutines") {
t.Error("expected no go_goroutines when IncludeGoMetrics=false")
}
}
func TestPrometheusBackendNilLabels(t *testing.T) {
// Adding with nil labels should not panic (treated as empty map).
b := newTestBackend(t)
c, err := b.NewCounter("nil_labels_total", "counter with no labels")
if err != nil {
t.Fatalf("NewCounter returned error: %v", err)
}
// nil labels with no label names declared should be safe
c.Add(context.Background(), 1, nil)
}
func TestPrometheusBackendConcurrentAdd(t *testing.T) {
b := newTestBackend(t)
c, err := b.NewCounter("concurrent_total", "concurrent counter", "worker")
if err != nil {
t.Fatalf("NewCounter returned error: %v", err)
}
done := make(chan struct{})
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 100; j++ {
c.Add(context.Background(), 1, map[string]string{"worker": "w"})
}
done <- struct{}{}
}()
}
for i := 0; i < 10; i++ {
<-done
}
body := scrapeMetrics(t, b)
assertMetricPresent(t, body, `concurrent_total{worker="w"} 1000`)
}
func TestPrometheusBackendAlreadyRegisteredCounter(t *testing.T) {
b := newTestBackend(t)
c1, err := b.NewCounter("dupe_counter_total", "duplicate counter", "result")
if err != nil {
t.Fatalf("first NewCounter returned error: %v", err)
}
c2, err := b.NewCounter("dupe_counter_total", "duplicate counter", "result")
if err != nil {
t.Fatalf("second NewCounter returned error: %v", err)
}
c1.Add(context.Background(), 1, map[string]string{"result": "ok"})
c2.Add(context.Background(), 2, map[string]string{"result": "ok"})
body := scrapeMetrics(t, b)
assertMetricPresent(t, body, `dupe_counter_total{result="ok"} 3`)
}
func TestPrometheusBackendInvalidLabelsNoPanic(t *testing.T) {
b := newTestBackend(t)
c, err := b.NewCounter("invalid_labels_total", "invalid labels test", "result")
if err != nil {
t.Fatalf("NewCounter returned error: %v", err)
}
// Extra label key should be dropped and must not panic.
c.Add(context.Background(), 5, map[string]string{"result": "ok", "unexpected": "x"})
body := scrapeMetrics(t, b)
if strings.Contains(body, `invalid_labels_total{result="ok"}`) {
t.Error("invalid label sample should have been dropped")
}
}
// --- helpers ---
func scrapeMetrics(t *testing.T, b *obsprom.Backend) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/metrics", http.NoBody)
rr := httptest.NewRecorder()
b.HTTPHandler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("metrics handler returned %d", rr.Code)
}
body, err := io.ReadAll(rr.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
return string(body)
}
func assertMetricPresent(t *testing.T, body, expected string) {
t.Helper()
if !strings.Contains(body, expected) {
t.Errorf("expected %q in metrics output\nbody:\n%s", expected, body)
}
}

1112
main.go

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,58 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"os"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/fosrl/gerbil/internal/metrics"
"github.com/fosrl/gerbil/logger" "github.com/fosrl/gerbil/logger"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
) )
// defaultMaxSNIConnections caps the number of concurrent client connections
// the SNI proxy will accept. Without a cap, a burst of connections (a
// scanner sweep, a client reconnect storm) spawns unbounded goroutines each
// holding copy buffers and sockets, exhausting container memory faster than
// GC/backpressure can catch up.
// Sized conservatively since each connection now holds up to two pooled
// 32KB copy buffers once the buffer pool is actually honored (see
// bufferedReader/bufferedWriter below). Overridable via
// GERBIL_MAX_SNI_CONNECTIONS.
const defaultMaxSNIConnections = 4096
var maxSNIConnections = loadMaxSNIConnections()
func loadMaxSNIConnections() int64 {
if v := os.Getenv("GERBIL_MAX_SNI_CONNECTIONS"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
return n
}
}
return defaultMaxSNIConnections
}
// defaultMaxSNIConnectionsPerIP caps concurrent connections from a single
// source IP, independent of the global maxSNIConnections budget. Without
// this, one noisy/misbehaving client (or a single scanning host) can
// consume the entire global budget and lock out every other customer
// sharing this proxy. Overridable via GERBIL_MAX_SNI_CONNECTIONS_PER_IP.
const defaultMaxSNIConnectionsPerIP = 256
var maxSNIConnectionsPerIP = loadMaxSNIConnectionsPerIP()
func loadMaxSNIConnectionsPerIP() int64 {
if v := os.Getenv("GERBIL_MAX_SNI_CONNECTIONS_PER_IP"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
return n
}
}
return defaultMaxSNIConnectionsPerIP
}
// RouteRecord represents a routing configuration // RouteRecord represents a routing configuration
type RouteRecord struct { type RouteRecord struct {
Hostname string Hostname string
@@ -31,6 +75,16 @@ type RouteAPIResponse struct {
Endpoints []string `json:"endpoints"` Endpoints []string `json:"endpoints"`
} }
// ProxyProtocolInfo holds information parsed from incoming PROXY protocol header
type ProxyProtocolInfo struct {
Protocol string // TCP4 or TCP6
SrcIP string
DestIP string
SrcPort int
DestPort int
OriginalConn net.Conn // The original connection after PROXY protocol parsing
}
// SNIProxy represents the main proxy server // SNIProxy represents the main proxy server
type SNIProxy struct { type SNIProxy struct {
port int port int
@@ -55,6 +109,27 @@ type SNIProxy struct {
// Track active tunnels by SNI // Track active tunnels by SNI
activeTunnels map[string]*activeTunnel activeTunnels map[string]*activeTunnel
activeTunnelsLock sync.Mutex activeTunnelsLock sync.Mutex
// Trusted upstream proxies that can send PROXY protocol
trustedUpstreams map[string]struct{}
// Reusable HTTP client for API requests
httpClient *http.Client
// Buffer pool for connection piping
bufferPool *sync.Pool
// activeConnections tracks concurrent client connections so
// acceptConnections can enforce maxSNIConnections.
activeConnections atomic.Int64
// perIPConnections tracks concurrent connections per source IP (map[string]*atomic.Int64)
// so acceptConnections can enforce maxSNIConnectionsPerIP and stop one
// client from starving the rest. Entries are removed once a given IP's
// count returns to zero, so this stays bounded by currently-connected
// distinct IPs (itself bounded by maxSNIConnections) rather than growing
// with every IP ever seen.
perIPConnections sync.Map
} }
type activeTunnel struct { type activeTunnel struct {
@@ -75,6 +150,194 @@ func (conn readOnlyConn) SetDeadline(t time.Time) error { return nil }
func (conn readOnlyConn) SetReadDeadline(t time.Time) error { return nil } func (conn readOnlyConn) SetReadDeadline(t time.Time) error { return nil }
func (conn readOnlyConn) SetWriteDeadline(t time.Time) error { return nil } func (conn readOnlyConn) SetWriteDeadline(t time.Time) error { return nil }
// parseProxyProtocolHeader parses a PROXY protocol v1 header from the connection
func (p *SNIProxy) parseProxyProtocolHeader(conn net.Conn) (*ProxyProtocolInfo, net.Conn, error) {
// Check if the connection comes from a trusted upstream
remoteHost, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
return nil, conn, fmt.Errorf("failed to parse remote address: %w", err)
}
// Resolve the remote IP to hostname to check if it's trusted
// For simplicity, we'll check the IP directly in trusted upstreams
// In production, you might want to do reverse DNS lookup
if _, isTrusted := p.trustedUpstreams[remoteHost]; !isTrusted {
// Not from trusted upstream, return original connection
return nil, conn, nil
}
// Set read timeout for PROXY protocol parsing
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
return nil, conn, fmt.Errorf("failed to set read deadline: %w", err)
}
// Read the first line (PROXY protocol header)
buffer := make([]byte, 512) // PROXY protocol header should be much smaller
n, err := conn.Read(buffer)
if err != nil {
// If we can't read from trusted upstream, treat as regular connection
logger.Debug("Could not read from trusted upstream %s, treating as regular connection: %v", remoteHost, err)
// Clear read timeout before returning
if clearErr := conn.SetReadDeadline(time.Time{}); clearErr != nil {
logger.Debug("Failed to clear read deadline: %v", clearErr)
}
return nil, conn, nil
}
// Find the end of the first line (CRLF)
headerEnd := bytes.Index(buffer[:n], []byte("\r\n"))
if headerEnd == -1 {
// No PROXY protocol header found, treat as regular TLS connection
// Return the connection with the buffered data prepended
logger.Debug("No PROXY protocol header from trusted upstream %s, treating as regular TLS connection", remoteHost)
// Clear read timeout
if err := conn.SetReadDeadline(time.Time{}); err != nil {
logger.Debug("Failed to clear read deadline: %v", err)
}
// Create a reader that includes the buffered data + original connection
newReader := io.MultiReader(bytes.NewReader(buffer[:n]), conn)
wrappedConn := &proxyProtocolConn{
Conn: conn,
reader: newReader,
}
return nil, wrappedConn, nil
}
headerLine := string(buffer[:headerEnd])
remainingData := buffer[headerEnd+2 : n]
// Parse PROXY protocol line: "PROXY TCP4/TCP6 srcIP destIP srcPort destPort"
parts := strings.Fields(headerLine)
if len(parts) != 6 || parts[0] != "PROXY" {
// Check for PROXY UNKNOWN
if len(parts) == 2 && parts[0] == "PROXY" && parts[1] == "UNKNOWN" {
// PROXY UNKNOWN - use original connection info
return nil, conn, nil
}
// Invalid PROXY protocol, but might be regular TLS - treat as such
logger.Debug("Invalid PROXY protocol from trusted upstream %s, treating as regular TLS connection: %s", remoteHost, headerLine)
// Clear read timeout
if err := conn.SetReadDeadline(time.Time{}); err != nil {
logger.Debug("Failed to clear read deadline: %v", err)
}
// Return the connection with all buffered data prepended
newReader := io.MultiReader(bytes.NewReader(buffer[:n]), conn)
wrappedConn := &proxyProtocolConn{
Conn: conn,
reader: newReader,
}
return nil, wrappedConn, nil
}
protocol := parts[1]
srcIP := parts[2]
destIP := parts[3]
srcPort, err := strconv.Atoi(parts[4])
if err != nil {
return nil, conn, fmt.Errorf("invalid source port in PROXY header: %s", parts[4])
}
destPort, err := strconv.Atoi(parts[5])
if err != nil {
return nil, conn, fmt.Errorf("invalid destination port in PROXY header: %s", parts[5])
}
// Create a new reader that includes remaining data + original connection
var newReader io.Reader
if len(remainingData) > 0 {
newReader = io.MultiReader(bytes.NewReader(remainingData), conn)
} else {
newReader = conn
}
// Create a wrapper connection that reads from the combined reader
wrappedConn := &proxyProtocolConn{
Conn: conn,
reader: newReader,
}
proxyInfo := &ProxyProtocolInfo{
Protocol: protocol,
SrcIP: srcIP,
DestIP: destIP,
SrcPort: srcPort,
DestPort: destPort,
OriginalConn: wrappedConn,
}
// Clear read timeout
if err := conn.SetReadDeadline(time.Time{}); err != nil {
return nil, conn, fmt.Errorf("failed to clear read deadline: %w", err)
}
return proxyInfo, wrappedConn, nil
}
// proxyProtocolConn wraps a connection to read from a custom reader
type proxyProtocolConn struct {
net.Conn
reader io.Reader
}
func (c *proxyProtocolConn) Read(b []byte) (int, error) {
return c.reader.Read(b)
}
// buildProxyProtocolHeaderFromInfo creates a PROXY protocol v1 header using ProxyProtocolInfo
func (p *SNIProxy) buildProxyProtocolHeaderFromInfo(proxyInfo *ProxyProtocolInfo, targetAddr net.Addr) string {
targetTCP, ok := targetAddr.(*net.TCPAddr)
if !ok {
// Fallback for unknown address types
return "PROXY UNKNOWN\r\n"
}
// Use the original client information from the PROXY protocol
var targetIP string
var protocol string
// Parse source IP to determine protocol family
srcIP := net.ParseIP(proxyInfo.SrcIP)
if srcIP == nil {
return "PROXY UNKNOWN\r\n"
}
if srcIP.To4() != nil {
// Source is IPv4, use TCP4 protocol
protocol = "TCP4"
if targetTCP.IP.To4() != nil {
// Target is also IPv4, use as-is
targetIP = targetTCP.IP.String()
} else {
// Target is IPv6, but we need IPv4 for consistent protocol family
if targetTCP.IP.IsLoopback() {
targetIP = "127.0.0.1"
} else {
targetIP = "127.0.0.1" // Safe fallback
}
}
} else {
// Source is IPv6, use TCP6 protocol
protocol = "TCP6"
if targetTCP.IP.To4() != nil {
// Target is IPv4, convert to IPv6 representation
targetIP = "::ffff:" + targetTCP.IP.String()
} else {
// Target is also IPv6, use as-is
targetIP = targetTCP.IP.String()
}
}
return fmt.Sprintf("PROXY %s %s %s %d %d\r\n",
protocol,
proxyInfo.SrcIP,
targetIP,
proxyInfo.SrcPort,
targetTCP.Port)
}
// buildProxyProtocolHeader creates a PROXY protocol v1 header // buildProxyProtocolHeader creates a PROXY protocol v1 header
func buildProxyProtocolHeader(clientAddr, targetAddr net.Addr) string { func buildProxyProtocolHeader(clientAddr, targetAddr net.Addr) string {
clientTCP, ok := clientAddr.(*net.TCPAddr) clientTCP, ok := clientAddr.(*net.TCPAddr)
@@ -131,7 +394,7 @@ func buildProxyProtocolHeader(clientAddr, targetAddr net.Addr) string {
} }
// NewSNIProxy creates a new SNI proxy instance // NewSNIProxy creates a new SNI proxy instance
func NewSNIProxy(port int, remoteConfigURL, publicKey, localProxyAddr string, localProxyPort int, localOverrides []string, proxyProtocol bool) (*SNIProxy, error) { func NewSNIProxy(port int, remoteConfigURL, publicKey, localProxyAddr string, localProxyPort int, localOverrides []string, proxyProtocol bool, trustedUpstreams []string) (*SNIProxy, error) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
// Create local overrides map // Create local overrides map
@@ -142,6 +405,22 @@ func NewSNIProxy(port int, remoteConfigURL, publicKey, localProxyAddr string, lo
} }
} }
// Create trusted upstreams map
trustedMap := make(map[string]struct{})
for _, upstream := range trustedUpstreams {
if upstream != "" {
// Add both the domain and potentially resolved IPs
trustedMap[upstream] = struct{}{}
// Try to resolve the domain to IPs and add them too
if ips, err := net.LookupIP(upstream); err == nil {
for _, ip := range ips {
trustedMap[ip.String()] = struct{}{}
}
}
}
}
proxy := &SNIProxy{ proxy := &SNIProxy{
port: port, port: port,
cache: cache.New(3*time.Second, 10*time.Minute), cache: cache.New(3*time.Second, 10*time.Minute),
@@ -155,6 +434,21 @@ func NewSNIProxy(port int, remoteConfigURL, publicKey, localProxyAddr string, lo
localSNIs: make(map[string]struct{}), localSNIs: make(map[string]struct{}),
localOverrides: overridesMap, localOverrides: overridesMap,
activeTunnels: make(map[string]*activeTunnel), activeTunnels: make(map[string]*activeTunnel),
trustedUpstreams: trustedMap,
httpClient: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
bufferPool: &sync.Pool{
New: func() interface{} {
buf := make([]byte, 32*1024)
return &buf
},
},
} }
return proxy, nil return proxy, nil
@@ -218,8 +512,46 @@ func (p *SNIProxy) acceptConnections() {
} }
} }
if p.activeConnections.Load() >= maxSNIConnections {
logger.Debug("Max concurrent SNI connections (%d) reached, rejecting connection from %s", maxSNIConnections, conn.RemoteAddr())
metrics.RecordSNIConnection("rejected_max_connections")
conn.Close()
continue
}
remoteHost, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
remoteHost = conn.RemoteAddr().String()
}
counterVal, _ := p.perIPConnections.LoadOrStore(remoteHost, new(atomic.Int64))
perIPCounter := counterVal.(*atomic.Int64)
if perIPCounter.Load() >= maxSNIConnectionsPerIP {
logger.Debug("Max concurrent SNI connections per IP (%d) reached for %s, rejecting connection", maxSNIConnectionsPerIP, remoteHost)
metrics.RecordSNIConnection("rejected_per_ip_limit")
conn.Close()
continue
}
perIPCounter.Add(1)
p.activeConnections.Add(1)
metrics.RecordSNIActiveConnection(1)
p.wg.Add(1) p.wg.Add(1)
go p.handleConnection(conn) go func() {
defer func() {
if perIPCounter.Add(-1) == 0 {
// Best-effort cleanup: only remove the map entry if it
// still holds this exact counter (a concurrent new
// connection from the same IP may have already bumped
// it back up via LoadOrStore, or replaced it after a
// prior race). Undercounting in that narrow race window
// just means one connection isn't rate-limited briefly,
// never unbounded growth.
p.perIPConnections.CompareAndDelete(remoteHost, counterVal)
}
}()
p.handleConnection(conn)
}()
} }
} }
@@ -267,21 +599,52 @@ func (p *SNIProxy) extractSNI(conn net.Conn) (string, io.Reader, error) {
func (p *SNIProxy) handleConnection(clientConn net.Conn) { func (p *SNIProxy) handleConnection(clientConn net.Conn) {
defer p.wg.Done() defer p.wg.Done()
defer clientConn.Close() defer clientConn.Close()
defer func() {
p.activeConnections.Add(-1)
metrics.RecordSNIActiveConnection(-1)
}()
metrics.RecordSNIConnection("accepted")
logger.Debug("Accepted connection from %s", clientConn.RemoteAddr()) logger.Debug("Accepted connection from %s", clientConn.RemoteAddr())
// Check for PROXY protocol from trusted upstream
var proxyInfo *ProxyProtocolInfo
var actualClientConn net.Conn = clientConn
if len(p.trustedUpstreams) > 0 {
var err error
proxyInfo, actualClientConn, err = p.parseProxyProtocolHeader(clientConn)
if err != nil {
metrics.RecordSNIProxyProtocolParseError()
logger.Debug("Failed to parse PROXY protocol: %v", err)
return
}
if proxyInfo != nil {
metrics.RecordSNITrustedProxyEvent("proxy_protocol_parsed")
logger.Debug("Received PROXY protocol from trusted upstream: %s:%d -> %s:%d",
proxyInfo.SrcIP, proxyInfo.SrcPort, proxyInfo.DestIP, proxyInfo.DestPort)
} else {
// No PROXY protocol detected, but connection is from trusted upstream
// This is fine - treat as regular connection
logger.Debug("No PROXY protocol detected from trusted upstream, treating as regular connection")
}
}
// Set read timeout for SNI extraction // Set read timeout for SNI extraction
if err := clientConn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { if err := actualClientConn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
logger.Debug("Failed to set read deadline: %v", err) logger.Debug("Failed to set read deadline: %v", err)
return return
} }
// Extract SNI hostname // Extract SNI hostname
hostname, clientReader, err := p.extractSNI(clientConn) clientHelloStart := time.Now()
hostname, clientReader, err := p.extractSNI(actualClientConn)
if err != nil { if err != nil {
logger.Debug("SNI extraction failed: %v", err) logger.Debug("SNI extraction failed: %v", err)
return return
} }
metrics.RecordProxyTLSHandshake(time.Since(clientHelloStart).Seconds())
if hostname == "" { if hostname == "" {
log.Println("No SNI hostname found") log.Println("No SNI hostname found")
@@ -291,13 +654,20 @@ func (p *SNIProxy) handleConnection(clientConn net.Conn) {
logger.Debug("SNI hostname detected: %s", hostname) logger.Debug("SNI hostname detected: %s", hostname)
// Remove read timeout for normal operation // Remove read timeout for normal operation
if err := clientConn.SetReadDeadline(time.Time{}); err != nil { if err := actualClientConn.SetReadDeadline(time.Time{}); err != nil {
logger.Debug("Failed to clear read deadline: %v", err) logger.Debug("Failed to clear read deadline: %v", err)
return return
} }
// Get routing information // Get routing information - use original client address if available from PROXY protocol
route, err := p.getRoute(hostname, clientConn.RemoteAddr().String()) var clientAddrStr string
if proxyInfo != nil {
clientAddrStr = fmt.Sprintf("%s:%d", proxyInfo.SrcIP, proxyInfo.SrcPort)
} else {
clientAddrStr = clientConn.RemoteAddr().String()
}
route, err := p.getRoute(hostname, clientAddrStr)
if err != nil { if err != nil {
logger.Debug("Failed to get route for %s: %v", hostname, err) logger.Debug("Failed to get route for %s: %v", hostname, err)
return return
@@ -322,10 +692,19 @@ func (p *SNIProxy) handleConnection(clientConn net.Conn) {
defer targetConn.Close() defer targetConn.Close()
logger.Debug("Connected to target: %s:%d", route.TargetHost, route.TargetPort) logger.Debug("Connected to target: %s:%d", route.TargetHost, route.TargetPort)
metrics.RecordActiveProxyConnection(1)
defer metrics.RecordActiveProxyConnection(-1)
// Send PROXY protocol header if enabled // Send PROXY protocol header if enabled
if p.proxyProtocol { if p.proxyProtocol {
proxyHeader := buildProxyProtocolHeader(clientConn.RemoteAddr(), targetConn.LocalAddr()) var proxyHeader string
if proxyInfo != nil {
// Use original client info from PROXY protocol
proxyHeader = p.buildProxyProtocolHeaderFromInfo(proxyInfo, targetConn.LocalAddr())
} else {
// Use direct client connection info
proxyHeader = buildProxyProtocolHeader(clientConn.RemoteAddr(), targetConn.LocalAddr())
}
logger.Debug("Sending PROXY protocol header: %s", strings.TrimSpace(proxyHeader)) logger.Debug("Sending PROXY protocol header: %s", strings.TrimSpace(proxyHeader))
if _, err := targetConn.Write([]byte(proxyHeader)); err != nil { if _, err := targetConn.Write([]byte(proxyHeader)); err != nil {
@@ -341,7 +720,7 @@ func (p *SNIProxy) handleConnection(clientConn net.Conn) {
tunnel = &activeTunnel{} tunnel = &activeTunnel{}
p.activeTunnels[hostname] = tunnel p.activeTunnels[hostname] = tunnel
} }
tunnel.conns = append(tunnel.conns, clientConn) tunnel.conns = append(tunnel.conns, actualClientConn)
p.activeTunnelsLock.Unlock() p.activeTunnelsLock.Unlock()
defer func() { defer func() {
@@ -350,7 +729,7 @@ func (p *SNIProxy) handleConnection(clientConn net.Conn) {
if tunnel, ok := p.activeTunnels[hostname]; ok { if tunnel, ok := p.activeTunnels[hostname]; ok {
newConns := make([]net.Conn, 0, len(tunnel.conns)) newConns := make([]net.Conn, 0, len(tunnel.conns))
for _, c := range tunnel.conns { for _, c := range tunnel.conns {
if c != clientConn { if c != actualClientConn {
newConns = append(newConns, c) newConns = append(newConns, c)
} }
} }
@@ -364,7 +743,7 @@ func (p *SNIProxy) handleConnection(clientConn net.Conn) {
}() }()
// Start bidirectional data transfer // Start bidirectional data transfer
p.pipe(clientConn, targetConn, clientReader) p.pipe(hostname, actualClientConn, targetConn, clientReader)
} }
// getRoute retrieves routing information for a hostname // getRoute retrieves routing information for a hostname
@@ -372,6 +751,7 @@ func (p *SNIProxy) getRoute(hostname, clientAddr string) (*RouteRecord, error) {
// Check local overrides first // Check local overrides first
if _, isOverride := p.localOverrides[hostname]; isOverride { if _, isOverride := p.localOverrides[hostname]; isOverride {
logger.Debug("Local override matched for hostname: %s", hostname) logger.Debug("Local override matched for hostname: %s", hostname)
metrics.RecordProxyRouteLookup("local_override")
return &RouteRecord{ return &RouteRecord{
Hostname: hostname, Hostname: hostname,
TargetHost: p.localProxyAddr, TargetHost: p.localProxyAddr,
@@ -384,6 +764,7 @@ func (p *SNIProxy) getRoute(hostname, clientAddr string) (*RouteRecord, error) {
_, isLocal := p.localSNIs[hostname] _, isLocal := p.localSNIs[hostname]
p.localSNIsLock.RUnlock() p.localSNIsLock.RUnlock()
if isLocal { if isLocal {
metrics.RecordProxyRouteLookup("local")
return &RouteRecord{ return &RouteRecord{
Hostname: hostname, Hostname: hostname,
TargetHost: p.localProxyAddr, TargetHost: p.localProxyAddr,
@@ -394,13 +775,16 @@ func (p *SNIProxy) getRoute(hostname, clientAddr string) (*RouteRecord, error) {
// Check cache first // Check cache first
if cached, found := p.cache.Get(hostname); found { if cached, found := p.cache.Get(hostname); found {
if cached == nil { if cached == nil {
metrics.RecordProxyRouteLookup("cached_not_found")
return nil, nil // Cached negative result return nil, nil // Cached negative result
} }
logger.Debug("Cache hit for hostname: %s", hostname) logger.Debug("Cache hit for hostname: %s", hostname)
metrics.RecordProxyRouteLookup("cache_hit")
return cached.(*RouteRecord), nil return cached.(*RouteRecord), nil
} }
logger.Debug("Cache miss for hostname: %s, querying API", hostname) logger.Debug("Cache miss for hostname: %s, querying API", hostname)
metrics.RecordProxyRouteLookup("cache_miss")
// Query API with timeout // Query API with timeout
ctx, cancel := context.WithTimeout(p.ctx, 5*time.Second) ctx, cancel := context.WithTimeout(p.ctx, 5*time.Second)
@@ -428,22 +812,28 @@ func (p *SNIProxy) getRoute(hostname, clientAddr string) (*RouteRecord, error) {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
// Make HTTP request // Make HTTP request
client := &http.Client{Timeout: 5 * time.Second} apiStart := time.Now()
resp, err := client.Do(req) // Make HTTP request using reusable client
resp, err := p.httpClient.Do(req)
if err != nil { if err != nil {
metrics.RecordSNIRouteAPIRequest("error")
return nil, fmt.Errorf("API request failed: %w", err) return nil, fmt.Errorf("API request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
metrics.RecordSNIRouteAPILatency(time.Since(apiStart).Seconds())
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
metrics.RecordSNIRouteAPIRequest("not_found")
// Cache negative result for shorter time (1 minute) // Cache negative result for shorter time (1 minute)
p.cache.Set(hostname, nil, 1*time.Minute) p.cache.Set(hostname, nil, 1*time.Minute)
return nil, nil return nil, nil
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
metrics.RecordSNIRouteAPIRequest("error")
return nil, fmt.Errorf("API returned status %d", resp.StatusCode) return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
} }
metrics.RecordSNIRouteAPIRequest("success")
// Parse response // Parse response
var apiResponse RouteAPIResponse var apiResponse RouteAPIResponse
@@ -499,23 +889,53 @@ func (p *SNIProxy) selectStickyEndpoint(clientAddr string, endpoints []string) s
return endpoints[index] return endpoints[index]
} }
// bufferedReader hides any io.WriterTo the wrapped reader implements (e.g.
// io.MultiReader, used by peekClientHello to replay the buffered
// ClientHello ahead of the raw connection). Without this, io.CopyBuffer
// bypasses the caller-supplied buffer entirely and lets WriteTo drive its
// own, uncapped allocations - defeating the point of bufferPool.
type bufferedReader struct {
io.Reader
}
// bufferedWriter hides any io.ReaderFrom the wrapped writer implements (e.g.
// *net.TCPConn's splice/sendfile fast path). Without this, io.CopyBuffer
// bypasses the caller-supplied buffer here too, so each copy allocates and
// manages its own buffer regardless of what's pooled.
type bufferedWriter struct {
io.Writer
}
// pipe handles bidirectional data transfer between connections // pipe handles bidirectional data transfer between connections
func (p *SNIProxy) pipe(clientConn, targetConn net.Conn, clientReader io.Reader) { func (p *SNIProxy) pipe(hostname string, clientConn, targetConn net.Conn, clientReader io.Reader) {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
// closeOnce ensures we only close connections once
var closeOnce sync.Once
closeConns := func() {
closeOnce.Do(func() {
// Close both connections to unblock any pending reads
clientConn.Close()
targetConn.Close()
})
}
// Copy data from client to target (using the buffered reader) // Copy data from client to target (using the buffered reader)
go func() { go func() {
defer wg.Done() defer wg.Done()
defer closeConns()
// Get buffer from pool and return when done
bufPtr := p.bufferPool.Get().(*[]byte)
defer func() { defer func() {
if tcpConn, ok := targetConn.(*net.TCPConn); ok { // Clear buffer before returning to pool to prevent data leakage
tcpConn.CloseWrite() clear(*bufPtr)
} p.bufferPool.Put(bufPtr)
}() }()
// Use a large buffer for better performance bytesCopied, err := io.CopyBuffer(bufferedWriter{targetConn}, bufferedReader{clientReader}, *bufPtr)
buf := make([]byte, 32*1024) metrics.RecordProxyBytesTransmitted("client_to_target", bytesCopied)
_, err := io.CopyBuffer(targetConn, clientReader, buf)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
logger.Debug("Copy client->target error: %v", err) logger.Debug("Copy client->target error: %v", err)
} }
@@ -524,15 +944,18 @@ func (p *SNIProxy) pipe(clientConn, targetConn net.Conn, clientReader io.Reader)
// Copy data from target to client // Copy data from target to client
go func() { go func() {
defer wg.Done() defer wg.Done()
defer closeConns()
// Get buffer from pool and return when done
bufPtr := p.bufferPool.Get().(*[]byte)
defer func() { defer func() {
if tcpConn, ok := clientConn.(*net.TCPConn); ok { // Clear buffer before returning to pool to prevent data leakage
tcpConn.CloseWrite() clear(*bufPtr)
} p.bufferPool.Put(bufPtr)
}() }()
// Use a large buffer for better performance bytesCopied, err := io.CopyBuffer(bufferedWriter{clientConn}, bufferedReader{targetConn}, *bufPtr)
buf := make([]byte, 32*1024) metrics.RecordProxyBytesTransmitted("target_to_client", bytesCopied)
_, err := io.CopyBuffer(clientConn, targetConn, buf)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
logger.Debug("Copy target->client error: %v", err) logger.Debug("Copy target->client error: %v", err)
} }

View File

@@ -76,3 +76,44 @@ func TestBuildProxyProtocolHeaderUnknownType(t *testing.T) {
t.Errorf("Expected %q, got %q", expected, result) t.Errorf("Expected %q, got %q", expected, result)
} }
} }
func TestBuildProxyProtocolHeaderFromInfo(t *testing.T) {
proxy, err := NewSNIProxy(8443, "", "", "127.0.0.1", 443, nil, true, nil)
if err != nil {
t.Fatalf("Failed to create SNI proxy: %v", err)
}
// Test IPv4 case
proxyInfo := &ProxyProtocolInfo{
Protocol: "TCP4",
SrcIP: "10.0.0.1",
DestIP: "192.168.1.100",
SrcPort: 12345,
DestPort: 443,
}
targetAddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:8080")
header := proxy.buildProxyProtocolHeaderFromInfo(proxyInfo, targetAddr)
expected := "PROXY TCP4 10.0.0.1 127.0.0.1 12345 8080\r\n"
if header != expected {
t.Errorf("Expected header '%s', got '%s'", expected, header)
}
// Test IPv6 case
proxyInfo = &ProxyProtocolInfo{
Protocol: "TCP6",
SrcIP: "2001:db8::1",
DestIP: "2001:db8::2",
SrcPort: 12345,
DestPort: 443,
}
targetAddr, _ = net.ResolveTCPAddr("tcp6", "[::1]:8080")
header = proxy.buildProxyProtocolHeaderFromInfo(proxyInfo, targetAddr)
expected = "PROXY TCP6 2001:db8::1 ::1 12345 8080\r\n"
if header != expected {
t.Errorf("Expected header '%s', got '%s'", expected, header)
}
}

File diff suppressed because it is too large Load Diff