Compare commits

...

151 Commits

Author SHA1 Message Date
Owen
5527bff671 Merge branch 'dev' 2026-02-06 15:17:21 -08:00
Owen
af973b2440 Support prt records 2026-02-06 15:17:01 -08:00
Owen
dd9bff9a4b Fix peer names clearing 2026-02-02 18:03:29 -08:00
Owen
1be5e454ba Default override dns to true
Ref #59
2026-02-02 10:03:22 -08:00
Owen
4850b1b332 Handle cross platform close
Former-commit-id: 89932bb736c7f4b3eb9bb2384b0cf6bd27872c1c
2026-01-31 17:50:31 -08:00
Owen
1ff74f7173 Dont go unregistered when low power mode
Former-commit-id: f55fc8fb39f8efc9d5438465f655dc2d734223c3
2026-01-31 17:15:30 -08:00
Owen
4a25a0d413 Dont go unregistered when low power mode
Former-commit-id: 0938564038
2026-01-31 16:58:05 -08:00
Owen
7fc3c7088e Lowercase all domains before matching
Former-commit-id: 8f8872aa47
2026-01-30 14:53:25 -08:00
Owen
1869e70894 Merge branch 'dev'
Former-commit-id: 43cc56a961
2026-01-30 10:58:00 -08:00
Owen
79783cc3dc Merge branch 'main' of github.com:fosrl/olm
Former-commit-id: 0b31f4e5d1
2026-01-30 10:57:40 -08:00
Owen
584298e3bd Fix terminate due to inactivity 2026-01-27 20:19:41 -08:00
miloschwartz
f683afa647 improve override-dns and tunnel-dns descriptions 2026-01-27 17:53:34 -08:00
Owen
ba2631d388 Prevent crashing on close before connect
Former-commit-id: ea461e0bfb
2026-01-23 14:47:54 -08:00
Owen Schwartz
6ae4e2b691 Merge pull request #87 from fosrl/dev
1.4.0

Former-commit-id: 1212217421
2026-01-23 10:25:03 -08:00
Owen
51eee9dcf5 Bump newt
Former-commit-id: f4885e9c4d
2026-01-23 10:23:42 -08:00
Owen
660e9e0e35 Merge branch 'main' into dev
Former-commit-id: b5580036d3
2026-01-23 10:22:21 -08:00
Owen
4ef6089053 Comment out local newt
Former-commit-id: c4ef1e724e
2026-01-23 10:19:38 -08:00
Owen
c4e297cc96 Handle properly stopping and starting the ping
Former-commit-id: 34c7717767
2026-01-20 11:30:06 -08:00
Owen
e3f5497176 Add stale bot
Former-commit-id: 313dee9ba8
2026-01-19 17:12:15 -08:00
dependabot[bot]
6a5dcc01a6 Bump actions/checkout from 5.0.0 to 6.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 5.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](08c6903cd8...8e8c483db8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: e19b33e2fa
2026-01-19 17:08:10 -08:00
dependabot[bot]
18b6d3bb0f Bump the patch-updates group across 1 directory with 3 updates
Bumps the patch-updates group with 3 updates in the / directory: [github.com/fosrl/newt](https://github.com/fosrl/newt), [github.com/godbus/dbus/v5](https://github.com/godbus/dbus) and [github.com/miekg/dns](https://github.com/miekg/dns).


Updates `github.com/fosrl/newt` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/fosrl/newt/releases)
- [Commits](https://github.com/fosrl/newt/compare/1.8.0...1.8.1)

Updates `github.com/godbus/dbus/v5` from 5.2.0 to 5.2.2
- [Release notes](https://github.com/godbus/dbus/releases)
- [Commits](https://github.com/godbus/dbus/compare/v5.2.0...v5.2.2)

Updates `github.com/miekg/dns` from 1.1.68 to 1.1.70
- [Commits](https://github.com/miekg/dns/compare/v1.1.68...v1.1.70)

---
updated-dependencies:
- dependency-name: github.com/fosrl/newt
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-updates
- dependency-name: github.com/godbus/dbus/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-updates
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.70
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: 69f25032cb
2026-01-19 17:08:00 -08:00
dependabot[bot]
ccbfdc5265 Bump docker/metadata-action from 5.9.0 to 5.10.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.9.0 to 5.10.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](318604b99e...c299e40c65)

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

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: 225779c665
2026-01-19 17:06:44 -08:00
dependabot[bot]
ab04537278 Bump softprops/action-gh-release from 2.4.2 to 2.5.0
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.4.2 to 2.5.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](5be0e66d93...a06a81a03e)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: a7f029e232
2026-01-19 17:06:23 -08:00
dependabot[bot]
29c36c9837 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>
Former-commit-id: af4e74de81
2026-01-19 17:05:50 -08:00
dependabot[bot]
c47e9bf547 Bump actions/cache from 4.3.0 to 5.0.2
Bumps [actions/cache](https://github.com/actions/cache) from 4.3.0 to 5.0.2.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](0057852bfa...8b402f58fb)

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

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: f87d043d59
2026-01-19 17:05:42 -08:00
dependabot[bot]
abb682c935 Bump the minor-updates group across 1 directory with 2 updates
Bumps the minor-updates group with 2 updates in the / directory: [golang.org/x/sys](https://github.com/golang/sys) and software.sslmate.com/src/go-pkcs12.


Updates `golang.org/x/sys` from 0.38.0 to 0.40.0
- [Commits](https://github.com/golang/sys/compare/v0.38.0...v0.40.0)

Updates `software.sslmate.com/src/go-pkcs12` from 0.6.0 to 0.7.0

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: software.sslmate.com/src/go-pkcs12
  dependency-version: 0.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: ae1436c5d1
2026-01-19 17:05:19 -08:00
Owen
79e8a4a8bb Dont start holepunching if we rebind while in low power mode
Former-commit-id: 4a5ebd41f3
2026-01-19 15:57:20 -08:00
Owen
f2e81c024a Set fingerprint earlier
Former-commit-id: ef36f7ca82
2026-01-19 15:05:29 -08:00
Owen
6d10650e70 Send an initial ping so we get online faster in the dashboard
Former-commit-id: 41e4eb24a2
2026-01-18 15:14:11 -08:00
Owen
a81c683c66 Reorder websocket disconnect message
Former-commit-id: 592a0d60c6
2026-01-18 14:49:42 -08:00
Owen
25cb50901e Quiet up logs again
Former-commit-id: 112283191c
2026-01-18 12:18:48 -08:00
Owen
a8e0844758 Send disconnecting message when stopping
Former-commit-id: 1fb6e2a00d
2026-01-18 11:55:09 -08:00
Owen
8b9ee6f26a Move power mode to the api from signal
Former-commit-id: 5d8ea92ef0
2026-01-18 11:46:18 -08:00
Owen
82e8fcc3a7 Merge branch 'bubble-errors-up' into dev
Former-commit-id: 61846f9ec4
2026-01-18 11:38:20 -08:00
Owen
e2b7777ba7 Merge branch 'rebind' into dev
Former-commit-id: 2139aeaa85
2026-01-18 11:37:43 -08:00
Owen
4e4d1a39f6 Try to close the socket first
Former-commit-id: ed4775bd26
2026-01-17 17:35:00 -08:00
Owen
17dc1b0be1 Dont start the ping until we are connected
Former-commit-id: 43c8a14fda
2026-01-17 17:32:01 -08:00
Owen
a06436eeab Add rebind endpoints for the shared socket
Former-commit-id: 6fd0984b13
2026-01-17 17:05:29 -08:00
Lokowitz
a83cc2a3a3 clean up dependabot
Former-commit-id: a37f0514c4
2026-01-17 15:43:06 -08:00
Lokowitz
d56537d0fd add docker build dev
Former-commit-id: b983216808
2026-01-17 15:43:06 -08:00
Lokowitz
31bb483e40 add qemu
Former-commit-id: 172eb97aa1
2026-01-17 15:43:06 -08:00
Lokowitz
cd91ae6e3a update test
Former-commit-id: b034f81ed9
2026-01-17 15:43:06 -08:00
Lokowitz
a9ec1e61d3 fix test
Former-commit-id: 076d01b48c
2026-01-17 15:43:06 -08:00
Owen
a13010c4af Update docs for metadata
Former-commit-id: 9d77a1daf7
2026-01-16 17:33:40 -08:00
Owen
cfac3cdd53 Use the right duration
Former-commit-id: c921f08bd5
2026-01-16 15:17:41 -08:00
Owen
5ecba61718 Use the right duration
Former-commit-id: 352b122166
2026-01-16 15:17:20 -08:00
Owen
2ea12ce258 Set the error on terminate as well
Former-commit-id: 8ff58e6efc
2026-01-16 14:59:13 -08:00
Owen
0b46289136 Add error can be sent from cloud to display in api
Former-commit-id: 2167f22713
2026-01-16 14:19:02 -08:00
Owen
71044165d0 Include fingerprint and posture info in ping
Former-commit-id: f061596e5b
2026-01-16 12:16:51 -08:00
Owen
eafd816159 Clean up log messages
Former-commit-id: 0231591f36
2026-01-16 12:02:02 -08:00
Owen
e1a687407e Set the ping inteval to 30 seconds
Former-commit-id: 737ffca15d
2026-01-15 21:59:18 -08:00
Owen
bd8031651e Message syncing works
Former-commit-id: 1650624a55
2026-01-15 21:25:53 -08:00
Owen
a63439543d Merge branch 'dev' into msg-delivery
Former-commit-id: d6b9170e79
2026-01-15 16:41:00 -08:00
Owen
90cd6e7f6e Merge branch 'power-state' into dev
Former-commit-id: e2a071e6dc
2026-01-15 16:39:41 -08:00
Owen
ea4a63c9b3 Merge branch 'dev' of github.com:fosrl/olm into dev
Former-commit-id: 1c21071ee1
2026-01-15 16:37:09 -08:00
Owen
e047330ffd Handle and test config version bugs
Former-commit-id: 285f8ce530
2026-01-15 16:36:11 -08:00
Owen
9dcc0796a6 Small clean up and move ping to client.go
Former-commit-id: af33218792
2026-01-15 14:20:12 -08:00
Varun Narravula
4b6999e06a feat(ping): send fingerprint and posture checks as part of ping/register
Former-commit-id: 70a7e83291
2026-01-15 12:13:36 -08:00
Varun Narravula
69952ee5c5 feat(api): add fingerprint + posture fields to client state
Former-commit-id: 566084683a
2026-01-15 12:13:36 -08:00
Owen
3710880ce0 Merge branch 'power-state' into msg-delivery
Former-commit-id: bda6606098
2026-01-14 17:51:42 -08:00
Owen
17b75bf58f Dont get token each time
Former-commit-id: 07dfc651f1
2026-01-14 16:51:04 -08:00
Owen
3ba1714524 Power state getting set correctly
Former-commit-id: 0895156efd
2026-01-14 16:38:40 -08:00
Owen
3470da76fc Update resetting intervals
Former-commit-id: 303c2dc0b7
2026-01-14 12:32:29 -08:00
Owen
c86df2c041 Refactor operation
Former-commit-id: 4f09d122bb
2026-01-14 11:58:12 -08:00
Owen
0e8315b149 Merge branch 'dev' into power-state
Former-commit-id: e9728efee3
2026-01-14 11:19:46 -08:00
Owen
2ab9790588 Reduce the pings
Former-commit-id: 5c6ad1ea75
2026-01-14 11:12:10 -08:00
Owen
1ecb97306f Add back AddDevice function
Former-commit-id: cae0ffa2e1
2026-01-13 21:38:37 -08:00
Varun Narravula
15e96a779c refactor(olm): convert global state into an olm instance
Former-commit-id: b755f77d95
2026-01-13 20:52:10 -08:00
miloschwartz
dada0cc124 add low power state for testing
Former-commit-id: 996fe59999
2026-01-13 14:30:02 -08:00
Owen
9c0b4fcd5f Fix error checking
Former-commit-id: 231808476b
2026-01-13 11:51:51 -08:00
Owen
8a788ef238 Merge branch 'dev' of github.com:fosrl/olm into dev
Former-commit-id: 8c5c8d3966
2026-01-12 17:12:45 -08:00
Owen
20e0c18845 Try to reduce cpu when idle
Former-commit-id: ba91478b89
2026-01-12 12:29:42 -08:00
Owen
5b637bb4ca Add expo backoff
Former-commit-id: faae551aca
2026-01-12 12:20:59 -08:00
Varun Narravula
c565a46a6f feat(logger): configure log file path thorugh global options
Former-commit-id: 577d89f4fb
2026-01-11 13:49:39 -08:00
Varun Narravula
7b7eae617a chore: format files using gofmt
Former-commit-id: 5cfa0dfb97
2026-01-11 13:49:39 -08:00
miloschwartz
1ed27fec1a set mtu to 0 on darwin
Former-commit-id: fbe686961e
2026-01-01 17:38:01 -05:00
Owen
83edde3449 Fix build on darwin
Former-commit-id: fbeb5be88d
2025-12-31 18:01:25 -05:00
Owen
1b43f029a9 Dont pass in dns proxy to override
Former-commit-id: 51dd927f9b
2025-12-31 15:42:51 -05:00
Owen
aeb908b68c Exiting the middle device works now?
Former-commit-id: d76b3c366f
2025-12-31 11:33:00 -05:00
Owen
f08b17c7bd Middle device working but not closing
Former-commit-id: c85fcc434b
2025-12-31 11:22:09 -05:00
Owen
cce8742490 Try to make the tun replacable
Former-commit-id: 6be0958887
2025-12-30 21:38:07 -05:00
Owen
c56696bab1 Use a different method on android
Former-commit-id: adf4c21f7b
2025-12-30 16:59:36 -05:00
Owen
7bb004cf50 Update docs
Former-commit-id: 543ca05eb9
2025-12-29 22:15:01 -05:00
Owen
28910ce188 Add stub
Former-commit-id: ece4239aaa
2025-12-29 17:50:15 -05:00
miloschwartz
f8dc134210 add content-length header to status payload
Former-commit-id: 8152d4133f
2025-12-29 17:28:12 -05:00
Varun Narravula
148f5fde23 fix(ci): add back missing docker build local image rule
Former-commit-id: 6d2afb4c72
2025-12-24 10:08:40 -05:00
Owen
b76259bc31 Add sync message
Former-commit-id: d01f180941
2025-12-24 10:06:25 -05:00
Owen
88cc57bcef Update mod
Former-commit-id: 1b474ebc1c
2025-12-23 18:00:15 -05:00
Owen
385c64c364 Dont run on v tags
Former-commit-id: 69a00b6231
2025-12-23 17:54:04 -05:00
Owen
0b05497c25 Merge branch 'dev' into msg-delivery
Former-commit-id: 4deb3e07b0
2025-12-23 15:44:02 -05:00
Owen
4e3e824276 Fix latest
Former-commit-id: 6fcd8ac6cb
2025-12-22 21:32:59 -05:00
Owen
effc1a31ac Update readme
Former-commit-id: 44282226b4
2025-12-22 17:24:51 -05:00
Owen
03051a37fe Update mod
Former-commit-id: ca5105b6b2
2025-12-22 16:19:45 -05:00
Owen
8cf2a28b6f Merge branch 'main' into dev
Former-commit-id: 075daed0a0
2025-12-22 14:05:04 -05:00
Owen
9f3422de1b Parallel the go build
Former-commit-id: aee6f24001
2025-12-22 14:02:18 -05:00
Owen
e6d0e9bb13 Update test
Former-commit-id: 91c9c48507
2025-12-21 21:07:26 -05:00
Owen
da0ad21fd4 Update test
Former-commit-id: 449e631aae
2025-12-21 21:07:14 -05:00
Owen
2940f16f19 Build binaries and do release
Former-commit-id: 2813de80ff
2025-12-21 21:04:54 -05:00
Owen
44c8d871c2 Build binaries and do release
Former-commit-id: 8aaefde72a
2025-12-21 21:04:24 -05:00
Owen
96a88057f9 Update mod
Former-commit-id: b026bea86e
2025-12-21 21:03:48 -05:00
Owen
d96fe6391e Remove replace
Former-commit-id: 5551eff130
2025-12-21 21:03:48 -05:00
Owen
fe7fd31955 Sending DNS over the tunnel works
Former-commit-id: ca763fff2d
2025-12-21 21:03:48 -05:00
Owen
86b19f243e Remove exit nodes from HPing if peers are removed
Former-commit-id: 0c96d3c25c
2025-12-21 21:03:48 -05:00
Owen
d0940d03c4 Cleanup unclean shutdown cross platform
Former-commit-id: c1a2efd9d2
2025-12-21 21:03:48 -05:00
Owen
5a51753dbf Update mod
Former-commit-id: cebefa9800
2025-12-21 21:02:45 -05:00
Owen
70be82d68a Remove replace
Former-commit-id: 014eccaf62
2025-12-21 20:58:33 -05:00
Owen
dde79bb2dc Fix go mod
Former-commit-id: e355d8db5f
2025-12-21 20:57:20 -05:00
Owen
3822b1a065 Add version and send it down
Former-commit-id: 52273a81c8
2025-12-19 16:45:11 -05:00
Owen
8b68f00f59 Sending DNS over the tunnel works
Former-commit-id: 304174ca2f
2025-12-19 10:55:37 -05:00
Owen
fe197f0a0b Remove exit nodes from HPing if peers are removed
Former-commit-id: a4365988eb
2025-12-18 15:04:20 -05:00
Owen
675c934ce1 Cleanup unclean shutdown cross platform
Former-commit-id: de18c0cc6d
2025-12-18 11:33:59 -05:00
Owen Schwartz
708c761fa6 Merge pull request #63 from fosrl/wildcard-resources
Wildcard resources

Former-commit-id: ee8b93b13a
2025-12-16 21:50:22 -05:00
Owen
78dc6508a4 Support wildcard alias records
Former-commit-id: cec79bf014
2025-12-16 21:33:41 -05:00
Owen
7f6c824122 Pull 21820 from config
Former-commit-id: 56f4614899
2025-12-16 18:36:04 -05:00
Owen
9ba3569573 Remove acciential file
Former-commit-id: c3a12bd2a9
2025-12-11 23:26:21 -05:00
Owen
fd38f4cc59 Fix test
Former-commit-id: e0efe8f950
2025-12-11 23:25:56 -05:00
Owen
c5d5fcedd9 Make sure to process version first
Former-commit-id: f0309857b9
2025-12-11 19:37:51 -05:00
Owen
13c0a082b5 Update cicd
Former-commit-id: 5a6fcadf91
2025-12-11 16:40:55 -05:00
Owen
48962d4b65 Make cicd create draft
Former-commit-id: 6e31d3dcd5
2025-12-11 16:09:34 -05:00
Owen
c469707986 Update cicd to use right username
Former-commit-id: 41e6324c79
2025-12-11 16:04:25 -05:00
Owen
13c40f6b2c Update cicd
Former-commit-id: 5757e8dca8
2025-12-11 16:00:20 -05:00
Owen
6071be0d08 Merge branch 'dev'
Former-commit-id: 382515a85c
2025-12-11 15:40:51 -05:00
Owen
4b269782ea Update iss
Former-commit-id: 5da2198b35
2025-12-11 12:29:43 -05:00
Owen
518bf0e36a Update link
Former-commit-id: 14f7682be5
2025-12-10 21:03:17 -05:00
Owen
c80bb9740a Update readme
Former-commit-id: 0d27206b28
2025-12-10 16:23:58 -05:00
Owen
3ceef1ef74 Small adjustments
Former-commit-id: df1c2c18e0
2025-12-10 14:06:36 -05:00
Owen
acb0b4a9a5 Fix ipv6 connectivity
Former-commit-id: 61065def17
2025-12-10 10:34:30 -05:00
Owen
29aa68ecf7 Fix docker ignore
Former-commit-id: f24add4f72
2025-12-08 14:05:48 -05:00
Owen
50a97b19d1 Use explicit newt version not local
Former-commit-id: 630b55008b
2025-12-08 14:00:59 -05:00
Owen
229ce7504f Merge branch 'dev'
Former-commit-id: ead73ca1c5
2025-12-08 12:12:47 -05:00
Owen
b4f3619aff Update test
Former-commit-id: 25644db2f3
2025-12-08 12:12:35 -05:00
Owen Schwartz
e77a4fbd66 Merge pull request #57 from fosrl/dev
Add robust client connectivity support

Former-commit-id: 1f15ecc4b2
2025-12-08 12:05:52 -05:00
Owen
f8f368a981 Update readme
Former-commit-id: 1687099c52
2025-12-07 21:25:24 -05:00
Owen
153b986100 Adapt args to work on windows
Former-commit-id: 7546fc82ac
2025-12-07 17:44:10 -05:00
Owen
1c47c0981c Fix small bugs =
Former-commit-id: 02c838eb86
2025-12-07 12:05:27 -05:00
Owen
defd85e118 Add site name
Former-commit-id: 2a60de4f1f
2025-12-07 10:52:22 -05:00
Owen
ec1085f5f7 Merge branch 'main' into dev
Former-commit-id: b2814dc157
2025-12-06 21:09:44 -05:00
Owen Schwartz
a39e6d4f2b Merge pull request #54 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

Former-commit-id: a9efe7c91a
2025-12-06 12:01:08 -05:00
Owen Schwartz
4875835024 Merge pull request #55 from fosrl/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6

Former-commit-id: 50b9dae88c
2025-12-06 12:00:51 -05:00
Owen Schwartz
f5a74c36f8 Merge pull request #56 from fosrl/dependabot/docker/minor-updates-60be0b6e22
Bump alpine from 3.22 to 3.23 in the minor-updates group

Former-commit-id: 84257a094e
2025-12-06 12:00:38 -05:00
dependabot[bot]
3e24a77625 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>
Former-commit-id: a9423b01e6
2025-12-03 20:24:29 +00:00
dependabot[bot]
534631fb27 Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [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/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: bd9e8857bf
2025-11-24 20:46:59 +00:00
dependabot[bot]
f93f73f541 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>
Former-commit-id: 0fc7f22f1a
2025-11-19 20:29:21 +00:00
Owen Schwartz
b87f90c211 Merge pull request #48 from fosrl/dependabot/github_actions/actions/upload-artifact-5
Bump actions/upload-artifact from 4 to 5

Former-commit-id: f7f072b919
2025-11-14 10:28:22 -05:00
Owen Schwartz
3e0cefa3dc Merge pull request #53 from fosrl/dependabot/go_modules/prod-minor-updates-aca723b595
Bump the prod-minor-updates group across 1 directory with 2 updates

Former-commit-id: 616ab62bc1
2025-11-14 10:27:44 -05:00
Owen Schwartz
2fe3359ae8 Merge pull request #52 from fosrl/copilot/fix-system-path-removal
Fix Windows uninstaller deleting entire System PATH instead of OLM entry

Former-commit-id: 0398e68788
2025-11-14 10:27:30 -05:00
dependabot[bot]
0aa8f07be3 Bump the prod-minor-updates group across 1 directory with 2 updates
Bumps the prod-minor-updates group with 1 update in the / directory: [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)

Updates `golang.org/x/sys` from 0.37.0 to 0.38.0
- [Commits](https://github.com/golang/sys/compare/v0.37.0...v0.38.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
- dependency-name: golang.org/x/sys
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: e061955e90
2025-11-11 20:19:38 +00:00
copilot-swe-agent[bot]
36d47a7331 Refactor PATH removal to use TStringList for more robust parsing
Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>

Former-commit-id: 91f0230d21
2025-11-11 01:46:51 +00:00
copilot-swe-agent[bot]
10fa5acb0b Fix Windows PATH removal issue by implementing custom uninstall procedure
Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>

Former-commit-id: 1168f5541c
2025-11-11 01:44:40 +00:00
copilot-swe-agent[bot]
e3a679609f Initial plan
Former-commit-id: d910034ea1
2025-11-11 01:41:18 +00:00
dependabot[bot]
b7a04dc511 Bump actions/upload-artifact from 4 to 5
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Former-commit-id: 33e28ead68
2025-10-27 21:16:53 +00:00
48 changed files with 6264 additions and 1810 deletions

View File

@@ -1,9 +1,9 @@
.gitignore .gitignore
.dockerignore .dockerignore
olm
*.json *.json
README.md README.md
Makefile Makefile
public/ public/
LICENSE LICENSE
CONTRIBUTING.md CONTRIBUTING.md
bin/

View File

@@ -5,20 +5,10 @@ updates:
schedule: schedule:
interval: "daily" interval: "daily"
groups: groups:
dev-patch-updates: patch-updates:
dependency-type: "development"
update-types: update-types:
- "patch" - "patch"
dev-minor-updates: 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: update-types:
- "minor" - "minor"

View File

@@ -1,60 +1,615 @@
name: CI/CD Pipeline name: CI/CD Pipeline
permissions:
contents: write # gh-release
packages: write # GHCR push
id-token: write # Keyless-Signatures & Attestations
attestations: write # actions/attest-build-provenance
security-events: write # upload-sarif
actions: read
on: on:
push: push:
tags: tags:
- "*" - "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
workflow_dispatch:
inputs:
version:
description: "SemVer version to release (e.g., 1.2.3, no leading 'v')"
required: true
type: string
publish_latest:
description: "Also publish the 'latest' image tag"
required: true
type: boolean
default: false
publish_minor:
description: "Also publish the 'major.minor' image tag (e.g., 1.2)"
required: true
type: boolean
default: false
target_branch:
description: "Branch to tag"
required: false
default: "main"
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }}
cancel-in-progress: true
jobs: jobs:
release: prepare:
name: Build and Release if: github.event_name == 'workflow_dispatch'
runs-on: amd64-runner name: Prepare release (create tag)
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
steps: - name: Validate version input
- name: Checkout code shell: bash
uses: actions/checkout@v5 env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "Invalid version: $INPUT_VERSION (expected X.Y.Z or X.Y.Z-rc.N)" >&2
exit 1
fi
- name: Create and push tag
shell: bash
env:
TARGET_BRANCH: ${{ inputs.target_branch }}
VERSION: ${{ inputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch --prune origin
git checkout "$TARGET_BRANCH"
git pull --ff-only origin "$TARGET_BRANCH"
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "Tag $VERSION already exists" >&2
exit 1
fi
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "refs/tags/$VERSION"
release:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'github-actions[bot]') }}
name: Build and Release
runs-on: ubuntu-24.04
timeout-minutes: 120
env:
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
- name: Set up QEMU steps:
uses: docker/setup-qemu-action@v3 - name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Set up Docker Buildx - name: Capture created timestamp
uses: docker/setup-buildx-action@v3 run: echo "IMAGE_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
shell: bash
- name: Log in to Docker Hub - name: Set up QEMU
uses: docker/login-action@v3 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract tag name - name: Set up 1.2.0 Buildx
id: get-tag uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Install Go - name: Log in to Docker Hub
uses: actions/setup-go@v6 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
go-version: 1.25 registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Update version in main.go - name: Log in to GHCR
run: | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
TAG=${{ env.TAG }} with:
if [ -f main.go ]; then registry: ghcr.io
sed -i 's/version_replaceme/'"$TAG"'/' main.go username: ${{ github.actor }}
echo "Updated main.go with version $TAG" password: ${{ secrets.GITHUB_TOKEN }}
else
echo "main.go not found"
fi
- name: Build and push Docker images
run: |
TAG=${{ env.TAG }}
make docker-build-release tag=$TAG
- name: Build binaries - name: Normalize image names to lowercase
run: | run: |
make go-build-release set -euo pipefail
echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV"
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV"
shell: bash
- name: Upload artifacts from /bin - name: Extract tag name
uses: actions/upload-artifact@v4 env:
with: EVENT_NAME: ${{ github.event_name }}
name: binaries INPUT_VERSION: ${{ inputs.version }}
path: bin/ run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "TAG=${INPUT_VERSION}" >> $GITHUB_ENV
else
echo "TAG=${{ github.ref_name }}" >> $GITHUB_ENV
fi
shell: bash
- name: Validate pushed tag format (no leading 'v')
if: ${{ github.event_name == 'push' }}
shell: bash
env:
TAG_GOT: ${{ env.TAG }}
run: |
set -euo pipefail
if [[ "$TAG_GOT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "Tag OK: $TAG_GOT"
exit 0
fi
echo "ERROR: Tag '$TAG_GOT' is not allowed. Use 'X.Y.Z' or 'X.Y.Z-rc.N' (no leading 'v')." >&2
exit 1
- name: Wait for tag to be visible (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
set -euo pipefail
for i in {1..90}; do
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then
echo "Tag ${TAG} is visible on origin"; exit 0
fi
echo "Tag not yet visible, retrying... ($i/90)"
sleep 2
done
echo "Tag ${TAG} not visible after waiting"; exit 1
shell: bash
- 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
- name: Ensure repository is at the tagged commit (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
set -euo pipefail
git fetch --tags --force
git checkout "refs/tags/${TAG}"
echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}"
shell: bash
- name: Detect release candidate (rc)
run: |
set -euo pipefail
if [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "IS_RC=true" >> $GITHUB_ENV
else
echo "IS_RC=false" >> $GITHUB_ENV
fi
shell: bash
- name: Install Go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version-file: go.mod
- name: Resolve publish-latest flag
env:
EVENT_NAME: ${{ github.event_name }}
PL_INPUT: ${{ inputs.publish_latest }}
PL_VAR: ${{ vars.PUBLISH_LATEST }}
run: |
set -euo pipefail
val="false"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
if [ "${PL_INPUT}" = "true" ]; then val="true"; fi
else
if [ "${PL_VAR}" = "true" ]; then val="true"; fi
fi
echo "PUBLISH_LATEST=$val" >> $GITHUB_ENV
shell: bash
- name: Resolve publish-minor flag
env:
EVENT_NAME: ${{ github.event_name }}
PM_INPUT: ${{ inputs.publish_minor }}
PM_VAR: ${{ vars.PUBLISH_MINOR }}
run: |
set -euo pipefail
val="false"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
if [ "${PM_INPUT}" = "true" ]; then val="true"; fi
else
if [ "${PM_VAR}" = "true" ]; then val="true"; fi
fi
echo "PUBLISH_MINOR=$val" >> $GITHUB_ENV
shell: bash
- name: Cache Go modules
if: ${{ hashFiles('**/go.sum') != '' }}
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Go vet & test
if: ${{ hashFiles('**/go.mod') != '' }}
run: |
go version
go vet ./...
go test ./... -race -covermode=atomic
shell: bash
- name: Resolve license fallback
run: echo "IMAGE_LICENSE=${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}" >> $GITHUB_ENV
shell: bash
- name: Resolve registries list (GHCR always, Docker Hub only if creds)
shell: bash
run: |
set -euo pipefail
images="${GHCR_IMAGE}"
if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ secrets.DOCKER_HUB_USERNAME }}" ]; then
images="${images}\n${DOCKERHUB_IMAGE}"
fi
{
echo 'IMAGE_LIST<<EOF'
echo -e "$images"
echo 'EOF'
} >> "$GITHUB_ENV"
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.IMAGE_LIST }}
tags: |
type=semver,pattern={{version}},value=${{ env.TAG }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG }},enable=${{ env.PUBLISH_MINOR == 'true' && env.IS_RC != 'true' }}
type=raw,value=latest,enable=${{ env.IS_RC != 'true' }}
flavor: |
latest=false
labels: |
org.opencontainers.image.title=${{ github.event.repository.name }}
org.opencontainers.image.version=${{ env.TAG }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.event.repository.html_url }}
org.opencontainers.image.url=${{ github.event.repository.html_url }}
org.opencontainers.image.documentation=${{ github.event.repository.html_url }}
org.opencontainers.image.description=${{ github.event.repository.description }}
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
org.opencontainers.image.created=${{ env.IMAGE_CREATED }}
org.opencontainers.image.ref.name=${{ env.TAG }}
org.opencontainers.image.authors=${{ github.repository_owner }}
- name: Echo build config (non-secret)
shell: bash
env:
IMAGE_TITLE: ${{ github.event.repository.name }}
IMAGE_VERSION: ${{ env.TAG }}
IMAGE_REVISION: ${{ github.sha }}
IMAGE_SOURCE_URL: ${{ github.event.repository.html_url }}
IMAGE_URL: ${{ github.event.repository.html_url }}
IMAGE_DESCRIPTION: ${{ github.event.repository.description }}
IMAGE_LICENSE: ${{ env.IMAGE_LICENSE }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USERNAME }}
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
WORKFLOW_REF: ${{ github.workflow_ref }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
echo "=== OCI Label Values ==="
echo "org.opencontainers.image.title=${IMAGE_TITLE}"
echo "org.opencontainers.image.version=${IMAGE_VERSION}"
echo "org.opencontainers.image.revision=${IMAGE_REVISION}"
echo "org.opencontainers.image.source=${IMAGE_SOURCE_URL}"
echo "org.opencontainers.image.url=${IMAGE_URL}"
echo "org.opencontainers.image.description=${IMAGE_DESCRIPTION}"
echo "org.opencontainers.image.licenses=${IMAGE_LICENSE}"
echo
echo "=== Images ==="
echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE}"
echo "GHCR_IMAGE=${GHCR_IMAGE}"
echo "DOCKER_HUB_USERNAME=${DOCKER_HUB_USER}"
echo
echo "=== GitHub Kontext ==="
echo "repository=${REPO}"
echo "owner=${OWNER}"
echo "workflow_ref=${WORKFLOW_REF}"
echo "ref=${REF}"
echo "ref_name=${REF_NAME}"
echo "run_url=${RUN_URL}"
echo
echo "=== docker/metadata-action outputs (Tags/Labels), raw ==="
echo "::group::tags"
echo "${{ steps.meta.outputs.tags }}"
echo "::endgroup::"
echo "::group::labels"
echo "${{ steps.meta.outputs.labels }}"
echo "::endgroup::"
- name: Build and push (Docker Hub + GHCR)
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ github.repository }}
cache-to: type=gha,mode=max,scope=${{ github.repository }}
provenance: mode=max
sbom: true
- name: Compute image digest refs
run: |
echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
echo "GHCR_REF=$GHCR_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
echo "DH_REF=$DOCKERHUB_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
echo "Built digest: ${{ steps.build.outputs.digest }}"
shell: bash
- name: Attest build provenance (GHCR)
id: attest-ghcr
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: ${{ env.GHCR_IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
show-summary: true
- name: Attest build provenance (Docker Hub)
continue-on-error: true
id: attest-dh
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: index.docker.io/fosrl/${{ github.event.repository.name }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
show-summary: true
- name: Install cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: 'v3.0.2'
- name: Sanity check cosign private key
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null
shell: bash
- name: Sign GHCR image (digest) with key (recursive)
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
echo "Signing ${GHCR_REF} (digest) recursively with provided key"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}"
echo "Waiting 30 seconds for signatures to propagate..."
shell: bash
- name: Generate SBOM (SPDX JSON)
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
with:
image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }}
format: spdx-json
output: sbom.spdx.json
- name: Validate SBOM JSON
run: jq -e . sbom.spdx.json >/dev/null
shell: bash
- name: Minify SBOM JSON (optional hardening)
run: jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json
shell: bash
- name: Create SBOM attestation (GHCR, private key)
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
cosign attest \
--key env://COSIGN_PRIVATE_KEY \
--type spdxjson \
--predicate sbom.spdx.json \
"${GHCR_REF}"
shell: bash
- name: Create SBOM attestation (Docker Hub, private key)
continue-on-error: true
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_DOCKER_MEDIA_TYPES: "1"
run: |
set -euo pipefail
cosign attest \
--key env://COSIGN_PRIVATE_KEY \
--type spdxjson \
--predicate sbom.spdx.json \
"${DH_REF}"
shell: bash
- name: Keyless sign & verify GHCR digest (OIDC)
env:
COSIGN_YES: "true"
WORKFLOW_REF: ${{ github.workflow_ref }} # owner/repo/.github/workflows/<file>@refs/tags/<tag>
ISSUER: https://token.actions.githubusercontent.com
run: |
set -euo pipefail
echo "Keyless signing ${GHCR_REF}"
cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}"
echo "Verify keyless (OIDC) signature policy on ${GHCR_REF}"
cosign verify \
--certificate-oidc-issuer "${ISSUER}" \
--certificate-identity "https://github.com/${WORKFLOW_REF}" \
"${GHCR_REF}" -o text
shell: bash
- name: Sign Docker Hub image (digest) with key (recursive)
continue-on-error: true
env:
COSIGN_YES: "true"
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_DOCKER_MEDIA_TYPES: "1"
run: |
set -euo pipefail
echo "Signing ${DH_REF} (digest) recursively with provided key (Docker media types fallback)"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}"
shell: bash
- name: Keyless sign & verify Docker Hub digest (OIDC)
continue-on-error: true
env:
COSIGN_YES: "true"
ISSUER: https://token.actions.githubusercontent.com
COSIGN_DOCKER_MEDIA_TYPES: "1"
run: |
set -euo pipefail
echo "Keyless signing ${DH_REF} (force public-good Rekor)"
cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}"
echo "Keyless verify via Rekor (strict identity)"
if ! cosign verify \
--rekor-url https://rekor.sigstore.dev \
--certificate-oidc-issuer "${ISSUER}" \
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
"${DH_REF}" -o text; then
echo "Rekor verify failed — retry offline bundle verify (no Rekor)"
if ! cosign verify \
--offline \
--certificate-oidc-issuer "${ISSUER}" \
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
"${DH_REF}" -o text; then
echo "Offline bundle verify failed — ignore tlog (TEMP for debugging)"
cosign verify \
--insecure-ignore-tlog=true \
--certificate-oidc-issuer "${ISSUER}" \
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
"${DH_REF}" -o text || true
fi
fi
- name: Verify signature (public key) GHCR digest + tag
env:
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
run: |
set -euo pipefail
TAG_VAR="${TAG}"
echo "Verifying (digest) ${GHCR_REF}"
cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_REF" -o text
echo "Verifying (tag) $GHCR_IMAGE:$TAG_VAR"
cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_IMAGE:$TAG_VAR" -o text
shell: bash
- name: Verify SBOM attestation (GHCR)
env:
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
run: cosign verify-attestation --key env://COSIGN_PUBLIC_KEY --type spdxjson "$GHCR_REF" -o text
shell: bash
- name: Verify SLSA provenance (GHCR)
env:
ISSUER: https://token.actions.githubusercontent.com
WFREF: ${{ github.workflow_ref }}
run: |
set -euo pipefail
# (optional) show which predicate types are present to aid debugging
cosign download attestation "$GHCR_REF" \
| jq -r '.payload | @base64d | fromjson | .predicateType' | sort -u || true
# Verify the SLSA v1 provenance attestation (predicate URL)
cosign verify-attestation \
--type 'https://slsa.dev/provenance/v1' \
--certificate-oidc-issuer "$ISSUER" \
--certificate-identity "https://github.com/${WFREF}" \
--rekor-url https://rekor.sigstore.dev \
"$GHCR_REF" -o text
shell: bash
- name: Verify signature (public key) Docker Hub digest
continue-on-error: true
env:
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_DOCKER_MEDIA_TYPES: "1"
run: |
set -euo pipefail
echo "Verifying (digest) ${DH_REF} with Docker media types"
cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text
shell: bash
- name: Verify signature (public key) Docker Hub tag
continue-on-error: true
env:
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_DOCKER_MEDIA_TYPES: "1"
run: |
set -euo pipefail
echo "Verifying (tag) $DOCKERHUB_IMAGE:$TAG with Docker media types"
cosign verify --key env://COSIGN_PUBLIC_KEY "$DOCKERHUB_IMAGE:$TAG" -o text
shell: bash
# - name: Trivy scan (GHCR image)
# id: trivy
# uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
# with:
# image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }}
# format: sarif
# output: trivy-ghcr.sarif
# ignore-unfixed: true
# vuln-type: os,library
# severity: CRITICAL,HIGH
# exit-code: ${{ (vars.TRIVY_FAIL || '0') }}
# - name: Upload SARIF
# if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }}
# uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
# with:
# sarif_file: trivy-ghcr.sarif
# category: Image Vulnerability Scan
- name: Build binaries
env:
CGO_ENABLED: "0"
GOFLAGS: "-trimpath"
run: |
set -euo pipefail
TAG_VAR="${TAG}"
make go-build-release tag=$TAG_VAR
shell: bash
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ env.TAG }}
generate_release_notes: true
prerelease: ${{ env.IS_RC == 'true' }}
files: |
bin/*
fail_on_unmatched_files: true
draft: true
body: |
## Container Images
- GHCR: `${{ env.GHCR_REF }}`
- Docker Hub: `${{ env.DH_REF || 'N/A' }}`
**Digest:** `${{ steps.build.outputs.digest }}`

37
.github/workflows/stale-bot.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Mark and Close Stale Issues
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch: # Allow manual trigger
permissions:
contents: write # only for delete-branch option
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
days-before-stale: 14
days-before-close: 14
stale-issue-message: 'This issue has been automatically marked as stale due to 14 days of inactivity. It will be closed in 14 days if no further activity occurs.'
close-issue-message: 'This issue has been automatically closed due to inactivity. If you believe this is still relevant, please open a new issue with up-to-date information.'
stale-issue-label: 'stale'
exempt-issue-labels: 'needs investigating, networking, new feature, reverse proxy, bug, api, authentication, documentation, enhancement, help wanted, good first issue, question'
exempt-all-issue-assignees: true
only-labels: ''
exempt-pr-labels: ''
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 100
remove-stale-when-updated: true
delete-branch: false
enable-statistics: true

View File

@@ -1,5 +1,8 @@
name: Run Tests name: Run Tests
permissions:
contents: read
on: on:
pull_request: pull_request:
branches: branches:
@@ -7,22 +10,33 @@ on:
- dev - dev
jobs: jobs:
test: build-go:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with: with:
go-version: 1.25 go-version: 1.25
- name: Build go
run: go build
- name: Build Docker image
run: make build
- name: Build binaries - name: Build binaries
run: make go-build-release run: make go-build-release
build-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up 1.2.0 Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build Docker image
run: make docker-build-dev

393
API.md Normal file
View File

@@ -0,0 +1,393 @@
## API
Olm can be controlled with an embedded API server when using `--enable-api`. This allows you to start it as a daemon and trigger it with the following endpoints. The API can listen on either a TCP address or a Unix socket/Windows named pipe.
### Socket vs TCP
When `--enable-api` is used, Olm can listen on a TCP address when configured via `--http-addr` (like `:9452`). Alternatively, Olm can listen on a Unix socket (Linux/macOS) or Windows named pipe for local-only communication with better security when using `--socket-path` (like `/var/run/olm.sock`).
**Unix Socket (Linux/macOS):**
- Socket path example: `/var/run/olm/olm.sock`
- The directory is created automatically if it doesn't exist
- Socket permissions are set to `0666` to allow access
- Existing socket files are automatically removed on startup
- Socket file is cleaned up when Olm stops
**Windows Named Pipe:**
- Pipe path example: `\\.\pipe\olm`
- If the path doesn't start with `\`, it's automatically prefixed with `\\.\pipe\`
- Security descriptor grants full access to Everyone and the current owner
- Named pipes are automatically cleaned up by Windows
**Connecting to the Socket:**
```bash
# Linux/macOS - using curl with Unix socket
curl --unix-socket /var/run/olm/olm.sock http://localhost/status
---
### POST /connect
Initiates a new connection request to a Pangolin server.
**Request Body:**
```json
{
"id": "string",
"secret": "string",
"endpoint": "string",
"userToken": "string",
"mtu": 1280,
"dns": "8.8.8.8",
"dnsProxyIP": "string",
"upstreamDNS": ["8.8.8.8:53", "1.1.1.1:53"],
"interfaceName": "olm",
"holepunch": false,
"tlsClientCert": "string",
"pingInterval": "3s",
"pingTimeout": "5s",
"orgId": "string",
"fingerprint": {
"username": "string",
"hostname": "string",
"platform": "string",
"osVersion": "string",
"kernelVersion": "string",
"arch": "string",
"deviceModel": "string",
"serialNumber": "string"
},
"postures": {}
}
```
**Required Fields:**
- `id`: Olm ID generated by Pangolin
- `secret`: Authentication secret for the Olm ID
- `endpoint`: Target Pangolin endpoint URL
**Optional Fields:**
- `userToken`: User authentication token
- `mtu`: MTU for the internal WireGuard interface (default: 1280)
- `dns`: DNS server to use for resolving the endpoint
- `dnsProxyIP`: DNS proxy IP address
- `upstreamDNS`: Array of upstream DNS servers
- `interfaceName`: Name of the WireGuard interface (default: olm)
- `holepunch`: Enable NAT hole punching (default: false)
- `tlsClientCert`: TLS client certificate
- `pingInterval`: Interval for pinging the server (default: 3s)
- `pingTimeout`: Timeout for each ping (default: 5s)
- `orgId`: Organization ID to connect to
- `fingerprint`: Device fingerprinting information (should be set before connecting)
- `username`: Current username on the device
- `hostname`: Device hostname
- `platform`: Operating system platform (macos, windows, linux, ios, android, unknown)
- `osVersion`: Operating system version
- `kernelVersion`: Kernel version
- `arch`: System architecture (e.g., amd64, arm64)
- `deviceModel`: Device model identifier
- `serialNumber`: Device serial number
- `postures`: Device posture/security information
**Response:**
- **Status Code:** `202 Accepted`
- **Content-Type:** `application/json`
```json
{
"status": "connection request accepted"
}
```
**Error Responses:**
- `405 Method Not Allowed` - Non-POST requests
- `400 Bad Request` - Invalid JSON or missing required fields
- `409 Conflict` - Already connected to a server (disconnect first)
---
### GET /status
Returns the current connection status, registration state, and peer information.
**Response:**
- **Status Code:** `200 OK`
- **Content-Type:** `application/json`
```json
{
"connected": true,
"registered": true,
"terminated": false,
"version": "1.0.0",
"agent": "olm",
"orgId": "org_123",
"peers": {
"10": {
"siteId": 10,
"name": "Site A",
"connected": true,
"rtt": 145338339,
"lastSeen": "2025-08-13T14:39:17.208334428-07:00",
"endpoint": "p.fosrl.io:21820",
"isRelay": true,
"peerAddress": "100.89.128.5",
"holepunchConnected": false
},
"8": {
"siteId": 8,
"name": "Site B",
"connected": false,
"rtt": 0,
"lastSeen": "2025-08-13T14:39:19.663823645-07:00",
"endpoint": "p.fosrl.io:21820",
"isRelay": true,
"peerAddress": "100.89.128.10",
"holepunchConnected": false
}
},
"networkSettings": {
"tunnelIP": "100.89.128.3/20"
}
}
```
**Fields:**
- `connected`: Boolean indicating if connected to Pangolin
- `registered`: Boolean indicating if registered with the server
- `terminated`: Boolean indicating if the connection was terminated
- `version`: Olm version string
- `agent`: Agent identifier
- `orgId`: Current organization ID
- `peers`: Map of peer statuses by site ID
- `siteId`: Peer site identifier
- `name`: Site name
- `connected`: Boolean peer connection state
- `rtt`: Peer round-trip time (integer, nanoseconds)
- `lastSeen`: Last time peer was seen (RFC3339 timestamp)
- `endpoint`: Peer endpoint address
- `isRelay`: Whether the peer is relayed (true) or direct (false)
- `peerAddress`: Peer's IP address in the tunnel
- `holepunchConnected`: Whether holepunch connection is established
- `networkSettings`: Current network configuration including tunnel IP
**Error Responses:**
- `405 Method Not Allowed` - Non-GET requests
---
### POST /disconnect
Disconnects from the current Pangolin server and tears down the WireGuard tunnel.
**Request Body:** None required
**Response:**
- **Status Code:** `200 OK`
- **Content-Type:** `application/json`
```json
{
"status": "disconnect initiated"
}
```
**Error Responses:**
- `405 Method Not Allowed` - Non-POST requests
- `409 Conflict` - Not currently connected to a server
---
### POST /switch-org
Switches to a different organization while maintaining the connection.
**Request Body:**
```json
{
"orgId": "string"
}
```
**Required Fields:**
- `orgId`: The organization ID to switch to
**Response:**
- **Status Code:** `200 OK`
- **Content-Type:** `application/json`
```json
{
"status": "org switch request accepted"
}
```
**Error Responses:**
- `405 Method Not Allowed` - Non-POST requests
- `400 Bad Request` - Invalid JSON or missing orgId field
- `500 Internal Server Error` - Org switch failed
---
### PUT /metadata
Updates device fingerprinting and posture information. This endpoint can be called at any time to update metadata, but it's recommended to provide this information in the initial `/connect` request or immediately before connecting.
**Request Body:**
```json
{
"fingerprint": {
"username": "string",
"hostname": "string",
"platform": "string",
"osVersion": "string",
"kernelVersion": "string",
"arch": "string",
"deviceModel": "string",
"serialNumber": "string"
},
"postures": {}
}
```
**Optional Fields:**
- `fingerprint`: Device fingerprinting information
- `username`: Current username on the device
- `hostname`: Device hostname
- `platform`: Operating system platform (macos, windows, linux, ios, android, unknown)
- `osVersion`: Operating system version
- `kernelVersion`: Kernel version
- `arch`: System architecture (e.g., amd64, arm64)
- `deviceModel`: Device model identifier
- `serialNumber`: Device serial number
- `postures`: Device posture/security information (object with arbitrary key-value pairs)
**Response:**
- **Status Code:** `200 OK`
- **Content-Type:** `application/json`
```json
{
"status": "metadata updated"
}
```
**Error Responses:**
- `405 Method Not Allowed` - Non-PUT requests
- `400 Bad Request` - Invalid JSON
**Note:** It's recommended to call this endpoint BEFORE `/connect` to ensure fingerprinting information is available during the initial connection handshake.
---
### POST /exit
Initiates a graceful shutdown of the Olm process.
**Request Body:** None required
**Response:**
- **Status Code:** `200 OK`
- **Content-Type:** `application/json`
```json
{
"status": "shutdown initiated"
}
```
**Note:** The response is sent before shutdown begins. There is a 100ms delay before the actual shutdown to ensure the response is delivered.
**Error Responses:**
- `405 Method Not Allowed` - Non-POST requests
---
### GET /health
Simple health check endpoint to verify the API server is running.
**Response:**
- **Status Code:** `200 OK`
- **Content-Type:** `application/json`
```json
{
"status": "ok"
}
```
**Error Responses:**
- `405 Method Not Allowed` - Non-GET requests
---
## Usage Examples
### Update metadata before connecting (recommended)
```bash
curl -X PUT http://localhost:9452/metadata \
-H "Content-Type: application/json" \
-d '{
"fingerprint": {
"username": "john",
"hostname": "johns-laptop",
"platform": "macos",
"osVersion": "14.2.1",
"arch": "arm64",
"deviceModel": "MacBookPro18,3"
}
}'
```
### Connect to a peer
```bash
curl -X POST http://localhost:9452/connect \
-H "Content-Type: application/json" \
-d '{
"id": "31frd0uzbjvp721",
"secret": "h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6",
"endpoint": "https://example.com"
}'
```
### Connect with additional options
```bash
curl -X POST http://localhost:9452/connect \
-H "Content-Type: application/json" \
-d '{
"id": "31frd0uzbjvp721",
"secret": "h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6",
"endpoint": "https://example.com",
"mtu": 1400,
"holepunch": true,
"pingInterval": "5s"
}'
```
### Check connection status
```bash
curl http://localhost:9452/status
```
### Switch organization
```bash
curl -X POST http://localhost:9452/switch-org \
-H "Content-Type: application/json" \
-d '{"orgId": "org_456"}'
```
### Disconnect from server
```bash
curl -X POST http://localhost:9452/disconnect
```
### Health check
```bash
curl http://localhost:9452/health
```
### Shutdown Olm
```bash
curl -X POST http://localhost:9452/exit
```
### Using Unix socket (Linux/macOS)
```bash
curl --unix-socket /var/run/olm/olm.sock http://localhost/status
curl --unix-socket /var/run/olm/olm.sock -X POST http://localhost/disconnect
```

View File

@@ -16,7 +16,7 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /olm RUN CGO_ENABLED=0 GOOS=linux go build -o /olm
# Start a new stage from scratch # Start a new stage from scratch
FROM alpine:3.22 AS runner FROM alpine:3.23 AS runner
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates

View File

@@ -1,26 +1,67 @@
.PHONY: all local docker-build-release
all: go-build-release all: local
local:
CGO_ENABLED=0 go build -o ./bin/olm
docker-build:
docker build -t fosrl/olm:latest .
docker-build-release: docker-build-release:
@if [ -z "$(tag)" ]; then \ @if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make docker-build-release tag=<tag>"; \ echo "Error: tag is required. Usage: make docker-build-release tag=<tag>"; \
exit 1; \ exit 1; \
fi fi
docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 -t fosrl/olm:latest -f Dockerfile --push . docker buildx build . \
docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 -t fosrl/olm:$(tag) -f Dockerfile --push . --platform linux/arm/v7,linux/arm64,linux/amd64 \
-t fosrl/olm:latest \
-t fosrl/olm:$(tag) \
-f Dockerfile \
--push
local: docker-build-dev:
CGO_ENABLED=0 go build -o bin/olm docker buildx build . \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-t fosrl/olm:latest \
-f Dockerfile
build: .PHONY: go-build-release \
docker build -t fosrl/olm:latest . go-build-release-linux-arm64 go-build-release-linux-arm32-v7 \
go-build-release-linux-arm32-v6 go-build-release-linux-amd64 \
go-build-release-linux-riscv64 go-build-release-darwin-arm64 \
go-build-release-darwin-amd64 go-build-release-windows-amd64
go-build-release: go-build-release: \
go-build-release-linux-arm64 \
go-build-release-linux-arm32-v7 \
go-build-release-linux-arm32-v6 \
go-build-release-linux-amd64 \
go-build-release-linux-riscv64 \
go-build-release-darwin-arm64 \
go-build-release-darwin-amd64 \
go-build-release-windows-amd64 \
go-build-release-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/olm_linux_arm64 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/olm_linux_arm64
go-build-release-linux-arm32-v7:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o bin/olm_linux_arm32
go-build-release-linux-arm32-v6:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o bin/olm_linux_arm32v6
go-build-release-linux-amd64:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/olm_linux_amd64 CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/olm_linux_amd64
go-build-release-linux-riscv64:
CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build -o bin/olm_linux_riscv64
go-build-release-darwin-arm64:
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/olm_darwin_arm64 CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/olm_darwin_arm64
go-build-release-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/olm_darwin_amd64 CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/olm_darwin_amd64
go-build-release-windows-amd64:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/olm_windows_amd64.exe CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/olm_windows_amd64.exe
clean:
rm olm

271
README.md
View File

@@ -6,7 +6,7 @@ Olm is a [WireGuard](https://www.wireguard.com/) tunnel client designed to secur
Olm is used with Pangolin and Newt as part of the larger system. See documentation below: Olm is used with Pangolin and Newt as part of the larger system. See documentation below:
- [Full Documentation](https://docs.pangolin.net) - [Full Documentation](https://docs.pangolin.net/manage/clients/understanding-clients)
## Key Functions ## Key Functions
@@ -18,281 +18,18 @@ Using the Olm ID and a secret, the olm will make HTTP requests to Pangolin to re
When Olm receives WireGuard control messages, it will use the information encoded (endpoint, public key) to bring up a WireGuard tunnel on your computer to a remote Newt. It will ping over the tunnel to ensure the peer is brought up. When Olm receives WireGuard control messages, it will use the information encoded (endpoint, public key) to bring up a WireGuard tunnel on your computer to a remote Newt. It will ping over the tunnel to ensure the peer is brought up.
## CLI Args
- `endpoint`: The endpoint where both Gerbil and Pangolin reside in order to connect to the websocket.
- `id`: Olm ID generated by Pangolin to identify the olm.
- `secret`: A unique secret (not shared and kept private) used to authenticate the olm ID with the websocket in order to receive commands.
- `mtu` (optional): MTU for the internal WG interface. Default: 1280
- `dns` (optional): DNS server to use to resolve the endpoint. Default: 8.8.8.8
- `log-level` (optional): The log level to use (DEBUG, INFO, WARN, ERROR, FATAL). Default: INFO
- `ping-interval` (optional): Interval for pinging the server. Default: 3s
- `ping-timeout` (optional): Timeout for each ping. Default: 5s
- `interface` (optional): Name of the WireGuard interface. Default: olm
- `enable-http` (optional): Enable HTTP server for receiving connection requests. Default: false
- `http-addr` (optional): HTTP server address (e.g., ':9452'). Default: :9452
- `holepunch` (optional): Enable hole punching. Default: false
## Environment Variables
All CLI arguments can also be set via environment variables:
- `PANGOLIN_ENDPOINT`: Equivalent to `--endpoint`
- `OLM_ID`: Equivalent to `--id`
- `OLM_SECRET`: Equivalent to `--secret`
- `MTU`: Equivalent to `--mtu`
- `DNS`: Equivalent to `--dns`
- `LOG_LEVEL`: Equivalent to `--log-level`
- `INTERFACE`: Equivalent to `--interface`
- `HTTP_ADDR`: Equivalent to `--http-addr`
- `PING_INTERVAL`: Equivalent to `--ping-interval`
- `PING_TIMEOUT`: Equivalent to `--ping-timeout`
- `HOLEPUNCH`: Set to "true" to enable hole punching (equivalent to `--holepunch`)
- `CONFIG_FILE`: Set to the location of a JSON file to load secret values
Examples:
```bash
olm \
--id 31frd0uzbjvp721 \
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
--endpoint https://example.com
```
You can also run it with Docker compose. For example, a service in your `docker-compose.yml` might look like this using environment vars (recommended):
```yaml
services:
olm:
image: fosrl/olm
container_name: olm
restart: unless-stopped
network_mode: host
devices:
- /dev/net/tun:/dev/net/tun
environment:
- PANGOLIN_ENDPOINT=https://example.com
- OLM_ID=31frd0uzbjvp721
- OLM_SECRET=h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6
```
You can also pass the CLI args to the container:
```yaml
services:
olm:
image: fosrl/olm
container_name: olm
restart: unless-stopped
network_mode: host
devices:
- /dev/net/tun:/dev/net/tun
command:
- --id 31frd0uzbjvp721
- --secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6
- --endpoint https://example.com
```
**Docker Configuration Notes:**
- `network_mode: host` brings the olm network interface to the host system, allowing the WireGuard tunnel to function properly
- `devices: - /dev/net/tun:/dev/net/tun` is required to give the container access to the TUN device for creating WireGuard interfaces
## Loading secrets from files
You can use `CONFIG_FILE` to define a location of a config file to store the credentials between runs.
```
$ cat ~/.config/olm-client/config.json
{
"id": "spmzu8rbpzj1qq6",
"secret": "f6v61mjutwme2kkydbw3fjo227zl60a2tsf5psw9r25hgae3",
"endpoint": "https://app.pangolin.net",
"tlsClientCert": ""
}
```
This file is also written to when newt first starts up. So you do not need to run every time with --id and secret if you have run it once!
Default locations:
- **macOS**: `~/Library/Application Support/olm-client/config.json`
- **Windows**: `%PROGRAMDATA%\olm\olm-client\config.json`
- **Linux/Others**: `~/.config/olm-client/config.json`
## Hole Punching ## Hole Punching
In the default mode, olm "relays" traffic through Gerbil in the cloud to get down to newt. This is a little more reliable. Support for NAT hole punching is also EXPERIMENTAL right now using the `--holepunch` flag. This will attempt to orchestrate a NAT hole punch between the two sites so that traffic flows directly. This will save data costs and speed. If it fails it should fall back to relaying. In the default mode, olm uses both relaying through Gerbil and NAT hole punching to connect to Newt. Hole punching attempts to orchestrate a NAT traversal between the two sites so that traffic flows directly, which can save data costs and improve speed. If hole punching fails, traffic will fall back to relaying through Gerbil.
Right now, basic NAT hole punching is supported. We plan to add:
- [ ] Birthday paradox
- [ ] UPnP
- [ ] LAN detection
## Windows Service
On Windows, olm has to be installed and run as a Windows service. When running it with the cli args live above it will attempt to install and run the service to function like a cli tool. You can also run the following:
### Service Management Commands
```
# Install the service
olm.exe install
# Start the service
olm.exe start
# Stop the service
olm.exe stop
# Check service status
olm.exe status
# Remove the service
olm.exe remove
# Run in debug mode (console output) with our without id & secret
olm.exe debug
# Show help
olm.exe help
```
Note running the service requires credentials in `%PROGRAMDATA%\olm\olm-client\config.json`.
### Service Configuration
When running as a service, Olm will read configuration from environment variables or you can modify the service to include command-line arguments:
1. Install the service: `olm.exe install`
2. Set the credentials in `%PROGRAMDATA%\olm\olm-client\config.json`. Hint: if you run olm once with --id and --secret this file will be populated!
3. Start the service: `olm.exe start`
### Service Logs
When running as a service, logs are written to:
- Windows Event Log (Application log, source: "OlmWireguardService")
- Log files in: `%PROGRAMDATA%\olm\logs\olm.log`
You can view the Windows Event Log using Event Viewer or PowerShell:
```powershell
Get-EventLog -LogName Application -Source "OlmWireguardService" -Newest 10
```
## HTTP Endpoints
Olm can be controlled with an embedded http server when using `--enable-http`. This allows you to start it as a daemon and trigger it with the following endpoints:
### POST /connect
Initiates a new connection request.
**Request Body:**
```json
{
"id": "string",
"secret": "string",
"endpoint": "string"
}
```
**Required Fields:**
- `id`: Connection identifier
- `secret`: Authentication secret
- `endpoint`: Target endpoint URL
**Response:**
- **Status Code:** `202 Accepted`
- **Content-Type:** `application/json`
```json
{
"status": "connection request accepted"
}
```
**Error Responses:**
- `405 Method Not Allowed` - Non-POST requests
- `400 Bad Request` - Invalid JSON or missing required fields
### GET /status
Returns the current connection status and peer information.
**Response:**
- **Status Code:** `200 OK`
- **Content-Type:** `application/json`
```json
{
"status": "connected",
"connected": true,
"tunnelIP": "100.89.128.3/20",
"version": "version_replaceme",
"peers": {
"10": {
"siteId": 10,
"connected": true,
"rtt": 145338339,
"lastSeen": "2025-08-13T14:39:17.208334428-07:00",
"endpoint": "p.fosrl.io:21820",
"isRelay": true
},
"8": {
"siteId": 8,
"connected": false,
"rtt": 0,
"lastSeen": "2025-08-13T14:39:19.663823645-07:00",
"endpoint": "p.fosrl.io:21820",
"isRelay": true
}
}
}
```
**Fields:**
- `status`: Overall connection status ("connected" or "disconnected")
- `connected`: Boolean connection state
- `tunnelIP`: IP address and subnet of the tunnel (when connected)
- `version`: Olm version string
- `peers`: Map of peer statuses by site ID
- `siteId`: Peer site identifier
- `connected`: Boolean peer connection state
- `rtt`: Peer round-trip time (integer, nanoseconds)
- `lastSeen`: Last time peer was seen (RFC3339 timestamp)
- `endpoint`: Peer endpoint address
- `isRelay`: Whether the peer is relayed (true) or direct (false)
**Error Responses:**
- `405 Method Not Allowed` - Non-GET requests
## Usage Examples
### Connect to a peer
```bash
curl -X POST http://localhost:8080/connect \
-H "Content-Type: application/json" \
-d '{
"id": "31frd0uzbjvp721",
"secret": "h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6",
"endpoint": "https://example.com"
}'
```
### Check connection status
```bash
curl http://localhost:8080/status
```
## Build ## Build
### Binary ### Binary
Make sure to have Go 1.23.1 installed. Make sure to have Go 1.25 installed.
```bash ```bash
make local make
``` ```
## Licensing ## Licensing

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
@@ -32,12 +33,18 @@ type ConnectionRequest struct {
// SwitchOrgRequest defines the structure for switching organizations // SwitchOrgRequest defines the structure for switching organizations
type SwitchOrgRequest struct { type SwitchOrgRequest struct {
OrgID string `json:"orgId"` OrgID string `json:"org_id"`
}
// PowerModeRequest represents a request to change power mode
type PowerModeRequest struct {
Mode string `json:"mode"` // "normal" or "low"
} }
// PeerStatus represents the status of a peer connection // PeerStatus represents the status of a peer connection
type PeerStatus struct { type PeerStatus struct {
SiteID int `json:"siteId"` SiteID int `json:"siteId"`
Name string `json:"name"`
Connected bool `json:"connected"` Connected bool `json:"connected"`
RTT time.Duration `json:"rtt"` RTT time.Duration `json:"rtt"`
LastSeen time.Time `json:"lastSeen"` LastSeen time.Time `json:"lastSeen"`
@@ -47,11 +54,18 @@ type PeerStatus struct {
HolepunchConnected bool `json:"holepunchConnected"` HolepunchConnected bool `json:"holepunchConnected"`
} }
// OlmError holds error information from registration failures
type OlmError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// StatusResponse is returned by the status endpoint // StatusResponse is returned by the status endpoint
type StatusResponse struct { type StatusResponse struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
Registered bool `json:"registered"` Registered bool `json:"registered"`
Terminated bool `json:"terminated"` Terminated bool `json:"terminated"`
OlmError *OlmError `json:"error,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
Agent string `json:"agent,omitempty"` Agent string `json:"agent,omitempty"`
OrgID string `json:"orgId,omitempty"` OrgID string `json:"orgId,omitempty"`
@@ -59,25 +73,37 @@ type StatusResponse struct {
NetworkSettings network.NetworkSettings `json:"networkSettings,omitempty"` NetworkSettings network.NetworkSettings `json:"networkSettings,omitempty"`
} }
type MetadataChangeRequest struct {
Fingerprint map[string]any `json:"fingerprint"`
Postures map[string]any `json:"postures"`
}
// API represents the HTTP server and its state // API represents the HTTP server and its state
type API struct { type API struct {
addr string addr string
socketPath string socketPath string
listener net.Listener listener net.Listener
server *http.Server server *http.Server
onConnect func(ConnectionRequest) error
onSwitchOrg func(SwitchOrgRequest) error onConnect func(ConnectionRequest) error
onDisconnect func() error onSwitchOrg func(SwitchOrgRequest) error
onExit func() error onMetadataChange func(MetadataChangeRequest) error
onDisconnect func() error
onExit func() error
onRebind func() error
onPowerMode func(PowerModeRequest) error
statusMu sync.RWMutex statusMu sync.RWMutex
peerStatuses map[int]*PeerStatus peerStatuses map[int]*PeerStatus
connectedAt time.Time connectedAt time.Time
isConnected bool isConnected bool
isRegistered bool isRegistered bool
isTerminated bool isTerminated bool
version string olmError *OlmError
agent string
orgID string version string
agent string
orgID string
} }
// NewAPI creates a new HTTP server that listens on a TCP address // NewAPI creates a new HTTP server that listens on a TCP address
@@ -100,28 +126,49 @@ func NewAPISocket(socketPath string) *API {
return s return s
} }
func NewAPIStub() *API {
s := &API{
peerStatuses: make(map[int]*PeerStatus),
}
return s
}
// SetHandlers sets the callback functions for handling API requests // SetHandlers sets the callback functions for handling API requests
func (s *API) SetHandlers( func (s *API) SetHandlers(
onConnect func(ConnectionRequest) error, onConnect func(ConnectionRequest) error,
onSwitchOrg func(SwitchOrgRequest) error, onSwitchOrg func(SwitchOrgRequest) error,
onMetadataChange func(MetadataChangeRequest) error,
onDisconnect func() error, onDisconnect func() error,
onExit func() error, onExit func() error,
onRebind func() error,
onPowerMode func(PowerModeRequest) error,
) { ) {
s.onConnect = onConnect s.onConnect = onConnect
s.onSwitchOrg = onSwitchOrg s.onSwitchOrg = onSwitchOrg
s.onMetadataChange = onMetadataChange
s.onDisconnect = onDisconnect s.onDisconnect = onDisconnect
s.onExit = onExit s.onExit = onExit
s.onRebind = onRebind
s.onPowerMode = onPowerMode
} }
// Start starts the HTTP server // Start starts the HTTP server
func (s *API) Start() error { func (s *API) Start() error {
if s.socketPath == "" && s.addr == "" {
return fmt.Errorf("either socketPath or addr must be provided to start the API server")
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/connect", s.handleConnect) mux.HandleFunc("/connect", s.handleConnect)
mux.HandleFunc("/status", s.handleStatus) mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/switch-org", s.handleSwitchOrg) mux.HandleFunc("/switch-org", s.handleSwitchOrg)
mux.HandleFunc("/metadata", s.handleMetadataChange)
mux.HandleFunc("/disconnect", s.handleDisconnect) mux.HandleFunc("/disconnect", s.handleDisconnect)
mux.HandleFunc("/exit", s.handleExit) mux.HandleFunc("/exit", s.handleExit)
mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/rebind", s.handleRebind)
mux.HandleFunc("/power-mode", s.handlePowerMode)
s.server = &http.Server{ s.server = &http.Server{
Handler: mux, Handler: mux,
@@ -159,7 +206,7 @@ func (s *API) Stop() error {
// Close the server first, which will also close the listener gracefully // Close the server first, which will also close the listener gracefully
if s.server != nil { if s.server != nil {
s.server.Close() _ = s.server.Close()
} }
// Clean up socket file if using Unix socket // Clean up socket file if using Unix socket
@@ -170,6 +217,26 @@ func (s *API) Stop() error {
return nil return nil
} }
func (s *API) AddPeerStatus(siteID int, siteName string, connected bool, rtt time.Duration, endpoint string, isRelay bool) {
s.statusMu.Lock()
defer s.statusMu.Unlock()
status, exists := s.peerStatuses[siteID]
if !exists {
status = &PeerStatus{
SiteID: siteID,
}
s.peerStatuses[siteID] = status
}
status.Name = siteName
status.Connected = connected
status.RTT = rtt
status.LastSeen = time.Now()
status.Endpoint = endpoint
status.IsRelay = isRelay
}
// UpdatePeerStatus updates the status of a peer including endpoint and relay info // UpdatePeerStatus updates the status of a peer including endpoint and relay info
func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool) { func (s *API) UpdatePeerStatus(siteID int, connected bool, rtt time.Duration, endpoint string, isRelay bool) {
s.statusMu.Lock() s.statusMu.Lock()
@@ -205,9 +272,6 @@ func (s *API) SetConnectionStatus(isConnected bool) {
if isConnected { if isConnected {
s.connectedAt = time.Now() s.connectedAt = time.Now()
} else {
// Clear peer statuses when disconnected
s.peerStatuses = make(map[int]*PeerStatus)
} }
} }
@@ -215,6 +279,27 @@ func (s *API) SetRegistered(registered bool) {
s.statusMu.Lock() s.statusMu.Lock()
defer s.statusMu.Unlock() defer s.statusMu.Unlock()
s.isRegistered = registered s.isRegistered = registered
// Clear any registration error when successfully registered
if registered {
s.olmError = nil
}
}
// SetOlmError sets the registration error
func (s *API) SetOlmError(code string, message string) {
s.statusMu.Lock()
defer s.statusMu.Unlock()
s.olmError = &OlmError{
Code: code,
Message: message,
}
}
// ClearOlmError clears any registration error
func (s *API) ClearOlmError() {
s.statusMu.Lock()
defer s.statusMu.Unlock()
s.olmError = nil
} }
func (s *API) SetTerminated(terminated bool) { func (s *API) SetTerminated(terminated bool) {
@@ -324,7 +409,7 @@ func (s *API) handleConnect(w http.ResponseWriter, r *http.Request) {
// Return a success response // Return a success response
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
"status": "connection request accepted", "status": "connection request accepted",
}) })
} }
@@ -337,12 +422,12 @@ func (s *API) handleStatus(w http.ResponseWriter, r *http.Request) {
} }
s.statusMu.RLock() s.statusMu.RLock()
defer s.statusMu.RUnlock()
resp := StatusResponse{ resp := StatusResponse{
Connected: s.isConnected, Connected: s.isConnected,
Registered: s.isRegistered, Registered: s.isRegistered,
Terminated: s.isTerminated, Terminated: s.isTerminated,
OlmError: s.olmError,
Version: s.version, Version: s.version,
Agent: s.agent, Agent: s.agent,
OrgID: s.orgID, OrgID: s.orgID,
@@ -350,8 +435,18 @@ func (s *API) handleStatus(w http.ResponseWriter, r *http.Request) {
NetworkSettings: network.GetSettings(), NetworkSettings: network.GetSettings(),
} }
s.statusMu.RUnlock()
data, err := json.Marshal(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp) w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
} }
// handleHealth handles the /health endpoint // handleHealth handles the /health endpoint
@@ -363,7 +458,7 @@ func (s *API) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok", "status": "ok",
}) })
} }
@@ -380,7 +475,7 @@ func (s *API) handleExit(w http.ResponseWriter, r *http.Request) {
// Return a success response first // Return a success response first
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
"status": "shutdown initiated", "status": "shutdown initiated",
}) })
@@ -429,7 +524,7 @@ func (s *API) handleSwitchOrg(w http.ResponseWriter, r *http.Request) {
// Return a success response // Return a success response
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
"status": "org switch request accepted", "status": "org switch request accepted",
}) })
} }
@@ -463,16 +558,43 @@ func (s *API) handleDisconnect(w http.ResponseWriter, r *http.Request) {
// Return a success response // Return a success response
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
"status": "disconnect initiated", "status": "disconnect initiated",
}) })
} }
// handleMetadataChange handles the /metadata endpoint
func (s *API) handleMetadataChange(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req MetadataChangeRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest)
return
}
logger.Info("Received metadata change request via API: %v", req)
_ = s.onMetadataChange(req)
// Return a success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "metadata updated",
})
}
func (s *API) GetStatus() StatusResponse { func (s *API) GetStatus() StatusResponse {
return StatusResponse{ return StatusResponse{
Connected: s.isConnected, Connected: s.isConnected,
Registered: s.isRegistered, Registered: s.isRegistered,
Terminated: s.isTerminated, Terminated: s.isTerminated,
OlmError: s.olmError,
Version: s.version, Version: s.version,
Agent: s.agent, Agent: s.agent,
OrgID: s.orgID, OrgID: s.orgID,
@@ -480,3 +602,74 @@ func (s *API) GetStatus() StatusResponse {
NetworkSettings: network.GetSettings(), NetworkSettings: network.GetSettings(),
} }
} }
// handleRebind handles the /rebind endpoint
// This triggers a socket rebind, which is necessary when network connectivity changes
// (e.g., WiFi to cellular transition on macOS/iOS) and the old socket becomes stale.
func (s *API) handleRebind(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
logger.Info("Received rebind request via API")
// Call the rebind handler if set
if s.onRebind != nil {
if err := s.onRebind(); err != nil {
http.Error(w, fmt.Sprintf("Rebind failed: %v", err), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "Rebind handler not configured", http.StatusNotImplemented)
return
}
// Return a success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "socket rebound successfully",
})
}
// handlePowerMode handles the /power-mode endpoint
// This allows changing the power mode between "normal" and "low"
func (s *API) handlePowerMode(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req PowerModeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest)
return
}
// Validate power mode
if req.Mode != "normal" && req.Mode != "low" {
http.Error(w, "Invalid power mode: must be 'normal' or 'low'", http.StatusBadRequest)
return
}
logger.Info("Received power mode change request via API: mode=%s", req.Mode)
// Call the power mode handler if set
if s.onPowerMode != nil {
if err := s.onPowerMode(req); err != nil {
http.Error(w, fmt.Sprintf("Power mode change failed: %v", err), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "Power mode handler not configured", http.StatusNotImplemented)
return
}
// Return a success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": fmt.Sprintf("power mode changed to %s successfully", req.Mode),
})
}

102
config.go
View File

@@ -40,10 +40,11 @@ type OlmConfig struct {
PingTimeout string `json:"pingTimeout"` PingTimeout string `json:"pingTimeout"`
// Advanced // Advanced
Holepunch bool `json:"holepunch"` DisableHolepunch bool `json:"disableHolepunch"`
TlsClientCert string `json:"tlsClientCert"` TlsClientCert string `json:"tlsClientCert"`
OverrideDNS bool `json:"overrideDNS"` OverrideDNS bool `json:"overrideDNS"`
DisableRelay bool `json:"disableRelay"` TunnelDNS bool `json:"tunnelDNS"`
DisableRelay bool `json:"disableRelay"`
// DoNotCreateNewClient bool `json:"doNotCreateNewClient"` // DoNotCreateNewClient bool `json:"doNotCreateNewClient"`
// Parsed values (not in JSON) // Parsed values (not in JSON)
@@ -78,16 +79,18 @@ func DefaultConfig() *OlmConfig {
} }
config := &OlmConfig{ config := &OlmConfig{
MTU: 1280, MTU: 1280,
DNS: "8.8.8.8", DNS: "8.8.8.8",
UpstreamDNS: []string{"8.8.8.8:53"}, UpstreamDNS: []string{"8.8.8.8:53"},
LogLevel: "INFO", LogLevel: "INFO",
InterfaceName: "olm", InterfaceName: "olm",
EnableAPI: false, EnableAPI: false,
SocketPath: socketPath, SocketPath: socketPath,
PingInterval: "3s", PingInterval: "3s",
PingTimeout: "5s", PingTimeout: "5s",
Holepunch: false, DisableHolepunch: false,
OverrideDNS: true,
TunnelDNS: false,
// DoNotCreateNewClient: false, // DoNotCreateNewClient: false,
sources: make(map[string]string), sources: make(map[string]string),
} }
@@ -103,8 +106,9 @@ func DefaultConfig() *OlmConfig {
config.sources["socketPath"] = string(SourceDefault) config.sources["socketPath"] = string(SourceDefault)
config.sources["pingInterval"] = string(SourceDefault) config.sources["pingInterval"] = string(SourceDefault)
config.sources["pingTimeout"] = string(SourceDefault) config.sources["pingTimeout"] = string(SourceDefault)
config.sources["holepunch"] = string(SourceDefault) config.sources["disableHolepunch"] = string(SourceDefault)
config.sources["overrideDNS"] = string(SourceDefault) config.sources["overrideDNS"] = string(SourceDefault)
config.sources["tunnelDNS"] = string(SourceDefault)
config.sources["disableRelay"] = string(SourceDefault) config.sources["disableRelay"] = string(SourceDefault)
// config.sources["doNotCreateNewClient"] = string(SourceDefault) // config.sources["doNotCreateNewClient"] = string(SourceDefault)
@@ -253,9 +257,9 @@ func loadConfigFromEnv(config *OlmConfig) {
config.SocketPath = val config.SocketPath = val
config.sources["socketPath"] = string(SourceEnv) config.sources["socketPath"] = string(SourceEnv)
} }
if val := os.Getenv("HOLEPUNCH"); val == "true" { if val := os.Getenv("DISABLE_HOLEPUNCH"); val == "true" {
config.Holepunch = true config.DisableHolepunch = true
config.sources["holepunch"] = string(SourceEnv) config.sources["disableHolepunch"] = string(SourceEnv)
} }
if val := os.Getenv("OVERRIDE_DNS"); val == "true" { if val := os.Getenv("OVERRIDE_DNS"); val == "true" {
config.OverrideDNS = true config.OverrideDNS = true
@@ -265,6 +269,10 @@ func loadConfigFromEnv(config *OlmConfig) {
config.DisableRelay = true config.DisableRelay = true
config.sources["disableRelay"] = string(SourceEnv) config.sources["disableRelay"] = string(SourceEnv)
} }
if val := os.Getenv("TUNNEL_DNS"); val == "true" {
config.TunnelDNS = true
config.sources["tunnelDNS"] = string(SourceEnv)
}
// if val := os.Getenv("DO_NOT_CREATE_NEW_CLIENT"); val == "true" { // if val := os.Getenv("DO_NOT_CREATE_NEW_CLIENT"); val == "true" {
// config.DoNotCreateNewClient = true // config.DoNotCreateNewClient = true
// config.sources["doNotCreateNewClient"] = string(SourceEnv) // config.sources["doNotCreateNewClient"] = string(SourceEnv)
@@ -277,24 +285,25 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
// Store original values to detect changes // Store original values to detect changes
origValues := map[string]interface{}{ origValues := map[string]interface{}{
"endpoint": config.Endpoint, "endpoint": config.Endpoint,
"id": config.ID, "id": config.ID,
"secret": config.Secret, "secret": config.Secret,
"org": config.OrgID, "org": config.OrgID,
"userToken": config.UserToken, "userToken": config.UserToken,
"mtu": config.MTU, "mtu": config.MTU,
"dns": config.DNS, "dns": config.DNS,
"upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS), "upstreamDNS": fmt.Sprintf("%v", config.UpstreamDNS),
"logLevel": config.LogLevel, "logLevel": config.LogLevel,
"interface": config.InterfaceName, "interface": config.InterfaceName,
"httpAddr": config.HTTPAddr, "httpAddr": config.HTTPAddr,
"socketPath": config.SocketPath, "socketPath": config.SocketPath,
"pingInterval": config.PingInterval, "pingInterval": config.PingInterval,
"pingTimeout": config.PingTimeout, "pingTimeout": config.PingTimeout,
"enableApi": config.EnableAPI, "enableApi": config.EnableAPI,
"holepunch": config.Holepunch, "disableHolepunch": config.DisableHolepunch,
"overrideDNS": config.OverrideDNS, "overrideDNS": config.OverrideDNS,
"disableRelay": config.DisableRelay, "disableRelay": config.DisableRelay,
"tunnelDNS": config.TunnelDNS,
// "doNotCreateNewClient": config.DoNotCreateNewClient, // "doNotCreateNewClient": config.DoNotCreateNewClient,
} }
@@ -315,9 +324,10 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
serviceFlags.StringVar(&config.PingInterval, "ping-interval", config.PingInterval, "Interval for pinging the server") serviceFlags.StringVar(&config.PingInterval, "ping-interval", config.PingInterval, "Interval for pinging the server")
serviceFlags.StringVar(&config.PingTimeout, "ping-timeout", config.PingTimeout, "Timeout for each ping") serviceFlags.StringVar(&config.PingTimeout, "ping-timeout", config.PingTimeout, "Timeout for each ping")
serviceFlags.BoolVar(&config.EnableAPI, "enable-api", config.EnableAPI, "Enable API server for receiving connection requests") serviceFlags.BoolVar(&config.EnableAPI, "enable-api", config.EnableAPI, "Enable API server for receiving connection requests")
serviceFlags.BoolVar(&config.Holepunch, "holepunch", config.Holepunch, "Enable hole punching") serviceFlags.BoolVar(&config.DisableHolepunch, "disable-holepunch", config.DisableHolepunch, "Disable hole punching")
serviceFlags.BoolVar(&config.OverrideDNS, "override-dns", config.OverrideDNS, "Override system DNS settings") serviceFlags.BoolVar(&config.OverrideDNS, "override-dns", config.OverrideDNS, "When enabled, the client uses custom DNS servers to resolve internal resources and aliases. This overrides your system's default DNS settings. Queries that cannot be resolved as a Pangolin resource will be forwarded to your configured Upstream DNS Server. (default false)")
serviceFlags.BoolVar(&config.DisableRelay, "disable-relay", config.DisableRelay, "Disable relay connections") serviceFlags.BoolVar(&config.DisableRelay, "disable-relay", config.DisableRelay, "Disable relay connections")
serviceFlags.BoolVar(&config.TunnelDNS, "tunnel-dns", config.TunnelDNS, "When enabled, DNS queries are routed through the tunnel for remote resolution. To ensure queries are tunneled correctly, you must define the DNS server as a Pangolin resource and enter its address as an Upstream DNS Server. (default false)")
// serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client") // serviceFlags.BoolVar(&config.DoNotCreateNewClient, "do-not-create-new-client", config.DoNotCreateNewClient, "Do not create new client")
version := serviceFlags.Bool("version", false, "Print the version") version := serviceFlags.Bool("version", false, "Print the version")
@@ -384,8 +394,8 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
if config.EnableAPI != origValues["enableApi"].(bool) { if config.EnableAPI != origValues["enableApi"].(bool) {
config.sources["enableApi"] = string(SourceCLI) config.sources["enableApi"] = string(SourceCLI)
} }
if config.Holepunch != origValues["holepunch"].(bool) { if config.DisableHolepunch != origValues["disableHolepunch"].(bool) {
config.sources["holepunch"] = string(SourceCLI) config.sources["disableHolepunch"] = string(SourceCLI)
} }
if config.OverrideDNS != origValues["overrideDNS"].(bool) { if config.OverrideDNS != origValues["overrideDNS"].(bool) {
config.sources["overrideDNS"] = string(SourceCLI) config.sources["overrideDNS"] = string(SourceCLI)
@@ -393,6 +403,9 @@ func loadConfigFromCLI(config *OlmConfig, args []string) (bool, bool, error) {
if config.DisableRelay != origValues["disableRelay"].(bool) { if config.DisableRelay != origValues["disableRelay"].(bool) {
config.sources["disableRelay"] = string(SourceCLI) config.sources["disableRelay"] = string(SourceCLI)
} }
if config.TunnelDNS != origValues["tunnelDNS"].(bool) {
config.sources["tunnelDNS"] = string(SourceCLI)
}
// if config.DoNotCreateNewClient != origValues["doNotCreateNewClient"].(bool) { // if config.DoNotCreateNewClient != origValues["doNotCreateNewClient"].(bool) {
// config.sources["doNotCreateNewClient"] = string(SourceCLI) // config.sources["doNotCreateNewClient"] = string(SourceCLI)
// } // }
@@ -505,9 +518,9 @@ func mergeConfigs(dest, src *OlmConfig) {
dest.EnableAPI = src.EnableAPI dest.EnableAPI = src.EnableAPI
dest.sources["enableApi"] = string(SourceFile) dest.sources["enableApi"] = string(SourceFile)
} }
if src.Holepunch { if src.DisableHolepunch {
dest.Holepunch = src.Holepunch dest.DisableHolepunch = src.DisableHolepunch
dest.sources["holepunch"] = string(SourceFile) dest.sources["disableHolepunch"] = string(SourceFile)
} }
if src.OverrideDNS { if src.OverrideDNS {
dest.OverrideDNS = src.OverrideDNS dest.OverrideDNS = src.OverrideDNS
@@ -604,8 +617,9 @@ func (c *OlmConfig) ShowConfig() {
// Advanced // Advanced
fmt.Println("\nAdvanced:") fmt.Println("\nAdvanced:")
fmt.Printf(" holepunch = %v [%s]\n", c.Holepunch, getSource("holepunch")) fmt.Printf(" disable-holepunch = %v [%s]\n", c.DisableHolepunch, getSource("disableHolepunch"))
fmt.Printf(" override-dns = %v [%s]\n", c.OverrideDNS, getSource("overrideDNS")) fmt.Printf(" override-dns = %v [%s]\n", c.OverrideDNS, getSource("overrideDNS"))
fmt.Printf(" tunnel-dns = %v [%s]\n", c.TunnelDNS, getSource("tunnelDNS"))
fmt.Printf(" disable-relay = %v [%s]\n", c.DisableRelay, getSource("disableRelay")) fmt.Printf(" disable-relay = %v [%s]\n", c.DisableRelay, getSource("disableRelay"))
// fmt.Printf(" do-not-create-new-client = %v [%s]\n", c.DoNotCreateNewClient, getSource("doNotCreateNewClient")) // fmt.Printf(" do-not-create-new-client = %v [%s]\n", c.DoNotCreateNewClient, getSource("doNotCreateNewClient"))
if c.TlsClientCert != "" { if c.TlsClientCert != "" {

View File

@@ -1,9 +1,12 @@
package device package device
import ( import (
"io"
"net/netip" "net/netip"
"os" "os"
"sync" "sync"
"sync/atomic"
"time"
"github.com/fosrl/newt/logger" "github.com/fosrl/newt/logger"
"golang.zx2c4.com/wireguard/tun" "golang.zx2c4.com/wireguard/tun"
@@ -18,14 +21,68 @@ type FilterRule struct {
Handler PacketHandler Handler PacketHandler
} }
// MiddleDevice wraps a TUN device with packet filtering capabilities // closeAwareDevice wraps a tun.Device along with a flag
type MiddleDevice struct { // indicating whether its Close method was called.
type closeAwareDevice struct {
isClosed atomic.Bool
tun.Device tun.Device
rules []FilterRule closeEventCh chan struct{}
mutex sync.RWMutex wg sync.WaitGroup
readCh chan readResult closeOnce sync.Once
injectCh chan []byte }
closed chan struct{}
func newCloseAwareDevice(tunDevice tun.Device) *closeAwareDevice {
return &closeAwareDevice{
Device: tunDevice,
isClosed: atomic.Bool{},
closeEventCh: make(chan struct{}),
}
}
// redirectEvents redirects the Events() method of the underlying tun.Device
// to the given channel.
func (c *closeAwareDevice) redirectEvents(out chan tun.Event) {
c.wg.Add(1)
go func() {
defer c.wg.Done()
for {
select {
case ev, ok := <-c.Device.Events():
if !ok {
return
}
if ev == tun.EventDown {
continue
}
select {
case out <- ev:
case <-c.closeEventCh:
return
}
case <-c.closeEventCh:
return
}
}
}()
}
// Close calls the underlying Device's Close method
// after setting isClosed to true.
func (c *closeAwareDevice) Close() (err error) {
c.closeOnce.Do(func() {
c.isClosed.Store(true)
close(c.closeEventCh)
err = c.Device.Close()
c.wg.Wait()
})
return err
}
func (c *closeAwareDevice) IsClosed() bool {
return c.isClosed.Load()
} }
type readResult struct { type readResult struct {
@@ -36,58 +93,136 @@ type readResult struct {
err error err error
} }
// MiddleDevice wraps a TUN device with packet filtering capabilities
// and supports swapping the underlying device.
type MiddleDevice struct {
devices []*closeAwareDevice
mu sync.Mutex
cond *sync.Cond
rules []FilterRule
rulesMutex sync.RWMutex
readCh chan readResult
injectCh chan []byte
closed atomic.Bool
events chan tun.Event
}
// NewMiddleDevice creates a new filtered TUN device wrapper // NewMiddleDevice creates a new filtered TUN device wrapper
func NewMiddleDevice(device tun.Device) *MiddleDevice { func NewMiddleDevice(device tun.Device) *MiddleDevice {
d := &MiddleDevice{ d := &MiddleDevice{
Device: device, devices: make([]*closeAwareDevice, 0),
rules: make([]FilterRule, 0), rules: make([]FilterRule, 0),
readCh: make(chan readResult), readCh: make(chan readResult, 16),
injectCh: make(chan []byte, 100), injectCh: make(chan []byte, 100),
closed: make(chan struct{}), events: make(chan tun.Event, 16),
} }
go d.pump() d.cond = sync.NewCond(&d.mu)
if device != nil {
d.AddDevice(device)
}
return d return d
} }
func (d *MiddleDevice) pump() { // AddDevice adds a new underlying TUN device, closing any previous one
func (d *MiddleDevice) AddDevice(device tun.Device) {
d.mu.Lock()
if d.closed.Load() {
d.mu.Unlock()
_ = device.Close()
return
}
var toClose *closeAwareDevice
if len(d.devices) > 0 {
toClose = d.devices[len(d.devices)-1]
}
cad := newCloseAwareDevice(device)
cad.redirectEvents(d.events)
d.devices = []*closeAwareDevice{cad}
// Start pump for the new device
go d.pump(cad)
d.cond.Broadcast()
d.mu.Unlock()
if toClose != nil {
logger.Debug("MiddleDevice: Closing previous device")
if err := toClose.Close(); err != nil {
logger.Debug("MiddleDevice: Error closing previous device: %v", err)
}
}
}
func (d *MiddleDevice) pump(dev *closeAwareDevice) {
const defaultOffset = 16 const defaultOffset = 16
batchSize := d.Device.BatchSize() batchSize := dev.BatchSize()
logger.Debug("MiddleDevice: pump started") logger.Debug("MiddleDevice: pump started for device")
// Recover from panic if readCh is closed while we're trying to send
defer func() {
if r := recover(); r != nil {
logger.Debug("MiddleDevice: pump recovered from panic (channel closed)")
}
}()
for { for {
// Check closed first with priority // Check if this device is closed
select { if dev.IsClosed() {
case <-d.closed: logger.Debug("MiddleDevice: pump exiting, device is closed")
logger.Debug("MiddleDevice: pump exiting due to closed channel") return
}
// Check if MiddleDevice itself is closed
if d.closed.Load() {
logger.Debug("MiddleDevice: pump exiting, MiddleDevice is closed")
return return
default:
} }
// Allocate buffers for reading // Allocate buffers for reading
// We allocate new buffers for each read to avoid race conditions
// since we pass them to the channel
bufs := make([][]byte, batchSize) bufs := make([][]byte, batchSize)
sizes := make([]int, batchSize) sizes := make([]int, batchSize)
for i := range bufs { for i := range bufs {
bufs[i] = make([]byte, 2048) // Standard MTU + headroom bufs[i] = make([]byte, 2048) // Standard MTU + headroom
} }
n, err := d.Device.Read(bufs, sizes, defaultOffset) n, err := dev.Read(bufs, sizes, defaultOffset)
// Check closed again after read returns // Check if device was closed during read
select { if dev.IsClosed() {
case <-d.closed: logger.Debug("MiddleDevice: pump exiting, device closed during read")
logger.Debug("MiddleDevice: pump exiting due to closed channel (after read)") return
}
// Check if MiddleDevice was closed during read
if d.closed.Load() {
logger.Debug("MiddleDevice: pump exiting, MiddleDevice closed during read")
return
}
// Try to send the result - check closed state first to avoid sending on closed channel
if d.closed.Load() {
logger.Debug("MiddleDevice: pump exiting, device closed before send")
return return
default:
} }
// Now try to send the result
select { select {
case d.readCh <- readResult{bufs: bufs, sizes: sizes, offset: defaultOffset, n: n, err: err}: case d.readCh <- readResult{bufs: bufs, sizes: sizes, offset: defaultOffset, n: n, err: err}:
case <-d.closed: default:
logger.Debug("MiddleDevice: pump exiting due to closed channel (during send)") // Channel full, check if we should exit
return if dev.IsClosed() || d.closed.Load() {
return
}
// Try again with blocking
select {
case d.readCh <- readResult{bufs: bufs, sizes: sizes, offset: defaultOffset, n: n, err: err}:
case <-dev.closeEventCh:
return
}
} }
if err != nil { if err != nil {
@@ -99,16 +234,28 @@ func (d *MiddleDevice) pump() {
// InjectOutbound injects a packet to be read by WireGuard (as if it came from TUN) // InjectOutbound injects a packet to be read by WireGuard (as if it came from TUN)
func (d *MiddleDevice) InjectOutbound(packet []byte) { func (d *MiddleDevice) InjectOutbound(packet []byte) {
if d.closed.Load() {
return
}
// Use defer/recover to handle panic from sending on closed channel
// This can happen during shutdown race conditions
defer func() {
if r := recover(); r != nil {
logger.Debug("MiddleDevice: InjectOutbound recovered from panic (channel closed)")
}
}()
select { select {
case d.injectCh <- packet: case d.injectCh <- packet:
case <-d.closed: default:
// Channel full, drop packet
logger.Debug("MiddleDevice: InjectOutbound dropping packet, channel full")
} }
} }
// AddRule adds a packet filtering rule // AddRule adds a packet filtering rule
func (d *MiddleDevice) AddRule(destIP netip.Addr, handler PacketHandler) { func (d *MiddleDevice) AddRule(destIP netip.Addr, handler PacketHandler) {
d.mutex.Lock() d.rulesMutex.Lock()
defer d.mutex.Unlock() defer d.rulesMutex.Unlock()
d.rules = append(d.rules, FilterRule{ d.rules = append(d.rules, FilterRule{
DestIP: destIP, DestIP: destIP,
Handler: handler, Handler: handler,
@@ -117,8 +264,8 @@ func (d *MiddleDevice) AddRule(destIP netip.Addr, handler PacketHandler) {
// RemoveRule removes all rules for a given destination IP // RemoveRule removes all rules for a given destination IP
func (d *MiddleDevice) RemoveRule(destIP netip.Addr) { func (d *MiddleDevice) RemoveRule(destIP netip.Addr) {
d.mutex.Lock() d.rulesMutex.Lock()
defer d.mutex.Unlock() defer d.rulesMutex.Unlock()
newRules := make([]FilterRule, 0, len(d.rules)) newRules := make([]FilterRule, 0, len(d.rules))
for _, rule := range d.rules { for _, rule := range d.rules {
if rule.DestIP != destIP { if rule.DestIP != destIP {
@@ -130,18 +277,120 @@ func (d *MiddleDevice) RemoveRule(destIP netip.Addr) {
// Close stops the device // Close stops the device
func (d *MiddleDevice) Close() error { func (d *MiddleDevice) Close() error {
select { if !d.closed.CompareAndSwap(false, true) {
case <-d.closed: return nil // already closed
// Already closed
return nil
default:
logger.Debug("MiddleDevice: Closing, signaling closed channel")
close(d.closed)
} }
logger.Debug("MiddleDevice: Closing underlying TUN device")
err := d.Device.Close() d.mu.Lock()
logger.Debug("MiddleDevice: Underlying TUN device closed, err=%v", err) devices := d.devices
return err d.devices = nil
d.cond.Broadcast()
d.mu.Unlock()
// Close underlying devices first - this causes the pump goroutines to exit
// when their read operations return errors
var lastErr error
logger.Debug("MiddleDevice: Closing %d devices", len(devices))
for _, device := range devices {
if err := device.Close(); err != nil {
logger.Debug("MiddleDevice: Error closing device: %v", err)
lastErr = err
}
}
// Now close channels to unblock any remaining readers
// The pump should have exited by now, but close channels to be safe
close(d.readCh)
close(d.injectCh)
close(d.events)
return lastErr
}
// Events returns the events channel
func (d *MiddleDevice) Events() <-chan tun.Event {
return d.events
}
// File returns the underlying file descriptor
func (d *MiddleDevice) File() *os.File {
for {
dev := d.peekLast()
if dev == nil {
if !d.waitForDevice() {
return nil
}
continue
}
file := dev.File()
if dev.IsClosed() {
time.Sleep(1 * time.Millisecond)
continue
}
return file
}
}
// MTU returns the MTU of the underlying device
func (d *MiddleDevice) MTU() (int, error) {
for {
dev := d.peekLast()
if dev == nil {
if !d.waitForDevice() {
return 0, io.EOF
}
continue
}
mtu, err := dev.MTU()
if err == nil {
return mtu, nil
}
if dev.IsClosed() {
time.Sleep(1 * time.Millisecond)
continue
}
return 0, err
}
}
// Name returns the name of the underlying device
func (d *MiddleDevice) Name() (string, error) {
for {
dev := d.peekLast()
if dev == nil {
if !d.waitForDevice() {
return "", io.EOF
}
continue
}
name, err := dev.Name()
if err == nil {
return name, nil
}
if dev.IsClosed() {
time.Sleep(1 * time.Millisecond)
continue
}
return "", err
}
}
// BatchSize returns the batch size
func (d *MiddleDevice) BatchSize() int {
dev := d.peekLast()
if dev == nil {
return 1
}
return dev.BatchSize()
} }
// extractDestIP extracts destination IP from packet (fast path) // extractDestIP extracts destination IP from packet (fast path)
@@ -176,156 +425,239 @@ func extractDestIP(packet []byte) (netip.Addr, bool) {
// Read intercepts packets going UP from the TUN device (towards WireGuard) // Read intercepts packets going UP from the TUN device (towards WireGuard)
func (d *MiddleDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) { func (d *MiddleDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
// Check if already closed first (non-blocking) for {
select { if d.closed.Load() {
case <-d.closed: logger.Debug("MiddleDevice: Read returning io.EOF, device closed")
logger.Debug("MiddleDevice: Read returning os.ErrClosed (pre-check)") return 0, io.EOF
return 0, os.ErrClosed
default:
}
// Now block waiting for data
select {
case res := <-d.readCh:
if res.err != nil {
logger.Debug("MiddleDevice: Read returning error from pump: %v", res.err)
return 0, res.err
} }
// Copy packets from result to provided buffers // Wait for a device to be available
count := 0 dev := d.peekLast()
for i := 0; i < res.n && i < len(bufs); i++ { if dev == nil {
// Handle offset mismatch if necessary if !d.waitForDevice() {
// We assume the pump used defaultOffset (16) return 0, io.EOF
// If caller asks for different offset, we need to shift
src := res.bufs[i]
srcOffset := res.offset
srcSize := res.sizes[i]
// Calculate where the packet data starts and ends in src
pktData := src[srcOffset : srcOffset+srcSize]
// Ensure dest buffer is large enough
if len(bufs[i]) < offset+len(pktData) {
continue // Skip if buffer too small
} }
copy(bufs[i][offset:], pktData)
sizes[i] = len(pktData)
count++
}
n = count
case pkt := <-d.injectCh:
if len(bufs) == 0 {
return 0, nil
}
if len(bufs[0]) < offset+len(pkt) {
return 0, nil // Buffer too small
}
copy(bufs[0][offset:], pkt)
sizes[0] = len(pkt)
n = 1
case <-d.closed:
logger.Debug("MiddleDevice: Read returning os.ErrClosed")
return 0, os.ErrClosed // Signal that device is closed
}
d.mutex.RLock()
rules := d.rules
d.mutex.RUnlock()
if len(rules) == 0 {
return n, nil
}
// Process packets and filter out handled ones
writeIdx := 0
for readIdx := 0; readIdx < n; readIdx++ {
packet := bufs[readIdx][offset : offset+sizes[readIdx]]
destIP, ok := extractDestIP(packet)
if !ok {
// Can't parse, keep packet
if writeIdx != readIdx {
bufs[writeIdx] = bufs[readIdx]
sizes[writeIdx] = sizes[readIdx]
}
writeIdx++
continue continue
} }
// Check if packet matches any rule // Now block waiting for data from readCh or injectCh
handled := false select {
for _, rule := range rules { case res, ok := <-d.readCh:
if rule.DestIP == destIP { if !ok {
if rule.Handler(packet) { // Channel closed, device is shutting down
// Packet was handled and should be dropped return 0, io.EOF
handled = true }
break if res.err != nil {
// Check if device was swapped
if dev.IsClosed() {
time.Sleep(1 * time.Millisecond)
continue
}
logger.Debug("MiddleDevice: Read returning error from pump: %v", res.err)
return 0, res.err
}
// Copy packets from result to provided buffers
count := 0
for i := 0; i < res.n && i < len(bufs); i++ {
src := res.bufs[i]
srcOffset := res.offset
srcSize := res.sizes[i]
pktData := src[srcOffset : srcOffset+srcSize]
if len(bufs[i]) < offset+len(pktData) {
continue
}
copy(bufs[i][offset:], pktData)
sizes[i] = len(pktData)
count++
}
n = count
case pkt, ok := <-d.injectCh:
if !ok {
// Channel closed, device is shutting down
return 0, io.EOF
}
if len(bufs) == 0 {
return 0, nil
}
if len(bufs[0]) < offset+len(pkt) {
return 0, nil
}
copy(bufs[0][offset:], pkt)
sizes[0] = len(pkt)
n = 1
}
// Apply filtering rules
d.rulesMutex.RLock()
rules := d.rules
d.rulesMutex.RUnlock()
if len(rules) == 0 {
return n, nil
}
// Process packets and filter out handled ones
writeIdx := 0
for readIdx := 0; readIdx < n; readIdx++ {
packet := bufs[readIdx][offset : offset+sizes[readIdx]]
destIP, ok := extractDestIP(packet)
if !ok {
if writeIdx != readIdx {
bufs[writeIdx] = bufs[readIdx]
sizes[writeIdx] = sizes[readIdx]
}
writeIdx++
continue
}
handled := false
for _, rule := range rules {
if rule.DestIP == destIP {
if rule.Handler(packet) {
handled = true
break
}
} }
} }
}
if !handled { if !handled {
// Keep packet if writeIdx != readIdx {
if writeIdx != readIdx { bufs[writeIdx] = bufs[readIdx]
bufs[writeIdx] = bufs[readIdx] sizes[writeIdx] = sizes[readIdx]
sizes[writeIdx] = sizes[readIdx] }
writeIdx++
} }
writeIdx++
} }
}
return writeIdx, err return writeIdx, nil
}
} }
// Write intercepts packets going DOWN to the TUN device (from WireGuard) // Write intercepts packets going DOWN to the TUN device (from WireGuard)
func (d *MiddleDevice) Write(bufs [][]byte, offset int) (int, error) { func (d *MiddleDevice) Write(bufs [][]byte, offset int) (int, error) {
d.mutex.RLock() for {
rules := d.rules if d.closed.Load() {
d.mutex.RUnlock() return 0, io.EOF
}
if len(rules) == 0 { dev := d.peekLast()
return d.Device.Write(bufs, offset) if dev == nil {
} if !d.waitForDevice() {
return 0, io.EOF
// Filter packets going down }
filteredBufs := make([][]byte, 0, len(bufs))
for _, buf := range bufs {
if len(buf) <= offset {
continue continue
} }
packet := buf[offset:] d.rulesMutex.RLock()
destIP, ok := extractDestIP(packet) rules := d.rules
if !ok { d.rulesMutex.RUnlock()
// Can't parse, keep packet
filteredBufs = append(filteredBufs, buf)
continue
}
// Check if packet matches any rule var filteredBufs [][]byte
handled := false if len(rules) == 0 {
for _, rule := range rules { filteredBufs = bufs
if rule.DestIP == destIP { } else {
if rule.Handler(packet) { filteredBufs = make([][]byte, 0, len(bufs))
// Packet was handled and should be dropped for _, buf := range bufs {
handled = true if len(buf) <= offset {
break continue
}
packet := buf[offset:]
destIP, ok := extractDestIP(packet)
if !ok {
filteredBufs = append(filteredBufs, buf)
continue
}
handled := false
for _, rule := range rules {
if rule.DestIP == destIP {
if rule.Handler(packet) {
handled = true
break
}
}
}
if !handled {
filteredBufs = append(filteredBufs, buf)
} }
} }
} }
if !handled { if len(filteredBufs) == 0 {
filteredBufs = append(filteredBufs, buf) return len(bufs), nil
} }
}
if len(filteredBufs) == 0 { n, err := dev.Write(filteredBufs, offset)
return len(bufs), nil // All packets were handled if err == nil {
} return n, nil
}
return d.Device.Write(filteredBufs, offset) if dev.IsClosed() {
time.Sleep(1 * time.Millisecond)
continue
}
return n, err
}
} }
func (d *MiddleDevice) waitForDevice() bool {
d.mu.Lock()
defer d.mu.Unlock()
for len(d.devices) == 0 && !d.closed.Load() {
d.cond.Wait()
}
return !d.closed.Load()
}
func (d *MiddleDevice) peekLast() *closeAwareDevice {
d.mu.Lock()
defer d.mu.Unlock()
if len(d.devices) == 0 {
return nil
}
return d.devices[len(d.devices)-1]
}
// WriteToTun writes packets directly to the underlying TUN device,
// bypassing WireGuard. This is useful for sending packets that should
// appear to come from the TUN interface (e.g., DNS responses from a proxy).
// Unlike Write(), this does not go through packet filtering rules.
func (d *MiddleDevice) WriteToTun(bufs [][]byte, offset int) (int, error) {
for {
if d.closed.Load() {
return 0, io.EOF
}
dev := d.peekLast()
if dev == nil {
if !d.waitForDevice() {
return 0, io.EOF
}
continue
}
n, err := dev.Write(bufs, offset)
if err == nil {
return n, nil
}
if dev.IsClosed() {
time.Sleep(1 * time.Millisecond)
continue
}
return n, err
}
}

View File

@@ -1,4 +1,4 @@
//go:build !windows //go:build darwin
package device package device
@@ -26,7 +26,7 @@ func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
} }
file := os.NewFile(uintptr(dupTunFd), "/dev/tun") file := os.NewFile(uintptr(dupTunFd), "/dev/tun")
device, err := tun.CreateTUNFromFile(file, mtuInt) device, err := tun.CreateTUNFromFile(file, 0)
if err != nil { if err != nil {
file.Close() file.Close()
return nil, err return nil, err

50
device/tun_linux.go Normal file
View File

@@ -0,0 +1,50 @@
//go:build linux
package device
import (
"net"
"os"
"runtime"
"github.com/fosrl/newt/logger"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/ipc"
"golang.zx2c4.com/wireguard/tun"
)
func CreateTUNFromFD(tunFd uint32, mtuInt int) (tun.Device, error) {
if runtime.GOOS == "android" { // otherwise we get a permission denied
theTun, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFd))
return theTun, err
}
dupTunFd, err := unix.Dup(int(tunFd))
if err != nil {
logger.Error("Unable to dup tun fd: %v", err)
return nil, err
}
err = unix.SetNonblock(dupTunFd, true)
if err != nil {
unix.Close(dupTunFd)
return nil, err
}
file := os.NewFile(uintptr(dupTunFd), "/dev/tun")
device, err := tun.CreateTUNFromFile(file, mtuInt)
if err != nil {
file.Close()
return nil, err
}
return device, nil
}
func UapiOpen(interfaceName string) (*os.File, error) {
return ipc.UAPIOpen(interfaceName)
}
func UapiListen(interfaceName string, fileUAPI *os.File) (net.Listener, error) {
return ipc.UAPIListen(interfaceName, fileUAPI)
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/fosrl/newt/util" "github.com/fosrl/newt/util"
"github.com/fosrl/olm/device" "github.com/fosrl/olm/device"
"github.com/miekg/dns" "github.com/miekg/dns"
"golang.zx2c4.com/wireguard/tun"
"gvisor.dev/gvisor/pkg/buffer" "gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
@@ -34,18 +33,25 @@ type DNSProxy struct {
ep *channel.Endpoint ep *channel.Endpoint
proxyIP netip.Addr proxyIP netip.Addr
upstreamDNS []string upstreamDNS []string
tunnelDNS bool // Whether to tunnel DNS queries over WireGuard or to spit them out locally
mtu int mtu int
tunDevice tun.Device // Direct reference to underlying TUN device for responses middleDevice *device.MiddleDevice // Reference to MiddleDevice for packet filtering and TUN writes
middleDevice *device.MiddleDevice // Reference to MiddleDevice for packet filtering
recordStore *DNSRecordStore // Local DNS records recordStore *DNSRecordStore // Local DNS records
// Tunnel DNS fields - for sending queries over WireGuard
tunnelIP netip.Addr // WireGuard interface IP (source for tunneled queries)
tunnelStack *stack.Stack // Separate netstack for outbound tunnel queries
tunnelEp *channel.Endpoint
tunnelActivePorts map[uint16]bool
tunnelPortsLock sync.Mutex
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
} }
// NewDNSProxy creates a new DNS proxy // NewDNSProxy creates a new DNS proxy
func NewDNSProxy(tunDevice tun.Device, middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string) (*DNSProxy, error) { func NewDNSProxy(middleDevice *device.MiddleDevice, mtu int, utilitySubnet string, upstreamDns []string, tunnelDns bool, tunnelIP string) (*DNSProxy, error) {
proxyIP, err := PickIPFromSubnet(utilitySubnet) proxyIP, err := PickIPFromSubnet(utilitySubnet)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to pick DNS proxy IP from subnet: %v", err) return nil, fmt.Errorf("failed to pick DNS proxy IP from subnet: %v", err)
@@ -58,17 +64,27 @@ func NewDNSProxy(tunDevice tun.Device, middleDevice *device.MiddleDevice, mtu in
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
proxy := &DNSProxy{ proxy := &DNSProxy{
proxyIP: proxyIP, proxyIP: proxyIP,
mtu: mtu, mtu: mtu,
tunDevice: tunDevice, middleDevice: middleDevice,
middleDevice: middleDevice, upstreamDNS: upstreamDns,
upstreamDNS: upstreamDns, tunnelDNS: tunnelDns,
recordStore: NewDNSRecordStore(), recordStore: NewDNSRecordStore(),
ctx: ctx, tunnelActivePorts: make(map[uint16]bool),
cancel: cancel, ctx: ctx,
cancel: cancel,
} }
// Create gvisor netstack // Parse tunnel IP if provided (needed for tunneled DNS)
if tunnelIP != "" {
addr, err := netip.ParseAddr(tunnelIP)
if err != nil {
return nil, fmt.Errorf("failed to parse tunnel IP: %v", err)
}
proxy.tunnelIP = addr
}
// Create gvisor netstack for receiving DNS queries
stackOpts := stack.Options{ stackOpts := stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol}, NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{udp.NewProtocol}, TransportProtocols: []stack.TransportProtocolFactory{udp.NewProtocol},
@@ -101,9 +117,104 @@ func NewDNSProxy(tunDevice tun.Device, middleDevice *device.MiddleDevice, mtu in
NIC: 1, NIC: 1,
}) })
// Initialize tunnel netstack if tunnel DNS is enabled
if tunnelDns {
if !proxy.tunnelIP.IsValid() {
return nil, fmt.Errorf("tunnel IP is required when tunnelDNS is enabled")
}
// TODO: DO WE NEED TO ESTABLISH ANOTHER NETSTACK HERE OR CAN WE COMBINE WITH WGTESTER?
if err := proxy.initTunnelNetstack(); err != nil {
return nil, fmt.Errorf("failed to initialize tunnel netstack: %v", err)
}
}
return proxy, nil return proxy, nil
} }
// initTunnelNetstack creates a separate netstack for outbound DNS queries through the tunnel
func (p *DNSProxy) initTunnelNetstack() error {
// Create gvisor netstack for outbound tunnel queries
stackOpts := stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{udp.NewProtocol},
HandleLocal: true,
}
p.tunnelEp = channel.New(256, uint32(p.mtu), "")
p.tunnelStack = stack.New(stackOpts)
// Create NIC
if err := p.tunnelStack.CreateNIC(1, p.tunnelEp); err != nil {
return fmt.Errorf("failed to create tunnel NIC: %v", err)
}
// Add tunnel IP address (WireGuard interface IP)
ipBytes := p.tunnelIP.As4()
protoAddr := tcpip.ProtocolAddress{
Protocol: ipv4.ProtocolNumber,
AddressWithPrefix: tcpip.AddrFrom4(ipBytes).WithPrefix(),
}
if err := p.tunnelStack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}); err != nil {
return fmt.Errorf("failed to add tunnel protocol address: %v", err)
}
// Add default route
p.tunnelStack.AddRoute(tcpip.Route{
Destination: header.IPv4EmptySubnet,
NIC: 1,
})
// Register filter rule on MiddleDevice to intercept responses
p.middleDevice.AddRule(p.tunnelIP, p.handleTunnelResponse)
return nil
}
// handleTunnelResponse handles packets coming back from the tunnel destined for the tunnel IP
func (p *DNSProxy) handleTunnelResponse(packet []byte) bool {
// Check if it's UDP
proto, ok := util.GetProtocol(packet)
if !ok || proto != 17 { // UDP
return false
}
// Check destination port - should be one of our active outbound ports
port, ok := util.GetDestPort(packet)
if !ok {
return false
}
// Check if we are expecting a response on this port
p.tunnelPortsLock.Lock()
active := p.tunnelActivePorts[uint16(port)]
p.tunnelPortsLock.Unlock()
if !active {
return false
}
// Inject into tunnel netstack
version := packet[0] >> 4
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(packet),
})
switch version {
case 4:
p.tunnelEp.InjectInbound(ipv4.ProtocolNumber, pkb)
case 6:
p.tunnelEp.InjectInbound(ipv6.ProtocolNumber, pkb)
default:
pkb.DecRef()
return false
}
pkb.DecRef()
return true // Handled
}
// Start starts the DNS proxy and registers with the filter // Start starts the DNS proxy and registers with the filter
func (p *DNSProxy) Start() error { func (p *DNSProxy) Start() error {
// Install packet filter rule // Install packet filter rule
@@ -114,7 +225,13 @@ func (p *DNSProxy) Start() error {
go p.runDNSListener() go p.runDNSListener()
go p.runPacketSender() go p.runPacketSender()
logger.Info("DNS proxy started on %s:%d", p.proxyIP.String(), DNSPort) // Start tunnel packet sender if tunnel DNS is enabled
if p.tunnelDNS {
p.wg.Add(1)
go p.runTunnelPacketSender()
}
logger.Info("DNS proxy started on %s:%d (tunnelDNS=%v)", p.proxyIP.String(), DNSPort, p.tunnelDNS)
return nil return nil
} }
@@ -122,6 +239,9 @@ func (p *DNSProxy) Start() error {
func (p *DNSProxy) Stop() { func (p *DNSProxy) Stop() {
if p.middleDevice != nil { if p.middleDevice != nil {
p.middleDevice.RemoveRule(p.proxyIP) p.middleDevice.RemoveRule(p.proxyIP)
if p.tunnelDNS && p.tunnelIP.IsValid() {
p.middleDevice.RemoveRule(p.tunnelIP)
}
} }
p.cancel() p.cancel()
@@ -130,12 +250,21 @@ func (p *DNSProxy) Stop() {
p.ep.Close() p.ep.Close()
} }
// Close tunnel endpoint if it exists
if p.tunnelEp != nil {
p.tunnelEp.Close()
}
p.wg.Wait() p.wg.Wait()
if p.stack != nil { if p.stack != nil {
p.stack.Close() p.stack.Close()
} }
if p.tunnelStack != nil {
p.tunnelStack.Close()
}
logger.Info("DNS proxy stopped") logger.Info("DNS proxy stopped")
} }
@@ -251,7 +380,7 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie
// Check if we have local records for this query // Check if we have local records for this query
var response *dns.Msg var response *dns.Msg
if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA { if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA || question.Qtype == dns.TypePTR {
response = p.checkLocalRecords(msg, question) response = p.checkLocalRecords(msg, question)
} }
@@ -281,6 +410,34 @@ func (p *DNSProxy) handleDNSQuery(udpConn *gonet.UDPConn, queryData []byte, clie
// checkLocalRecords checks if we have local records for the query // checkLocalRecords checks if we have local records for the query
func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns.Msg { func (p *DNSProxy) checkLocalRecords(query *dns.Msg, question dns.Question) *dns.Msg {
// Handle PTR queries
if question.Qtype == dns.TypePTR {
if ptrDomain, ok := p.recordStore.GetPTRRecord(question.Name); ok {
logger.Debug("Found local PTR record for %s -> %s", question.Name, ptrDomain)
// Create response message
response := new(dns.Msg)
response.SetReply(query)
response.Authoritative = true
// Add PTR answer record
rr := &dns.PTR{
Hdr: dns.RR_Header{
Name: question.Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: 300, // 5 minutes
},
Ptr: ptrDomain,
}
response.Answer = append(response.Answer, rr)
return response
}
return nil
}
// Handle A and AAAA queries
var recordType RecordType var recordType RecordType
if question.Qtype == dns.TypeA { if question.Qtype == dns.TypeA {
recordType = RecordTypeA recordType = RecordTypeA
@@ -348,8 +505,16 @@ func (p *DNSProxy) forwardToUpstream(query *dns.Msg) *dns.Msg {
return response return response
} }
// queryUpstream sends a DNS query to upstream server using miekg/dns // queryUpstream sends a DNS query to upstream server
func (p *DNSProxy) queryUpstream(server string, query *dns.Msg, timeout time.Duration) (*dns.Msg, error) { func (p *DNSProxy) queryUpstream(server string, query *dns.Msg, timeout time.Duration) (*dns.Msg, error) {
if p.tunnelDNS {
return p.queryUpstreamTunnel(server, query, timeout)
}
return p.queryUpstreamDirect(server, query, timeout)
}
// queryUpstreamDirect sends a DNS query to upstream server using miekg/dns directly (host networking)
func (p *DNSProxy) queryUpstreamDirect(server string, query *dns.Msg, timeout time.Duration) (*dns.Msg, error) {
client := &dns.Client{ client := &dns.Client{
Timeout: timeout, Timeout: timeout,
} }
@@ -362,6 +527,147 @@ func (p *DNSProxy) queryUpstream(server string, query *dns.Msg, timeout time.Dur
return response, nil return response, nil
} }
// queryUpstreamTunnel sends a DNS query through the WireGuard tunnel
func (p *DNSProxy) queryUpstreamTunnel(server string, query *dns.Msg, timeout time.Duration) (*dns.Msg, error) {
// Dial through the tunnel netstack
conn, port, err := p.dialTunnel("udp", server)
if err != nil {
return nil, fmt.Errorf("failed to dial tunnel: %v", err)
}
defer func() {
conn.Close()
p.removeTunnelPort(port)
}()
// Pack the query
queryData, err := query.Pack()
if err != nil {
return nil, fmt.Errorf("failed to pack query: %v", err)
}
// Set deadline
conn.SetDeadline(time.Now().Add(timeout))
// Send the query
_, err = conn.Write(queryData)
if err != nil {
return nil, fmt.Errorf("failed to send query: %v", err)
}
// Read the response
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return nil, fmt.Errorf("failed to read response: %v", err)
}
// Parse the response
response := new(dns.Msg)
if err := response.Unpack(buf[:n]); err != nil {
return nil, fmt.Errorf("failed to unpack response: %v", err)
}
return response, nil
}
// dialTunnel creates a UDP connection through the tunnel netstack
func (p *DNSProxy) dialTunnel(network, addr string) (net.Conn, uint16, error) {
if p.tunnelStack == nil {
return nil, 0, fmt.Errorf("tunnel netstack not initialized")
}
// Parse remote address
raddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return nil, 0, err
}
// Use tunnel IP as source
ipBytes := p.tunnelIP.As4()
// Create UDP connection with ephemeral port
laddr := &tcpip.FullAddress{
NIC: 1,
Addr: tcpip.AddrFrom4(ipBytes),
Port: 0,
}
raddrTcpip := &tcpip.FullAddress{
NIC: 1,
Addr: tcpip.AddrFrom4([4]byte(raddr.IP.To4())),
Port: uint16(raddr.Port),
}
conn, err := gonet.DialUDP(p.tunnelStack, laddr, raddrTcpip, ipv4.ProtocolNumber)
if err != nil {
return nil, 0, err
}
// Get local port
localAddr := conn.LocalAddr().(*net.UDPAddr)
port := uint16(localAddr.Port)
// Register port so we can receive responses
p.tunnelPortsLock.Lock()
p.tunnelActivePorts[port] = true
p.tunnelPortsLock.Unlock()
return conn, port, nil
}
// removeTunnelPort removes a port from the active ports map
func (p *DNSProxy) removeTunnelPort(port uint16) {
p.tunnelPortsLock.Lock()
delete(p.tunnelActivePorts, port)
p.tunnelPortsLock.Unlock()
}
// runTunnelPacketSender reads packets from tunnel netstack and injects them into WireGuard
func (p *DNSProxy) runTunnelPacketSender() {
defer p.wg.Done()
logger.Debug("DNS tunnel packet sender goroutine started")
for {
// Use blocking ReadContext instead of polling - much more CPU efficient
// This will block until a packet is available or context is cancelled
pkt := p.tunnelEp.ReadContext(p.ctx)
if pkt == nil {
// Context was cancelled or endpoint closed
logger.Debug("DNS tunnel packet sender exiting")
// Drain any remaining packets
for {
pkt := p.tunnelEp.Read()
if pkt == nil {
break
}
pkt.DecRef()
}
return
}
// Extract packet data
slices := pkt.AsSlices()
if len(slices) > 0 {
var totalSize int
for _, slice := range slices {
totalSize += len(slice)
}
buf := make([]byte, totalSize)
pos := 0
for _, slice := range slices {
copy(buf[pos:], slice)
pos += len(slice)
}
// Inject into MiddleDevice (outbound to WG)
p.middleDevice.InjectOutbound(buf)
}
pkt.DecRef()
}
}
// runPacketSender sends packets from netstack back to TUN // runPacketSender sends packets from netstack back to TUN
func (p *DNSProxy) runPacketSender() { func (p *DNSProxy) runPacketSender() {
defer p.wg.Done() defer p.wg.Done()
@@ -371,18 +677,12 @@ func (p *DNSProxy) runPacketSender() {
const offset = 16 const offset = 16
for { for {
select { // Use blocking ReadContext instead of polling - much more CPU efficient
case <-p.ctx.Done(): // This will block until a packet is available or context is cancelled
return pkt := p.ep.ReadContext(p.ctx)
default:
}
// Read packets from netstack endpoint
pkt := p.ep.Read()
if pkt == nil { if pkt == nil {
// No packet available, small sleep to avoid busy loop // Context was cancelled or endpoint closed
time.Sleep(1 * time.Millisecond) return
continue
} }
// Extract packet data as slices // Extract packet data as slices
@@ -405,9 +705,9 @@ func (p *DNSProxy) runPacketSender() {
pos += len(slice) pos += len(slice)
} }
// Write packet to TUN device // Write packet to TUN device via MiddleDevice
// offset=16 indicates packet data starts at position 16 in the buffer // offset=16 indicates packet data starts at position 16 in the buffer
_, err := p.tunDevice.Write([][]byte{buf}, offset) _, err := p.middleDevice.WriteToTun([][]byte{buf}, offset)
if err != nil { if err != nil {
logger.Error("Failed to write DNS response to TUN: %v", err) logger.Error("Failed to write DNS response to TUN: %v", err)
} }

View File

@@ -1,7 +1,9 @@
package dns package dns
import ( import (
"fmt"
"net" "net"
"strings"
"sync" "sync"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -13,26 +15,35 @@ type RecordType uint16
const ( const (
RecordTypeA RecordType = RecordType(dns.TypeA) RecordTypeA RecordType = RecordType(dns.TypeA)
RecordTypeAAAA RecordType = RecordType(dns.TypeAAAA) RecordTypeAAAA RecordType = RecordType(dns.TypeAAAA)
RecordTypePTR RecordType = RecordType(dns.TypePTR)
) )
// DNSRecordStore manages local DNS records for A and AAAA queries // DNSRecordStore manages local DNS records for A, AAAA, and PTR queries
type DNSRecordStore struct { type DNSRecordStore struct {
mu sync.RWMutex mu sync.RWMutex
aRecords map[string][]net.IP // domain -> list of IPv4 addresses aRecords map[string][]net.IP // domain -> list of IPv4 addresses
aaaaRecords map[string][]net.IP // domain -> list of IPv6 addresses aaaaRecords map[string][]net.IP // domain -> list of IPv6 addresses
aWildcards map[string][]net.IP // wildcard pattern -> list of IPv4 addresses
aaaaWildcards map[string][]net.IP // wildcard pattern -> list of IPv6 addresses
ptrRecords map[string]string // IP address string -> domain name
} }
// NewDNSRecordStore creates a new DNS record store // NewDNSRecordStore creates a new DNS record store
func NewDNSRecordStore() *DNSRecordStore { func NewDNSRecordStore() *DNSRecordStore {
return &DNSRecordStore{ return &DNSRecordStore{
aRecords: make(map[string][]net.IP), aRecords: make(map[string][]net.IP),
aaaaRecords: make(map[string][]net.IP), aaaaRecords: make(map[string][]net.IP),
aWildcards: make(map[string][]net.IP),
aaaaWildcards: make(map[string][]net.IP),
ptrRecords: make(map[string]string),
} }
} }
// AddRecord adds a DNS record mapping (A or AAAA) // AddRecord adds a DNS record mapping (A or AAAA)
// domain should be in FQDN format (e.g., "example.com.") // domain should be in FQDN format (e.g., "example.com.")
// domain can contain wildcards: * (0+ chars) and ? (exactly 1 char)
// ip should be a valid IPv4 or IPv6 address // ip should be a valid IPv4 or IPv6 address
// Automatically adds a corresponding PTR record for non-wildcard domains
func (s *DNSRecordStore) AddRecord(domain string, ip net.IP) error { func (s *DNSRecordStore) AddRecord(domain string, ip net.IP) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -42,15 +53,30 @@ func (s *DNSRecordStore) AddRecord(domain string, ip net.IP) error {
domain = domain + "." domain = domain + "."
} }
// Normalize domain to lowercase // Normalize domain to lowercase FQDN
domain = dns.Fqdn(domain) domain = strings.ToLower(dns.Fqdn(domain))
// Check if domain contains wildcards
isWildcard := strings.ContainsAny(domain, "*?")
if ip.To4() != nil { if ip.To4() != nil {
// IPv4 address // IPv4 address
s.aRecords[domain] = append(s.aRecords[domain], ip) if isWildcard {
s.aWildcards[domain] = append(s.aWildcards[domain], ip)
} else {
s.aRecords[domain] = append(s.aRecords[domain], ip)
// Automatically add PTR record for non-wildcard domains
s.ptrRecords[ip.String()] = domain
}
} else if ip.To16() != nil { } else if ip.To16() != nil {
// IPv6 address // IPv6 address
s.aaaaRecords[domain] = append(s.aaaaRecords[domain], ip) if isWildcard {
s.aaaaWildcards[domain] = append(s.aaaaWildcards[domain], ip)
} else {
s.aaaaRecords[domain] = append(s.aaaaRecords[domain], ip)
// Automatically add PTR record for non-wildcard domains
s.ptrRecords[ip.String()] = domain
}
} else { } else {
return &net.ParseError{Type: "IP address", Text: ip.String()} return &net.ParseError{Type: "IP address", Text: ip.String()}
} }
@@ -58,8 +84,30 @@ func (s *DNSRecordStore) AddRecord(domain string, ip net.IP) error {
return nil return nil
} }
// AddPTRRecord adds a PTR record mapping an IP address to a domain name
// ip should be a valid IPv4 or IPv6 address
// domain should be in FQDN format (e.g., "example.com.")
func (s *DNSRecordStore) AddPTRRecord(ip net.IP, domain string) error {
s.mu.Lock()
defer s.mu.Unlock()
// Ensure domain ends with a dot (FQDN format)
if len(domain) == 0 || domain[len(domain)-1] != '.' {
domain = domain + "."
}
// Normalize domain to lowercase FQDN
domain = strings.ToLower(dns.Fqdn(domain))
// Store PTR record using IP string as key
s.ptrRecords[ip.String()] = domain
return nil
}
// RemoveRecord removes a specific DNS record mapping // RemoveRecord removes a specific DNS record mapping
// If ip is nil, removes all records for the domain // If ip is nil, removes all records for the domain (including wildcards)
// Automatically removes corresponding PTR records for non-wildcard domains
func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) { func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -69,82 +117,223 @@ func (s *DNSRecordStore) RemoveRecord(domain string, ip net.IP) {
domain = domain + "." domain = domain + "."
} }
// Normalize domain to lowercase // Normalize domain to lowercase FQDN
domain = dns.Fqdn(domain) domain = strings.ToLower(dns.Fqdn(domain))
// Check if domain contains wildcards
isWildcard := strings.ContainsAny(domain, "*?")
if ip == nil { if ip == nil {
// Remove all records for this domain // Remove all records for this domain
delete(s.aRecords, domain) if isWildcard {
delete(s.aaaaRecords, domain) delete(s.aWildcards, domain)
delete(s.aaaaWildcards, domain)
} else {
// For non-wildcard domains, remove PTR records for all IPs
if ips, ok := s.aRecords[domain]; ok {
for _, ipAddr := range ips {
// Only remove PTR if it points to this domain
if ptrDomain, exists := s.ptrRecords[ipAddr.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ipAddr.String())
}
}
}
if ips, ok := s.aaaaRecords[domain]; ok {
for _, ipAddr := range ips {
// Only remove PTR if it points to this domain
if ptrDomain, exists := s.ptrRecords[ipAddr.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ipAddr.String())
}
}
}
delete(s.aRecords, domain)
delete(s.aaaaRecords, domain)
}
return return
} }
if ip.To4() != nil { if ip.To4() != nil {
// Remove specific IPv4 address // Remove specific IPv4 address
if ips, ok := s.aRecords[domain]; ok { if isWildcard {
s.aRecords[domain] = removeIP(ips, ip) if ips, ok := s.aWildcards[domain]; ok {
if len(s.aRecords[domain]) == 0 { s.aWildcards[domain] = removeIP(ips, ip)
delete(s.aRecords, domain) if len(s.aWildcards[domain]) == 0 {
delete(s.aWildcards, domain)
}
}
} else {
if ips, ok := s.aRecords[domain]; ok {
s.aRecords[domain] = removeIP(ips, ip)
if len(s.aRecords[domain]) == 0 {
delete(s.aRecords, domain)
}
// Automatically remove PTR record if it points to this domain
if ptrDomain, exists := s.ptrRecords[ip.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ip.String())
}
} }
} }
} else if ip.To16() != nil { } else if ip.To16() != nil {
// Remove specific IPv6 address // Remove specific IPv6 address
if ips, ok := s.aaaaRecords[domain]; ok { if isWildcard {
s.aaaaRecords[domain] = removeIP(ips, ip) if ips, ok := s.aaaaWildcards[domain]; ok {
if len(s.aaaaRecords[domain]) == 0 { s.aaaaWildcards[domain] = removeIP(ips, ip)
delete(s.aaaaRecords, domain) if len(s.aaaaWildcards[domain]) == 0 {
delete(s.aaaaWildcards, domain)
}
}
} else {
if ips, ok := s.aaaaRecords[domain]; ok {
s.aaaaRecords[domain] = removeIP(ips, ip)
if len(s.aaaaRecords[domain]) == 0 {
delete(s.aaaaRecords, domain)
}
// Automatically remove PTR record if it points to this domain
if ptrDomain, exists := s.ptrRecords[ip.String()]; exists && ptrDomain == domain {
delete(s.ptrRecords, ip.String())
}
} }
} }
} }
} }
// RemovePTRRecord removes a PTR record for an IP address
func (s *DNSRecordStore) RemovePTRRecord(ip net.IP) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.ptrRecords, ip.String())
}
// GetRecords returns all IP addresses for a domain and record type // GetRecords returns all IP addresses for a domain and record type
// First checks for exact matches, then checks wildcard patterns
func (s *DNSRecordStore) GetRecords(domain string, recordType RecordType) []net.IP { func (s *DNSRecordStore) GetRecords(domain string, recordType RecordType) []net.IP {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
// Normalize domain to lowercase FQDN // Normalize domain to lowercase FQDN
domain = dns.Fqdn(domain) domain = strings.ToLower(dns.Fqdn(domain))
var records []net.IP var records []net.IP
switch recordType { switch recordType {
case RecordTypeA: case RecordTypeA:
// Check exact match first
if ips, ok := s.aRecords[domain]; ok { if ips, ok := s.aRecords[domain]; ok {
// Return a copy to prevent external modifications // Return a copy to prevent external modifications
records = make([]net.IP, len(ips)) records = make([]net.IP, len(ips))
copy(records, ips) copy(records, ips)
return records
} }
// Check wildcard patterns
for pattern, ips := range s.aWildcards {
if matchWildcard(pattern, domain) {
records = append(records, ips...)
}
}
if len(records) > 0 {
// Return a copy
result := make([]net.IP, len(records))
copy(result, records)
return result
}
case RecordTypeAAAA: case RecordTypeAAAA:
// Check exact match first
if ips, ok := s.aaaaRecords[domain]; ok { if ips, ok := s.aaaaRecords[domain]; ok {
// Return a copy to prevent external modifications // Return a copy to prevent external modifications
records = make([]net.IP, len(ips)) records = make([]net.IP, len(ips))
copy(records, ips) copy(records, ips)
return records
}
// Check wildcard patterns
for pattern, ips := range s.aaaaWildcards {
if matchWildcard(pattern, domain) {
records = append(records, ips...)
}
}
if len(records) > 0 {
// Return a copy
result := make([]net.IP, len(records))
copy(result, records)
return result
} }
} }
return records return records
} }
// GetPTRRecord returns the domain name for a PTR record query
// domain should be in reverse DNS format (e.g., "1.0.0.127.in-addr.arpa.")
func (s *DNSRecordStore) GetPTRRecord(domain string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
// Convert reverse DNS format to IP address
ip := reverseDNSToIP(domain)
if ip == nil {
return "", false
}
// Look up the PTR record
if ptrDomain, ok := s.ptrRecords[ip.String()]; ok {
return ptrDomain, true
}
return "", false
}
// HasRecord checks if a domain has any records of the specified type // HasRecord checks if a domain has any records of the specified type
// Checks both exact matches and wildcard patterns
func (s *DNSRecordStore) HasRecord(domain string, recordType RecordType) bool { func (s *DNSRecordStore) HasRecord(domain string, recordType RecordType) bool {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
// Normalize domain to lowercase FQDN // Normalize domain to lowercase FQDN
domain = dns.Fqdn(domain) domain = strings.ToLower(dns.Fqdn(domain))
switch recordType { switch recordType {
case RecordTypeA: case RecordTypeA:
_, ok := s.aRecords[domain] // Check exact match
return ok if _, ok := s.aRecords[domain]; ok {
return true
}
// Check wildcard patterns
for pattern := range s.aWildcards {
if matchWildcard(pattern, domain) {
return true
}
}
case RecordTypeAAAA: case RecordTypeAAAA:
_, ok := s.aaaaRecords[domain] // Check exact match
return ok if _, ok := s.aaaaRecords[domain]; ok {
return true
}
// Check wildcard patterns
for pattern := range s.aaaaWildcards {
if matchWildcard(pattern, domain) {
return true
}
}
} }
return false return false
} }
// HasPTRRecord checks if a PTR record exists for the given reverse DNS domain
func (s *DNSRecordStore) HasPTRRecord(domain string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
// Convert reverse DNS format to IP address
ip := reverseDNSToIP(domain)
if ip == nil {
return false
}
_, ok := s.ptrRecords[ip.String()]
return ok
}
// Clear removes all records from the store // Clear removes all records from the store
func (s *DNSRecordStore) Clear() { func (s *DNSRecordStore) Clear() {
s.mu.Lock() s.mu.Lock()
@@ -152,6 +341,9 @@ func (s *DNSRecordStore) Clear() {
s.aRecords = make(map[string][]net.IP) s.aRecords = make(map[string][]net.IP)
s.aaaaRecords = make(map[string][]net.IP) s.aaaaRecords = make(map[string][]net.IP)
s.aWildcards = make(map[string][]net.IP)
s.aaaaWildcards = make(map[string][]net.IP)
s.ptrRecords = make(map[string]string)
} }
// removeIP is a helper function to remove a specific IP from a slice // removeIP is a helper function to remove a specific IP from a slice
@@ -164,3 +356,142 @@ func removeIP(ips []net.IP, toRemove net.IP) []net.IP {
} }
return result return result
} }
// matchWildcard checks if a domain matches a wildcard pattern
// Pattern supports * (0+ chars) and ? (exactly 1 char)
// Special case: *.domain.com does not match domain.com itself
func matchWildcard(pattern, domain string) bool {
return matchWildcardInternal(pattern, domain, 0, 0)
}
// matchWildcardInternal performs the actual wildcard matching recursively
func matchWildcardInternal(pattern, domain string, pi, di int) bool {
plen := len(pattern)
dlen := len(domain)
// Base cases
if pi == plen && di == dlen {
return true
}
if pi == plen {
return false
}
// Handle wildcard characters
if pattern[pi] == '*' {
// Special case: if pattern starts with "*." and we're at the beginning,
// ensure we don't match the domain without a prefix
// e.g., *.autoco.internal should not match autoco.internal
if pi == 0 && pi+1 < plen && pattern[pi+1] == '.' {
// The * must match at least one character
if di == dlen {
return false
}
// Try matching 1 or more characters before the dot
for i := di + 1; i <= dlen; i++ {
if matchWildcardInternal(pattern, domain, pi+1, i) {
return true
}
}
return false
}
// Normal * matching (0 or more characters)
// Try matching 0 characters (skip the *)
if matchWildcardInternal(pattern, domain, pi+1, di) {
return true
}
// Try matching 1+ characters
if di < dlen {
return matchWildcardInternal(pattern, domain, pi, di+1)
}
return false
}
if pattern[pi] == '?' {
// ? matches exactly one character
if di >= dlen {
return false
}
return matchWildcardInternal(pattern, domain, pi+1, di+1)
}
// Regular character - must match exactly
if di >= dlen || pattern[pi] != domain[di] {
return false
}
return matchWildcardInternal(pattern, domain, pi+1, di+1)
}
// reverseDNSToIP converts a reverse DNS query name to an IP address
// Supports both IPv4 (in-addr.arpa) and IPv6 (ip6.arpa) formats
func reverseDNSToIP(domain string) net.IP {
// Normalize to lowercase and ensure FQDN
domain = strings.ToLower(dns.Fqdn(domain))
// Check for IPv4 reverse DNS (in-addr.arpa)
if strings.HasSuffix(domain, ".in-addr.arpa.") {
// Remove the suffix
ipPart := strings.TrimSuffix(domain, ".in-addr.arpa.")
// Split by dots and reverse
parts := strings.Split(ipPart, ".")
if len(parts) != 4 {
return nil
}
// Reverse the octets
reversed := make([]string, 4)
for i := 0; i < 4; i++ {
reversed[i] = parts[3-i]
}
// Parse as IP
return net.ParseIP(strings.Join(reversed, "."))
}
// Check for IPv6 reverse DNS (ip6.arpa)
if strings.HasSuffix(domain, ".ip6.arpa.") {
// Remove the suffix
ipPart := strings.TrimSuffix(domain, ".ip6.arpa.")
// Split by dots and reverse
parts := strings.Split(ipPart, ".")
if len(parts) != 32 {
return nil
}
// Reverse the nibbles and group into 16-bit hex values
reversed := make([]string, 32)
for i := 0; i < 32; i++ {
reversed[i] = parts[31-i]
}
// Join into IPv6 format (groups of 4 nibbles separated by colons)
var ipv6Parts []string
for i := 0; i < 32; i += 4 {
ipv6Parts = append(ipv6Parts, reversed[i]+reversed[i+1]+reversed[i+2]+reversed[i+3])
}
// Parse as IP
return net.ParseIP(strings.Join(ipv6Parts, ":"))
}
return nil
}
// IPToReverseDNS converts an IP address to reverse DNS format
// Returns the domain name for PTR queries (e.g., "1.0.0.127.in-addr.arpa.")
func IPToReverseDNS(ip net.IP) string {
if ip4 := ip.To4(); ip4 != nil {
// IPv4: reverse octets and append .in-addr.arpa.
return dns.Fqdn(fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa",
ip4[3], ip4[2], ip4[1], ip4[0]))
}
if ip6 := ip.To16(); ip6 != nil && ip.To4() == nil {
// IPv6: expand to 32 nibbles, reverse, and append .ip6.arpa.
var nibbles []string
for i := 15; i >= 0; i-- {
nibbles = append(nibbles, fmt.Sprintf("%x", ip6[i]&0x0f))
nibbles = append(nibbles, fmt.Sprintf("%x", ip6[i]>>4))
}
return dns.Fqdn(strings.Join(nibbles, ".") + ".ip6.arpa")
}
return ""
}

864
dns/dns_records_test.go Normal file
View File

@@ -0,0 +1,864 @@
package dns
import (
"net"
"testing"
)
func TestWildcardMatching(t *testing.T) {
tests := []struct {
name string
pattern string
domain string
expected bool
}{
// Basic wildcard tests
{
name: "*.autoco.internal matches host.autoco.internal",
pattern: "*.autoco.internal.",
domain: "host.autoco.internal.",
expected: true,
},
{
name: "*.autoco.internal matches longerhost.autoco.internal",
pattern: "*.autoco.internal.",
domain: "longerhost.autoco.internal.",
expected: true,
},
{
name: "*.autoco.internal matches sub.host.autoco.internal",
pattern: "*.autoco.internal.",
domain: "sub.host.autoco.internal.",
expected: true,
},
{
name: "*.autoco.internal does NOT match autoco.internal",
pattern: "*.autoco.internal.",
domain: "autoco.internal.",
expected: false,
},
// Question mark wildcard tests
{
name: "host-0?.autoco.internal matches host-01.autoco.internal",
pattern: "host-0?.autoco.internal.",
domain: "host-01.autoco.internal.",
expected: true,
},
{
name: "host-0?.autoco.internal matches host-0a.autoco.internal",
pattern: "host-0?.autoco.internal.",
domain: "host-0a.autoco.internal.",
expected: true,
},
{
name: "host-0?.autoco.internal does NOT match host-0.autoco.internal",
pattern: "host-0?.autoco.internal.",
domain: "host-0.autoco.internal.",
expected: false,
},
{
name: "host-0?.autoco.internal does NOT match host-012.autoco.internal",
pattern: "host-0?.autoco.internal.",
domain: "host-012.autoco.internal.",
expected: false,
},
// Combined wildcard tests
{
name: "*.host-0?.autoco.internal matches sub.host-01.autoco.internal",
pattern: "*.host-0?.autoco.internal.",
domain: "sub.host-01.autoco.internal.",
expected: true,
},
{
name: "*.host-0?.autoco.internal matches prefix.host-0a.autoco.internal",
pattern: "*.host-0?.autoco.internal.",
domain: "prefix.host-0a.autoco.internal.",
expected: true,
},
{
name: "*.host-0?.autoco.internal does NOT match host-01.autoco.internal",
pattern: "*.host-0?.autoco.internal.",
domain: "host-01.autoco.internal.",
expected: false,
},
// Multiple asterisks
{
name: "*.*. autoco.internal matches any.thing.autoco.internal",
pattern: "*.*.autoco.internal.",
domain: "any.thing.autoco.internal.",
expected: true,
},
{
name: "*.*.autoco.internal does NOT match single.autoco.internal",
pattern: "*.*.autoco.internal.",
domain: "single.autoco.internal.",
expected: false,
},
// Asterisk in middle
{
name: "host-*.autoco.internal matches host-anything.autoco.internal",
pattern: "host-*.autoco.internal.",
domain: "host-anything.autoco.internal.",
expected: true,
},
{
name: "host-*.autoco.internal matches host-.autoco.internal (empty match)",
pattern: "host-*.autoco.internal.",
domain: "host-.autoco.internal.",
expected: true,
},
// Multiple question marks
{
name: "host-??.autoco.internal matches host-01.autoco.internal",
pattern: "host-??.autoco.internal.",
domain: "host-01.autoco.internal.",
expected: true,
},
{
name: "host-??.autoco.internal does NOT match host-1.autoco.internal",
pattern: "host-??.autoco.internal.",
domain: "host-1.autoco.internal.",
expected: false,
},
// Exact match (no wildcards)
{
name: "exact.autoco.internal matches exact.autoco.internal",
pattern: "exact.autoco.internal.",
domain: "exact.autoco.internal.",
expected: true,
},
{
name: "exact.autoco.internal does NOT match other.autoco.internal",
pattern: "exact.autoco.internal.",
domain: "other.autoco.internal.",
expected: false,
},
// Edge cases
{
name: "* matches anything",
pattern: "*",
domain: "anything.at.all.",
expected: true,
},
{
name: "*.* matches multi.level.",
pattern: "*.*",
domain: "multi.level.",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := matchWildcard(tt.pattern, tt.domain)
if result != tt.expected {
t.Errorf("matchWildcard(%q, %q) = %v, want %v", tt.pattern, tt.domain, result, tt.expected)
}
})
}
}
func TestDNSRecordStoreWildcard(t *testing.T) {
store := NewDNSRecordStore()
// Add wildcard records
wildcardIP := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.autoco.internal", wildcardIP)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Add exact record
exactIP := net.ParseIP("10.0.0.2")
err = store.AddRecord("exact.autoco.internal", exactIP)
if err != nil {
t.Fatalf("Failed to add exact record: %v", err)
}
// Test exact match takes precedence
ips := store.GetRecords("exact.autoco.internal.", RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP for exact match, got %d", len(ips))
}
if !ips[0].Equal(exactIP) {
t.Errorf("Expected exact IP %v, got %v", exactIP, ips[0])
}
// Test wildcard match
ips = store.GetRecords("host.autoco.internal.", RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP for wildcard match, got %d", len(ips))
}
if !ips[0].Equal(wildcardIP) {
t.Errorf("Expected wildcard IP %v, got %v", wildcardIP, ips[0])
}
// Test non-match (base domain)
ips = store.GetRecords("autoco.internal.", RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected 0 IPs for base domain, got %d", len(ips))
}
}
func TestDNSRecordStoreComplexWildcard(t *testing.T) {
store := NewDNSRecordStore()
// Add complex wildcard pattern
ip1 := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.host-0?.autoco.internal", ip1)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Test matching domain
ips := store.GetRecords("sub.host-01.autoco.internal.", RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP for complex wildcard match, got %d", len(ips))
}
if len(ips) > 0 && !ips[0].Equal(ip1) {
t.Errorf("Expected IP %v, got %v", ip1, ips[0])
}
// Test non-matching domain (missing prefix)
ips = store.GetRecords("host-01.autoco.internal.", RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected 0 IPs for domain without prefix, got %d", len(ips))
}
// Test non-matching domain (wrong ? position)
ips = store.GetRecords("sub.host-012.autoco.internal.", RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected 0 IPs for domain with wrong ? match, got %d", len(ips))
}
}
func TestDNSRecordStoreRemoveWildcard(t *testing.T) {
store := NewDNSRecordStore()
// Add wildcard record
ip := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.autoco.internal", ip)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Verify it exists
ips := store.GetRecords("host.autoco.internal.", RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP before removal, got %d", len(ips))
}
// Remove wildcard record
store.RemoveRecord("*.autoco.internal", nil)
// Verify it's gone
ips = store.GetRecords("host.autoco.internal.", RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected 0 IPs after removal, got %d", len(ips))
}
}
func TestDNSRecordStoreMultipleWildcards(t *testing.T) {
store := NewDNSRecordStore()
// Add multiple wildcard patterns that don't overlap
ip1 := net.ParseIP("10.0.0.1")
ip2 := net.ParseIP("10.0.0.2")
ip3 := net.ParseIP("10.0.0.3")
err := store.AddRecord("*.prod.autoco.internal", ip1)
if err != nil {
t.Fatalf("Failed to add first wildcard: %v", err)
}
err = store.AddRecord("*.dev.autoco.internal", ip2)
if err != nil {
t.Fatalf("Failed to add second wildcard: %v", err)
}
// Add a broader wildcard that matches both
err = store.AddRecord("*.autoco.internal", ip3)
if err != nil {
t.Fatalf("Failed to add third wildcard: %v", err)
}
// Test domain matching only the prod pattern and the broad pattern
ips := store.GetRecords("host.prod.autoco.internal.", RecordTypeA)
if len(ips) != 2 {
t.Errorf("Expected 2 IPs (prod + broad), got %d", len(ips))
}
// Test domain matching only the dev pattern and the broad pattern
ips = store.GetRecords("service.dev.autoco.internal.", RecordTypeA)
if len(ips) != 2 {
t.Errorf("Expected 2 IPs (dev + broad), got %d", len(ips))
}
// Test domain matching only the broad pattern
ips = store.GetRecords("host.test.autoco.internal.", RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP (broad only), got %d", len(ips))
}
}
func TestDNSRecordStoreIPv6Wildcard(t *testing.T) {
store := NewDNSRecordStore()
// Add IPv6 wildcard record
ip := net.ParseIP("2001:db8::1")
err := store.AddRecord("*.autoco.internal", ip)
if err != nil {
t.Fatalf("Failed to add IPv6 wildcard record: %v", err)
}
// Test wildcard match for IPv6
ips := store.GetRecords("host.autoco.internal.", RecordTypeAAAA)
if len(ips) != 1 {
t.Errorf("Expected 1 IPv6 for wildcard match, got %d", len(ips))
}
if len(ips) > 0 && !ips[0].Equal(ip) {
t.Errorf("Expected IPv6 %v, got %v", ip, ips[0])
}
}
func TestHasRecordWildcard(t *testing.T) {
store := NewDNSRecordStore()
// Add wildcard record
ip := net.ParseIP("10.0.0.1")
err := store.AddRecord("*.autoco.internal", ip)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Test HasRecord with wildcard match
if !store.HasRecord("host.autoco.internal.", RecordTypeA) {
t.Error("Expected HasRecord to return true for wildcard match")
}
// Test HasRecord with non-match
if store.HasRecord("autoco.internal.", RecordTypeA) {
t.Error("Expected HasRecord to return false for base domain")
}
}
func TestDNSRecordStoreCaseInsensitive(t *testing.T) {
store := NewDNSRecordStore()
// Add record with mixed case
ip := net.ParseIP("10.0.0.1")
err := store.AddRecord("MyHost.AutoCo.Internal", ip)
if err != nil {
t.Fatalf("Failed to add mixed case record: %v", err)
}
// Test lookup with different cases
testCases := []string{
"myhost.autoco.internal.",
"MYHOST.AUTOCO.INTERNAL.",
"MyHost.AutoCo.Internal.",
"mYhOsT.aUtOcO.iNtErNaL.",
}
for _, domain := range testCases {
ips := store.GetRecords(domain, RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP for domain %q, got %d", domain, len(ips))
}
if len(ips) > 0 && !ips[0].Equal(ip) {
t.Errorf("Expected IP %v for domain %q, got %v", ip, domain, ips[0])
}
}
// Test wildcard with mixed case
wildcardIP := net.ParseIP("10.0.0.2")
err = store.AddRecord("*.Example.Com", wildcardIP)
if err != nil {
t.Fatalf("Failed to add mixed case wildcard: %v", err)
}
wildcardTestCases := []string{
"host.example.com.",
"HOST.EXAMPLE.COM.",
"Host.Example.Com.",
"HoSt.ExAmPlE.CoM.",
}
for _, domain := range wildcardTestCases {
ips := store.GetRecords(domain, RecordTypeA)
if len(ips) != 1 {
t.Errorf("Expected 1 IP for wildcard domain %q, got %d", domain, len(ips))
}
if len(ips) > 0 && !ips[0].Equal(wildcardIP) {
t.Errorf("Expected IP %v for wildcard domain %q, got %v", wildcardIP, domain, ips[0])
}
}
// Test removal with different case
store.RemoveRecord("MYHOST.AUTOCO.INTERNAL", nil)
ips := store.GetRecords("myhost.autoco.internal.", RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected 0 IPs after removal, got %d", len(ips))
}
// Test HasRecord with different case
if !store.HasRecord("HOST.EXAMPLE.COM.", RecordTypeA) {
t.Error("Expected HasRecord to return true for mixed case wildcard match")
}
}
func TestPTRRecordIPv4(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record for IPv4
ip := net.ParseIP("192.168.1.1")
domain := "host.example.com."
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Test reverse DNS lookup
reverseDomain := "1.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to be found")
}
if result != domain {
t.Errorf("Expected domain %q, got %q", domain, result)
}
// Test HasPTRRecord
if !store.HasPTRRecord(reverseDomain) {
t.Error("Expected HasPTRRecord to return true")
}
// Test non-existent PTR record
_, ok = store.GetPTRRecord("2.1.168.192.in-addr.arpa.")
if ok {
t.Error("Expected PTR record not to be found for different IP")
}
}
func TestPTRRecordIPv6(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record for IPv6
ip := net.ParseIP("2001:db8::1")
domain := "ipv6host.example.com."
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Test reverse DNS lookup
// 2001:db8::1 = 2001:0db8:0000:0000:0000:0000:0000:0001
// Reverse: 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.
reverseDomain := "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected IPv6 PTR record to be found")
}
if result != domain {
t.Errorf("Expected domain %q, got %q", domain, result)
}
// Test HasPTRRecord
if !store.HasPTRRecord(reverseDomain) {
t.Error("Expected HasPTRRecord to return true for IPv6")
}
}
func TestRemovePTRRecord(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record
ip := net.ParseIP("10.0.0.1")
domain := "test.example.com."
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Verify it exists
reverseDomain := "1.0.0.10.in-addr.arpa."
_, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to exist before removal")
}
// Remove PTR record
store.RemovePTRRecord(ip)
// Verify it's gone
_, ok = store.GetPTRRecord(reverseDomain)
if ok {
t.Error("Expected PTR record to be removed")
}
// Test HasPTRRecord after removal
if store.HasPTRRecord(reverseDomain) {
t.Error("Expected HasPTRRecord to return false after removal")
}
}
func TestIPToReverseDNS(t *testing.T) {
tests := []struct {
name string
ip string
expected string
}{
{
name: "IPv4 simple",
ip: "192.168.1.1",
expected: "1.1.168.192.in-addr.arpa.",
},
{
name: "IPv4 localhost",
ip: "127.0.0.1",
expected: "1.0.0.127.in-addr.arpa.",
},
{
name: "IPv4 with zeros",
ip: "10.0.0.1",
expected: "1.0.0.10.in-addr.arpa.",
},
{
name: "IPv6 simple",
ip: "2001:db8::1",
expected: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
},
{
name: "IPv6 localhost",
ip: "::1",
expected: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("Failed to parse IP: %s", tt.ip)
}
result := IPToReverseDNS(ip)
if result != tt.expected {
t.Errorf("IPToReverseDNS(%s) = %q, want %q", tt.ip, result, tt.expected)
}
})
}
}
func TestReverseDNSToIP(t *testing.T) {
tests := []struct {
name string
reverseDNS string
expectedIP string
shouldMatch bool
}{
{
name: "IPv4 simple",
reverseDNS: "1.1.168.192.in-addr.arpa.",
expectedIP: "192.168.1.1",
shouldMatch: true,
},
{
name: "IPv4 localhost",
reverseDNS: "1.0.0.127.in-addr.arpa.",
expectedIP: "127.0.0.1",
shouldMatch: true,
},
{
name: "IPv6 simple",
reverseDNS: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
expectedIP: "2001:db8::1",
shouldMatch: true,
},
{
name: "Invalid IPv4 format",
reverseDNS: "1.1.168.in-addr.arpa.",
expectedIP: "",
shouldMatch: false,
},
{
name: "Invalid IPv6 format",
reverseDNS: "1.0.0.0.ip6.arpa.",
expectedIP: "",
shouldMatch: false,
},
{
name: "Not a reverse DNS domain",
reverseDNS: "example.com.",
expectedIP: "",
shouldMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := reverseDNSToIP(tt.reverseDNS)
if tt.shouldMatch {
if result == nil {
t.Errorf("reverseDNSToIP(%q) returned nil, expected IP", tt.reverseDNS)
return
}
expectedIP := net.ParseIP(tt.expectedIP)
if !result.Equal(expectedIP) {
t.Errorf("reverseDNSToIP(%q) = %v, want %v", tt.reverseDNS, result, expectedIP)
}
} else {
if result != nil {
t.Errorf("reverseDNSToIP(%q) = %v, expected nil", tt.reverseDNS, result)
}
}
})
}
}
func TestPTRRecordCaseInsensitive(t *testing.T) {
store := NewDNSRecordStore()
// Add PTR record with mixed case domain
ip := net.ParseIP("192.168.1.1")
domain := "MyHost.Example.Com"
err := store.AddPTRRecord(ip, domain)
if err != nil {
t.Fatalf("Failed to add PTR record: %v", err)
}
// Test lookup with different cases in reverse DNS format
reverseDomain := "1.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to be found")
}
// Domain should be normalized to lowercase
if result != "myhost.example.com." {
t.Errorf("Expected normalized domain %q, got %q", "myhost.example.com.", result)
}
// Test with uppercase reverse DNS
reverseDomainUpper := "1.1.168.192.IN-ADDR.ARPA."
result, ok = store.GetPTRRecord(reverseDomainUpper)
if !ok {
t.Error("Expected PTR record to be found with uppercase reverse DNS")
}
if result != "myhost.example.com." {
t.Errorf("Expected normalized domain %q, got %q", "myhost.example.com.", result)
}
}
func TestClearPTRRecords(t *testing.T) {
store := NewDNSRecordStore()
// Add some PTR records
ip1 := net.ParseIP("192.168.1.1")
ip2 := net.ParseIP("192.168.1.2")
store.AddPTRRecord(ip1, "host1.example.com.")
store.AddPTRRecord(ip2, "host2.example.com.")
// Add some A records too
store.AddRecord("test.example.com.", net.ParseIP("10.0.0.1"))
// Verify PTR records exist
if !store.HasPTRRecord("1.1.168.192.in-addr.arpa.") {
t.Error("Expected PTR record to exist before clear")
}
// Clear all records
store.Clear()
// Verify PTR records are gone
if store.HasPTRRecord("1.1.168.192.in-addr.arpa.") {
t.Error("Expected PTR record to be cleared")
}
if store.HasPTRRecord("2.1.168.192.in-addr.arpa.") {
t.Error("Expected PTR record to be cleared")
}
// Verify A records are also gone
if store.HasRecord("test.example.com.", RecordTypeA) {
t.Error("Expected A record to be cleared")
}
}
func TestAutomaticPTRRecordOnAdd(t *testing.T) {
store := NewDNSRecordStore()
// Add an A record - should automatically add PTR record
domain := "host.example.com."
ip := net.ParseIP("192.168.1.100")
err := store.AddRecord(domain, ip)
if err != nil {
t.Fatalf("Failed to add A record: %v", err)
}
// Verify PTR record was automatically created
reverseDomain := "100.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to be automatically created")
}
if result != domain {
t.Errorf("Expected PTR to point to %q, got %q", domain, result)
}
// Add AAAA record - should also automatically add PTR record
domain6 := "ipv6host.example.com."
ip6 := net.ParseIP("2001:db8::1")
err = store.AddRecord(domain6, ip6)
if err != nil {
t.Fatalf("Failed to add AAAA record: %v", err)
}
// Verify IPv6 PTR record was automatically created
reverseDomain6 := "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."
result6, ok := store.GetPTRRecord(reverseDomain6)
if !ok {
t.Error("Expected IPv6 PTR record to be automatically created")
}
if result6 != domain6 {
t.Errorf("Expected PTR to point to %q, got %q", domain6, result6)
}
}
func TestAutomaticPTRRecordOnRemove(t *testing.T) {
store := NewDNSRecordStore()
// Add an A record (with automatic PTR)
domain := "host.example.com."
ip := net.ParseIP("192.168.1.100")
store.AddRecord(domain, ip)
// Verify PTR exists
reverseDomain := "100.1.168.192.in-addr.arpa."
if !store.HasPTRRecord(reverseDomain) {
t.Error("Expected PTR record to exist after adding A record")
}
// Remove the A record
store.RemoveRecord(domain, ip)
// Verify PTR was automatically removed
if store.HasPTRRecord(reverseDomain) {
t.Error("Expected PTR record to be automatically removed")
}
// Verify A record is also gone
ips := store.GetRecords(domain, RecordTypeA)
if len(ips) != 0 {
t.Errorf("Expected A record to be removed, got %d records", len(ips))
}
}
func TestAutomaticPTRRecordOnRemoveAll(t *testing.T) {
store := NewDNSRecordStore()
// Add multiple IPs for the same domain
domain := "host.example.com."
ip1 := net.ParseIP("192.168.1.100")
ip2 := net.ParseIP("192.168.1.101")
store.AddRecord(domain, ip1)
store.AddRecord(domain, ip2)
// Verify both PTR records exist
reverseDomain1 := "100.1.168.192.in-addr.arpa."
reverseDomain2 := "101.1.168.192.in-addr.arpa."
if !store.HasPTRRecord(reverseDomain1) {
t.Error("Expected first PTR record to exist")
}
if !store.HasPTRRecord(reverseDomain2) {
t.Error("Expected second PTR record to exist")
}
// Remove all records for the domain
store.RemoveRecord(domain, nil)
// Verify both PTR records were removed
if store.HasPTRRecord(reverseDomain1) {
t.Error("Expected first PTR record to be removed")
}
if store.HasPTRRecord(reverseDomain2) {
t.Error("Expected second PTR record to be removed")
}
}
func TestNoPTRForWildcardRecords(t *testing.T) {
store := NewDNSRecordStore()
// Add wildcard record - should NOT create PTR record
domain := "*.example.com."
ip := net.ParseIP("192.168.1.100")
err := store.AddRecord(domain, ip)
if err != nil {
t.Fatalf("Failed to add wildcard record: %v", err)
}
// Verify no PTR record was created
reverseDomain := "100.1.168.192.in-addr.arpa."
_, ok := store.GetPTRRecord(reverseDomain)
if ok {
t.Error("Expected no PTR record for wildcard domain")
}
// Verify wildcard A record exists
if !store.HasRecord("host.example.com.", RecordTypeA) {
t.Error("Expected wildcard A record to exist")
}
}
func TestPTRRecordOverwrite(t *testing.T) {
store := NewDNSRecordStore()
// Add first domain with IP
domain1 := "host1.example.com."
ip := net.ParseIP("192.168.1.100")
store.AddRecord(domain1, ip)
// Verify PTR points to first domain
reverseDomain := "100.1.168.192.in-addr.arpa."
result, ok := store.GetPTRRecord(reverseDomain)
if !ok {
t.Fatal("Expected PTR record to exist")
}
if result != domain1 {
t.Errorf("Expected PTR to point to %q, got %q", domain1, result)
}
// Add second domain with same IP - should overwrite PTR
domain2 := "host2.example.com."
store.AddRecord(domain2, ip)
// Verify PTR now points to second domain (last one added)
result, ok = store.GetPTRRecord(reverseDomain)
if !ok {
t.Fatal("Expected PTR record to still exist")
}
if result != domain2 {
t.Errorf("Expected PTR to point to %q (overwritten), got %q", domain2, result)
}
// Remove first domain - PTR should remain pointing to second domain
store.RemoveRecord(domain1, ip)
result, ok = store.GetPTRRecord(reverseDomain)
if !ok {
t.Error("Expected PTR record to still exist after removing first domain")
}
if result != domain2 {
t.Errorf("Expected PTR to still point to %q, got %q", domain2, result)
}
// Remove second domain - PTR should now be gone
store.RemoveRecord(domain2, ip)
_, ok = store.GetPTRRecord(reverseDomain)
if ok {
t.Error("Expected PTR record to be removed after removing second domain")
}
}

View File

@@ -0,0 +1,16 @@
//go:build android
package olm
import "net/netip"
// SetupDNSOverride is a no-op on Android
// Android handles DNS through the VpnService API at the Java/Kotlin layer
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
return nil
}
// RestoreDNSOverride is a no-op on Android
func RestoreDNSOverride() error {
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"net/netip" "net/netip"
"github.com/fosrl/newt/logger" "github.com/fosrl/newt/logger"
"github.com/fosrl/olm/dns"
platform "github.com/fosrl/olm/dns/platform" platform "github.com/fosrl/olm/dns/platform"
) )
@@ -15,11 +14,7 @@ var configurator platform.DNSConfigurator
// SetupDNSOverride configures the system DNS to use the DNS proxy on macOS // SetupDNSOverride configures the system DNS to use the DNS proxy on macOS
// Uses scutil for DNS configuration // Uses scutil for DNS configuration
func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error { func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
if dnsProxy == nil {
return fmt.Errorf("DNS proxy is nil")
}
var err error var err error
configurator, err = platform.NewDarwinDNSConfigurator() configurator, err = platform.NewDarwinDNSConfigurator()
if err != nil { if err != nil {
@@ -38,7 +33,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
// Set new DNS servers to point to our proxy // Set new DNS servers to point to our proxy
newDNS := []netip.Addr{ newDNS := []netip.Addr{
dnsProxy.GetProxyIP(), proxyIp,
} }
logger.Info("Setting DNS servers to: %v", newDNS) logger.Info("Setting DNS servers to: %v", newDNS)

View File

@@ -0,0 +1,15 @@
//go:build ios
package olm
import "net/netip"
// SetupDNSOverride is a no-op on iOS as DNS configuration is handled by the system
func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
return nil
}
// RestoreDNSOverride is a no-op on iOS as DNS configuration is handled by the system
func RestoreDNSOverride() error {
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"net/netip" "net/netip"
"github.com/fosrl/newt/logger" "github.com/fosrl/newt/logger"
"github.com/fosrl/olm/dns"
platform "github.com/fosrl/olm/dns/platform" platform "github.com/fosrl/olm/dns/platform"
) )
@@ -15,11 +14,7 @@ var configurator platform.DNSConfigurator
// SetupDNSOverride configures the system DNS to use the DNS proxy on Linux/FreeBSD // SetupDNSOverride configures the system DNS to use the DNS proxy on Linux/FreeBSD
// Detects the DNS manager by reading /etc/resolv.conf and verifying runtime availability // Detects the DNS manager by reading /etc/resolv.conf and verifying runtime availability
func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error { func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
if dnsProxy == nil {
return fmt.Errorf("DNS proxy is nil")
}
var err error var err error
// Detect which DNS manager is in use by checking /etc/resolv.conf and runtime availability // Detect which DNS manager is in use by checking /etc/resolv.conf and runtime availability
@@ -32,7 +27,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
configurator, err = platform.NewSystemdResolvedDNSConfigurator(interfaceName) configurator, err = platform.NewSystemdResolvedDNSConfigurator(interfaceName)
if err == nil { if err == nil {
logger.Info("Using systemd-resolved DNS configurator") logger.Info("Using systemd-resolved DNS configurator")
return setDNS(dnsProxy, configurator) return setDNS(proxyIp, configurator)
} }
logger.Warn("Failed to create systemd-resolved configurator: %v, falling back", err) logger.Warn("Failed to create systemd-resolved configurator: %v, falling back", err)
@@ -40,7 +35,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
configurator, err = platform.NewNetworkManagerDNSConfigurator(interfaceName) configurator, err = platform.NewNetworkManagerDNSConfigurator(interfaceName)
if err == nil { if err == nil {
logger.Info("Using NetworkManager DNS configurator") logger.Info("Using NetworkManager DNS configurator")
return setDNS(dnsProxy, configurator) return setDNS(proxyIp, configurator)
} }
logger.Warn("Failed to create NetworkManager configurator: %v, falling back", err) logger.Warn("Failed to create NetworkManager configurator: %v, falling back", err)
@@ -48,7 +43,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
configurator, err = platform.NewResolvconfDNSConfigurator(interfaceName) configurator, err = platform.NewResolvconfDNSConfigurator(interfaceName)
if err == nil { if err == nil {
logger.Info("Using resolvconf DNS configurator") logger.Info("Using resolvconf DNS configurator")
return setDNS(dnsProxy, configurator) return setDNS(proxyIp, configurator)
} }
logger.Warn("Failed to create resolvconf configurator: %v, falling back", err) logger.Warn("Failed to create resolvconf configurator: %v, falling back", err)
} }
@@ -60,11 +55,11 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
} }
logger.Info("Using file-based DNS configurator") logger.Info("Using file-based DNS configurator")
return setDNS(dnsProxy, configurator) return setDNS(proxyIp, configurator)
} }
// setDNS is a helper function to set DNS and log the results // setDNS is a helper function to set DNS and log the results
func setDNS(dnsProxy *dns.DNSProxy, conf platform.DNSConfigurator) error { func setDNS(proxyIp netip.Addr, conf platform.DNSConfigurator) error {
// Get current DNS servers before changing // Get current DNS servers before changing
currentDNS, err := conf.GetCurrentDNS() currentDNS, err := conf.GetCurrentDNS()
if err != nil { if err != nil {
@@ -75,7 +70,7 @@ func setDNS(dnsProxy *dns.DNSProxy, conf platform.DNSConfigurator) error {
// Set new DNS servers to point to our proxy // Set new DNS servers to point to our proxy
newDNS := []netip.Addr{ newDNS := []netip.Addr{
dnsProxy.GetProxyIP(), proxyIp,
} }
logger.Info("Setting DNS servers to: %v", newDNS) logger.Info("Setting DNS servers to: %v", newDNS)

View File

@@ -7,7 +7,6 @@ import (
"net/netip" "net/netip"
"github.com/fosrl/newt/logger" "github.com/fosrl/newt/logger"
"github.com/fosrl/olm/dns"
platform "github.com/fosrl/olm/dns/platform" platform "github.com/fosrl/olm/dns/platform"
) )
@@ -15,11 +14,7 @@ var configurator platform.DNSConfigurator
// SetupDNSOverride configures the system DNS to use the DNS proxy on Windows // SetupDNSOverride configures the system DNS to use the DNS proxy on Windows
// Uses registry-based configuration (automatically extracts interface GUID) // Uses registry-based configuration (automatically extracts interface GUID)
func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error { func SetupDNSOverride(interfaceName string, proxyIp netip.Addr) error {
if dnsProxy == nil {
return fmt.Errorf("DNS proxy is nil")
}
var err error var err error
configurator, err = platform.NewWindowsDNSConfigurator(interfaceName) configurator, err = platform.NewWindowsDNSConfigurator(interfaceName)
if err != nil { if err != nil {
@@ -38,7 +33,7 @@ func SetupDNSOverride(interfaceName string, dnsProxy *dns.DNSProxy) error {
// Set new DNS servers to point to our proxy // Set new DNS servers to point to our proxy
newDNS := []netip.Addr{ newDNS := []netip.Addr{
dnsProxy.GetProxyIP(), proxyIp,
} }
logger.Info("Setting DNS servers to: %v", newDNS) logger.Info("Setting DNS servers to: %v", newDNS)

View File

@@ -5,9 +5,13 @@ package dns
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"os"
"os/exec" "os/exec"
"path/filepath"
"runtime"
"strconv" "strconv"
"strings" "strings"
@@ -28,19 +32,38 @@ const (
keyServerPort = "ServerPort" keyServerPort = "ServerPort"
arraySymbol = "* " arraySymbol = "* "
digitSymbol = "# " digitSymbol = "# "
// State file name for crash recovery
dnsStateFileName = "dns_state.json"
) )
// DNSPersistentState represents the state saved to disk for crash recovery
type DNSPersistentState struct {
CreatedKeys []string `json:"created_keys"`
}
// DarwinDNSConfigurator manages DNS settings on macOS using scutil // DarwinDNSConfigurator manages DNS settings on macOS using scutil
type DarwinDNSConfigurator struct { type DarwinDNSConfigurator struct {
createdKeys map[string]struct{} createdKeys map[string]struct{}
originalState *DNSState originalState *DNSState
stateFilePath string
} }
// NewDarwinDNSConfigurator creates a new macOS DNS configurator // NewDarwinDNSConfigurator creates a new macOS DNS configurator
func NewDarwinDNSConfigurator() (*DarwinDNSConfigurator, error) { func NewDarwinDNSConfigurator() (*DarwinDNSConfigurator, error) {
return &DarwinDNSConfigurator{ stateFilePath := getDNSStateFilePath()
createdKeys: make(map[string]struct{}),
}, nil configurator := &DarwinDNSConfigurator{
createdKeys: make(map[string]struct{}),
stateFilePath: stateFilePath,
}
// Clean up any leftover state from a previous crash
if err := configurator.CleanupUncleanShutdown(); err != nil {
logger.Warn("Failed to cleanup previous DNS state: %v", err)
}
return configurator, nil
} }
// Name returns the configurator name // Name returns the configurator name
@@ -67,6 +90,11 @@ func (d *DarwinDNSConfigurator) SetDNS(servers []netip.Addr) ([]netip.Addr, erro
return nil, fmt.Errorf("apply DNS servers: %w", err) return nil, fmt.Errorf("apply DNS servers: %w", err)
} }
// Persist state to disk for crash recovery
if err := d.saveState(); err != nil {
logger.Warn("Failed to save DNS state for crash recovery: %v", err)
}
// Flush DNS cache // Flush DNS cache
if err := d.flushDNSCache(); err != nil { if err := d.flushDNSCache(); err != nil {
// Non-fatal, just log // Non-fatal, just log
@@ -85,6 +113,11 @@ func (d *DarwinDNSConfigurator) RestoreDNS() error {
} }
} }
// Clear state file after successful restoration
if err := d.clearState(); err != nil {
logger.Warn("Failed to clear DNS state file: %v", err)
}
// Flush DNS cache // Flush DNS cache
if err := d.flushDNSCache(); err != nil { if err := d.flushDNSCache(); err != nil {
fmt.Printf("warning: failed to flush DNS cache: %v\n", err) fmt.Printf("warning: failed to flush DNS cache: %v\n", err)
@@ -112,6 +145,47 @@ func (d *DarwinDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) {
return servers, nil return servers, nil
} }
// CleanupUncleanShutdown removes any DNS keys left over from a previous crash
func (d *DarwinDNSConfigurator) CleanupUncleanShutdown() error {
state, err := d.loadState()
if err != nil {
if os.IsNotExist(err) {
// No state file, nothing to clean up
return nil
}
return fmt.Errorf("load state: %w", err)
}
if len(state.CreatedKeys) == 0 {
// No keys to clean up
return nil
}
logger.Info("Found DNS state from previous session, cleaning up %d keys", len(state.CreatedKeys))
// Remove all keys from previous session
var lastErr error
for _, key := range state.CreatedKeys {
logger.Debug("Removing leftover DNS key: %s", key)
if err := d.removeKeyDirect(key); err != nil {
logger.Warn("Failed to remove DNS key %s: %v", key, err)
lastErr = err
}
}
// Clear state file
if err := d.clearState(); err != nil {
logger.Warn("Failed to clear DNS state file: %v", err)
}
// Flush DNS cache after cleanup
if err := d.flushDNSCache(); err != nil {
logger.Warn("Failed to flush DNS cache after cleanup: %v", err)
}
return lastErr
}
// applyDNSServers applies the DNS server configuration // applyDNSServers applies the DNS server configuration
func (d *DarwinDNSConfigurator) applyDNSServers(servers []netip.Addr) error { func (d *DarwinDNSConfigurator) applyDNSServers(servers []netip.Addr) error {
if len(servers) == 0 { if len(servers) == 0 {
@@ -156,15 +230,25 @@ func (d *DarwinDNSConfigurator) addDNSState(state, domains string, dnsServer net
return nil return nil
} }
// removeKey removes a DNS configuration key // removeKey removes a DNS configuration key and updates internal state
func (d *DarwinDNSConfigurator) removeKey(key string) error { func (d *DarwinDNSConfigurator) removeKey(key string) error {
if err := d.removeKeyDirect(key); err != nil {
return err
}
delete(d.createdKeys, key)
return nil
}
// removeKeyDirect removes a DNS configuration key without updating internal state
// Used for cleanup operations
func (d *DarwinDNSConfigurator) removeKeyDirect(key string) error {
cmd := fmt.Sprintf("remove %s\n", key) cmd := fmt.Sprintf("remove %s\n", key)
if _, err := d.runScutil(cmd); err != nil { if _, err := d.runScutil(cmd); err != nil {
return fmt.Errorf("remove key: %w", err) return fmt.Errorf("remove key: %w", err)
} }
delete(d.createdKeys, key)
return nil return nil
} }
@@ -266,3 +350,70 @@ func (d *DarwinDNSConfigurator) runScutil(commands string) ([]byte, error) {
return output, nil return output, nil
} }
// getDNSStateFilePath returns the path to the DNS state file
func getDNSStateFilePath() string {
var stateDir string
switch runtime.GOOS {
case "darwin":
stateDir = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "olm-client")
default:
stateDir = filepath.Join(os.Getenv("HOME"), ".config", "olm-client")
}
if err := os.MkdirAll(stateDir, 0755); err != nil {
logger.Warn("Failed to create state directory: %v", err)
}
return filepath.Join(stateDir, dnsStateFileName)
}
// saveState persists the current DNS state to disk
func (d *DarwinDNSConfigurator) saveState() error {
keys := make([]string, 0, len(d.createdKeys))
for key := range d.createdKeys {
keys = append(keys, key)
}
state := DNSPersistentState{
CreatedKeys: keys,
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
if err := os.WriteFile(d.stateFilePath, data, 0644); err != nil {
return fmt.Errorf("write state file: %w", err)
}
logger.Debug("Saved DNS state to %s", d.stateFilePath)
return nil
}
// loadState loads the DNS state from disk
func (d *DarwinDNSConfigurator) loadState() (*DNSPersistentState, error) {
data, err := os.ReadFile(d.stateFilePath)
if err != nil {
return nil, err
}
var state DNSPersistentState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("unmarshal state: %w", err)
}
return &state, nil
}
// clearState removes the DNS state file
func (d *DarwinDNSConfigurator) clearState() error {
err := os.Remove(d.stateFilePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove state file: %w", err)
}
logger.Debug("Cleared DNS state file")
return nil
}

View File

@@ -22,7 +22,11 @@ type FileDNSConfigurator struct {
// NewFileDNSConfigurator creates a new file-based DNS configurator // NewFileDNSConfigurator creates a new file-based DNS configurator
func NewFileDNSConfigurator() (*FileDNSConfigurator, error) { func NewFileDNSConfigurator() (*FileDNSConfigurator, error) {
return &FileDNSConfigurator{}, nil f := &FileDNSConfigurator{}
if err := f.CleanupUncleanShutdown(); err != nil {
return nil, fmt.Errorf("cleanup unclean shutdown: %w", err)
}
return f, nil
} }
// Name returns the configurator name // Name returns the configurator name
@@ -78,6 +82,30 @@ func (f *FileDNSConfigurator) RestoreDNS() error {
return nil return nil
} }
// CleanupUncleanShutdown removes any DNS configuration left over from a previous crash
// For the file-based configurator, we check if a backup file exists (indicating a crash
// happened while DNS was configured) and restore from it if so.
func (f *FileDNSConfigurator) CleanupUncleanShutdown() error {
// Check if backup file exists from a previous session
if !f.isBackupExists() {
// No backup file, nothing to clean up
return nil
}
// A backup exists, which means we crashed while DNS was configured
// Restore the original resolv.conf
if err := copyFile(resolvConfBackupPath, resolvConfPath); err != nil {
return fmt.Errorf("restore from backup during cleanup: %w", err)
}
// Remove backup file
if err := os.Remove(resolvConfBackupPath); err != nil {
return fmt.Errorf("remove backup file during cleanup: %w", err)
}
return nil
}
// GetCurrentDNS returns the currently configured DNS servers // GetCurrentDNS returns the currently configured DNS servers
func (f *FileDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) { func (f *FileDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) {
content, err := os.ReadFile(resolvConfPath) content, err := os.ReadFile(resolvConfPath)

View File

@@ -50,11 +50,18 @@ func NewNetworkManagerDNSConfigurator(ifaceName string) (*NetworkManagerDNSConfi
return nil, fmt.Errorf("NetworkManager conf.d directory not found: %s", networkManagerConfDir) return nil, fmt.Errorf("NetworkManager conf.d directory not found: %s", networkManagerConfDir)
} }
return &NetworkManagerDNSConfigurator{ configurator := &NetworkManagerDNSConfigurator{
ifaceName: ifaceName, ifaceName: ifaceName,
confPath: networkManagerConfDir + "/" + networkManagerDNSConfFile, confPath: networkManagerConfDir + "/" + networkManagerDNSConfFile,
dispatchPath: networkManagerDispatcherDir + "/" + networkManagerDispatcherFile, dispatchPath: networkManagerDispatcherDir + "/" + networkManagerDispatcherFile,
}, nil }
// Clean up any stale configuration from a previous unclean shutdown
if err := configurator.CleanupUncleanShutdown(); err != nil {
return nil, fmt.Errorf("cleanup unclean shutdown: %w", err)
}
return configurator, nil
} }
// Name returns the configurator name // Name returns the configurator name
@@ -100,6 +107,30 @@ func (n *NetworkManagerDNSConfigurator) RestoreDNS() error {
return nil return nil
} }
// CleanupUncleanShutdown removes any DNS configuration left over from a previous crash
// For NetworkManager, we check if our config file exists and remove it if so.
// This ensures that if the process crashed while DNS was configured, the stale
// configuration is removed on the next startup.
func (n *NetworkManagerDNSConfigurator) CleanupUncleanShutdown() error {
// Check if our config file exists from a previous session
if _, err := os.Stat(n.confPath); os.IsNotExist(err) {
// No config file, nothing to clean up
return nil
}
// Remove the stale configuration file
if err := os.Remove(n.confPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale DNS config file: %w", err)
}
// Reload NetworkManager to apply the change
if err := n.reloadNetworkManager(); err != nil {
return fmt.Errorf("reload NetworkManager after cleanup: %w", err)
}
return nil
}
// GetCurrentDNS returns the currently configured DNS servers by reading /etc/resolv.conf // GetCurrentDNS returns the currently configured DNS servers by reading /etc/resolv.conf
func (n *NetworkManagerDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) { func (n *NetworkManagerDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) {
content, err := os.ReadFile("/etc/resolv.conf") content, err := os.ReadFile("/etc/resolv.conf")

View File

@@ -31,10 +31,17 @@ func NewResolvconfDNSConfigurator(ifaceName string) (*ResolvconfDNSConfigurator,
return nil, fmt.Errorf("detect resolvconf type: %w", err) return nil, fmt.Errorf("detect resolvconf type: %w", err)
} }
return &ResolvconfDNSConfigurator{ configurator := &ResolvconfDNSConfigurator{
ifaceName: ifaceName, ifaceName: ifaceName,
implType: implType, implType: implType,
}, nil }
// Call cleanup function to remove any stale DNS config for this interface
if err := configurator.CleanupUncleanShutdown(); err != nil {
return nil, fmt.Errorf("cleanup unclean shutdown: %w", err)
}
return configurator, nil
} }
// Name returns the configurator name // Name returns the configurator name
@@ -84,6 +91,28 @@ func (r *ResolvconfDNSConfigurator) RestoreDNS() error {
return nil return nil
} }
// CleanupUncleanShutdown removes any DNS configuration left over from a previous crash
// For resolvconf, we attempt to delete any entry for the interface name.
// This ensures that if the process crashed while DNS was configured, the stale
// entry is removed on the next startup.
func (r *ResolvconfDNSConfigurator) CleanupUncleanShutdown() error {
// Try to delete any existing entry for this interface
// This is idempotent - if no entry exists, resolvconf will just return success
var cmd *exec.Cmd
switch r.implType {
case "openresolv":
cmd = exec.Command(resolvconfCommand, "-f", "-d", r.ifaceName)
default:
cmd = exec.Command(resolvconfCommand, "-d", r.ifaceName)
}
// Ignore errors - the entry may not exist, which is fine
_ = cmd.Run()
return nil
}
// GetCurrentDNS returns the currently configured DNS servers // GetCurrentDNS returns the currently configured DNS servers
func (r *ResolvconfDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) { func (r *ResolvconfDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) {
// resolvconf doesn't provide a direct way to query per-interface DNS // resolvconf doesn't provide a direct way to query per-interface DNS

View File

@@ -73,10 +73,17 @@ func NewSystemdResolvedDNSConfigurator(ifaceName string) (*SystemdResolvedDNSCon
return nil, fmt.Errorf("get link: %w", err) return nil, fmt.Errorf("get link: %w", err)
} }
return &SystemdResolvedDNSConfigurator{ config := &SystemdResolvedDNSConfigurator{
ifaceName: ifaceName, ifaceName: ifaceName,
dbusLinkObject: dbus.ObjectPath(linkPath), dbusLinkObject: dbus.ObjectPath(linkPath),
}, nil }
// Call cleanup function here
if err := config.CleanupUncleanShutdown(); err != nil {
fmt.Printf("warning: cleanup unclean shutdown failed: %v\n", err)
}
return config, nil
} }
// Name returns the configurator name // Name returns the configurator name
@@ -133,6 +140,17 @@ func (s *SystemdResolvedDNSConfigurator) RestoreDNS() error {
return nil return nil
} }
// CleanupUncleanShutdown removes any DNS configuration left over from a previous crash
// For systemd-resolved, the DNS configuration is tied to the network interface.
// When the interface is destroyed and recreated, systemd-resolved automatically
// clears the per-link DNS settings, so there's nothing to clean up.
func (s *SystemdResolvedDNSConfigurator) CleanupUncleanShutdown() error {
// systemd-resolved DNS configuration is per-link and automatically cleared
// when the link (interface) is destroyed. Since the WireGuard interface is
// recreated on restart, there's no leftover state to clean up.
return nil
}
// GetCurrentDNS returns the currently configured DNS servers // GetCurrentDNS returns the currently configured DNS servers
// Note: systemd-resolved doesn't easily expose current per-link DNS servers via D-Bus // Note: systemd-resolved doesn't easily expose current per-link DNS servers via D-Bus
// This is a placeholder that returns an empty list // This is a placeholder that returns an empty list

View File

@@ -17,6 +17,10 @@ type DNSConfigurator interface {
// Name returns the name of this configurator implementation // Name returns the name of this configurator implementation
Name() string Name() string
// CleanupUncleanShutdown removes any DNS configuration left over from
// a previous crash or unclean shutdown. This should be called on startup.
CleanupUncleanShutdown() error
} }
// DNSConfig contains the configuration for DNS override // DNSConfig contains the configuration for DNS override

View File

@@ -113,6 +113,18 @@ func (w *WindowsDNSConfigurator) RestoreDNS() error {
return nil return nil
} }
// CleanupUncleanShutdown removes any DNS configuration left over from a previous crash
// On Windows, we rely on the registry-based approach which doesn't leave orphaned state
// in the same way as macOS scutil. The DNS settings are tied to the interface which
// gets recreated on restart.
func (w *WindowsDNSConfigurator) CleanupUncleanShutdown() error {
// Windows DNS configuration via registry is interface-specific.
// When the WireGuard interface is recreated, it gets a new GUID,
// so there's no leftover state to clean up from previous sessions.
// The old interface's registry keys are effectively orphaned but harmless.
return nil
}
// GetCurrentDNS returns the currently configured DNS servers // GetCurrentDNS returns the currently configured DNS servers
func (w *WindowsDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) { func (w *WindowsDNSConfigurator) GetCurrentDNS() ([]netip.Addr, error) {
regKey, err := w.getInterfaceRegistryKey(registry.QUERY_VALUE) regKey, err := w.getInterfaceRegistryKey(registry.QUERY_VALUE)

28
go.mod
View File

@@ -4,30 +4,32 @@ go 1.25
require ( require (
github.com/Microsoft/go-winio v0.6.2 github.com/Microsoft/go-winio v0.6.2
github.com/fosrl/newt v0.0.0 github.com/fosrl/newt v1.9.0
github.com/godbus/dbus/v5 v5.2.0 github.com/godbus/dbus/v5 v5.2.2
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/miekg/dns v1.1.68 github.com/miekg/dns v1.1.70
github.com/vishvananda/netlink v1.3.1 golang.org/x/sys v0.40.0
golang.org/x/sys v0.38.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v0.5.3
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
software.sslmate.com/src/go-pkcs12 v0.6.0 software.sslmate.com/src/go-pkcs12 v0.7.0
) )
require ( require (
github.com/google/btree v1.1.3 // indirect github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/crypto v0.44.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/mod v0.30.0 // indirect golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/time v0.12.0 // indirect golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.39.0 // indirect golang.org/x/tools v0.40.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
) )
replace github.com/fosrl/newt => ../newt // To be used ONLY for local development
// replace github.com/fosrl/newt => ../newt

38
go.sum
View File

@@ -1,37 +1,39 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= github.com/fosrl/newt v1.9.0 h1:66eJMo6fA+YcBTbddxTfNJXNQo1WWKzmn6zPRP5kSDE=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/fosrl/newt v1.9.0/go.mod h1:d1+yYMnKqg4oLqAM9zdbjthjj2FQEVouiACjqU468ck=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
@@ -42,5 +44,5 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
software.sslmate.com/src/go-pkcs12 v0.6.0 h1:f3sQittAeF+pao32Vb+mkli+ZyT+VwKaD014qFGq6oU= software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

18
main.go
View File

@@ -10,7 +10,7 @@ import (
"github.com/fosrl/newt/logger" "github.com/fosrl/newt/logger"
"github.com/fosrl/newt/updates" "github.com/fosrl/newt/updates"
"github.com/fosrl/olm/olm" olmpkg "github.com/fosrl/olm/olm"
) )
func main() { func main() {
@@ -177,7 +177,8 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
// Load configuration from file, env vars, and CLI args // Load configuration from file, env vars, and CLI args
// Priority: CLI args > Env vars > Config file > Defaults // Priority: CLI args > Env vars > Config file > Defaults
config, showVersion, showConfig, err := LoadConfig(os.Args[1:]) // Use the passed args parameter instead of os.Args[1:] to support Windows service mode
config, showVersion, showConfig, err := LoadConfig(args)
if err != nil { if err != nil {
fmt.Printf("Failed to load configuration: %v\n", err) fmt.Printf("Failed to load configuration: %v\n", err)
return return
@@ -209,7 +210,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
} }
// Create a new olm.Config struct and copy values from the main config // Create a new olm.Config struct and copy values from the main config
olmConfig := olm.GlobalConfig{ olmConfig := olmpkg.OlmConfig{
LogLevel: config.LogLevel, LogLevel: config.LogLevel,
EnableAPI: config.EnableAPI, EnableAPI: config.EnableAPI,
HTTPAddr: config.HTTPAddr, HTTPAddr: config.HTTPAddr,
@@ -218,15 +219,20 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
Agent: "Olm CLI", Agent: "Olm CLI",
OnExit: cancel, // Pass cancel function directly to trigger shutdown OnExit: cancel, // Pass cancel function directly to trigger shutdown
OnTerminated: cancel, OnTerminated: cancel,
PprofAddr: ":4444", // TODO: REMOVE OR MAKE CONFIGURABLE
}
olm, err := olmpkg.Init(ctx, olmConfig)
if err != nil {
logger.Fatal("Failed to initialize olm: %v", err)
} }
olm.Init(ctx, olmConfig)
if err := olm.StartApi(); err != nil { if err := olm.StartApi(); err != nil {
logger.Fatal("Failed to start API server: %v", err) logger.Fatal("Failed to start API server: %v", err)
} }
if config.ID != "" && config.Secret != "" && config.Endpoint != "" { if config.ID != "" && config.Secret != "" && config.Endpoint != "" {
tunnelConfig := olm.TunnelConfig{ tunnelConfig := olmpkg.TunnelConfig{
Endpoint: config.Endpoint, Endpoint: config.Endpoint,
ID: config.ID, ID: config.ID,
Secret: config.Secret, Secret: config.Secret,
@@ -235,7 +241,7 @@ func runOlmMainWithArgs(ctx context.Context, cancel context.CancelFunc, signalCt
DNS: config.DNS, DNS: config.DNS,
UpstreamDNS: config.UpstreamDNS, UpstreamDNS: config.UpstreamDNS,
InterfaceName: config.InterfaceName, InterfaceName: config.InterfaceName,
Holepunch: config.Holepunch, Holepunch: !config.DisableHolepunch,
TlsClientCert: config.TlsClientCert, TlsClientCert: config.TlsClientCert,
PingIntervalDuration: config.PingIntervalDuration, PingIntervalDuration: config.PingIntervalDuration,
PingTimeoutDuration: config.PingTimeoutDuration, PingTimeoutDuration: config.PingTimeoutDuration,

78
olm.iss
View File

@@ -44,8 +44,8 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
[Files] [Files]
; The 'DestName' flag ensures that 'olm_windows_amd64.exe' is installed as 'olm.exe' ; The 'DestName' flag ensures that 'olm_windows_amd64.exe' is installed as 'olm.exe'
Source: "C:\Users\Administrator\Downloads\olm_windows_amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion Source: "Z:\olm_windows_amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion
Source: "C:\Users\Administrator\Downloads\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "Z:\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion
; NOTE: Don't use "Flags: ignoreversion" on any shared system files ; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons] [Icons]
@@ -57,13 +57,13 @@ Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
; The 'Path' variable is located under 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'. ; The 'Path' variable is located under 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'.
; ValueType: expandsz allows for environment variables (like %ProgramFiles%) in the path. ; ValueType: expandsz allows for environment variables (like %ProgramFiles%) in the path.
; ValueData: "{olddata};{app}" appends the current application directory to the existing PATH. ; ValueData: "{olddata};{app}" appends the current application directory to the existing PATH.
; Flags: uninsdeletevalue ensures the entry is removed upon uninstallation. ; Note: Removal during uninstallation is handled by CurUninstallStepChanged procedure in [Code] section.
; Check: IsWin64 ensures this is applied on 64-bit systems, which matches ArchitecturesAllowed. ; Check: NeedsAddPath ensures this is applied only if the path is not already present.
[Registry] [Registry]
; Add the application's installation directory to the system PATH. ; Add the application's installation directory to the system PATH.
Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \ Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \ ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
Flags: uninsdeletevalue; Check: NeedsAddPath(ExpandConstant('{app}')) Check: NeedsAddPath(ExpandConstant('{app}'))
[Code] [Code]
function NeedsAddPath(Path: string): boolean; function NeedsAddPath(Path: string): boolean;
@@ -78,11 +78,75 @@ begin
Result := True; Result := True;
exit; exit;
end; end;
// Perform a case-insensitive check to see if the path is already present. // Perform a case-insensitive check to see if the path is already present.
// We add semicolons to prevent partial matches (e.g., matching C:\App in C:\App2). // We add semicolons to prevent partial matches (e.g., matching C:\App in C:\App2).
if Pos(';' + UpperCase(Path) + ';', ';' + UpperCase(OrigPath) + ';') > 0 then if Pos(';' + UpperCase(Path) + ';', ';' + UpperCase(OrigPath) + ';') > 0 then
Result := False Result := False
else else
Result := True; Result := True;
end; end;
procedure RemovePathEntry(PathToRemove: string);
var
OrigPath: string;
NewPath: string;
PathList: TStringList;
I: Integer;
begin
if not RegQueryStringValue(HKEY_LOCAL_MACHINE,
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
'Path', OrigPath)
then begin
// Path variable doesn't exist, nothing to remove
exit;
end;
// Create a string list to parse the PATH entries
PathList := TStringList.Create;
try
// Split the PATH by semicolons
PathList.Delimiter := ';';
PathList.StrictDelimiter := True;
PathList.DelimitedText := OrigPath;
// Find and remove the matching entry (case-insensitive)
for I := PathList.Count - 1 downto 0 do
begin
if CompareText(Trim(PathList[I]), Trim(PathToRemove)) = 0 then
begin
Log('Found and removing PATH entry: ' + PathList[I]);
PathList.Delete(I);
end;
end;
// Reconstruct the PATH
NewPath := PathList.DelimitedText;
// Write the new PATH back to the registry
if RegWriteExpandStringValue(HKEY_LOCAL_MACHINE,
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
'Path', NewPath)
then
Log('Successfully removed path entry: ' + PathToRemove)
else
Log('Failed to write modified PATH to registry');
finally
PathList.Free;
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
AppPath: string;
begin
if CurUninstallStep = usUninstall then
begin
// Get the application installation path
AppPath := ExpandConstant('{app}');
Log('Removing PATH entry for: ' + AppPath);
// Remove only our path entry from the system PATH
RemovePathEntry(AppPath);
end;
end;

299
olm/connect.go Normal file
View File

@@ -0,0 +1,299 @@
package olm
import (
"encoding/json"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/network"
olmDevice "github.com/fosrl/olm/device"
"github.com/fosrl/olm/dns"
dnsOverride "github.com/fosrl/olm/dns/override"
"github.com/fosrl/olm/peers"
"github.com/fosrl/olm/websocket"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun"
)
// OlmErrorData represents the error data sent from the server
type OlmErrorData struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (o *Olm) handleConnect(msg websocket.WSMessage) {
logger.Debug("Received message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring connect message")
return
}
var wgData WgData
if o.registered {
logger.Info("Already connected. Ignoring new connection request.")
return
}
if o.stopRegister != nil {
o.stopRegister()
o.stopRegister = nil
}
if o.updateRegister != nil {
o.updateRegister = nil
}
// if there is an existing tunnel then close it
if o.dev != nil {
logger.Info("Got new message. Closing existing tunnel!")
o.dev.Close()
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info("Error marshaling data: %v", err)
return
}
if err := json.Unmarshal(jsonData, &wgData); err != nil {
logger.Info("Error unmarshaling target data: %v", err)
return
}
o.tdev, err = func() (tun.Device, error) {
if o.tunnelConfig.FileDescriptorTun != 0 {
return olmDevice.CreateTUNFromFD(o.tunnelConfig.FileDescriptorTun, o.tunnelConfig.MTU)
}
ifName := o.tunnelConfig.InterfaceName
if runtime.GOOS == "darwin" { // this is if we dont pass a fd
ifName, err = network.FindUnusedUTUN()
if err != nil {
return nil, err
}
}
return tun.CreateTUN(ifName, o.tunnelConfig.MTU)
}()
if err != nil {
logger.Error("Failed to create TUN device: %v", err)
return
}
// if config.FileDescriptorTun == 0 {
if realInterfaceName, err2 := o.tdev.Name(); err2 == nil { // if the interface is defined then this should not really do anything?
o.tunnelConfig.InterfaceName = realInterfaceName
}
// }
// Wrap TUN device with packet filter for DNS proxy
o.middleDev = olmDevice.NewMiddleDevice(o.tdev)
wgLogger := logger.GetLogger().GetWireGuardLogger("wireguard: ")
// Use filtered device instead of raw TUN device
o.dev = device.NewDevice(o.middleDev, o.sharedBind, (*device.Logger)(wgLogger))
if o.tunnelConfig.EnableUAPI {
fileUAPI, err := func() (*os.File, error) {
if o.tunnelConfig.FileDescriptorUAPI != 0 {
fd, err := strconv.ParseUint(fmt.Sprintf("%d", o.tunnelConfig.FileDescriptorUAPI), 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid UAPI file descriptor: %v", err)
}
return os.NewFile(uintptr(fd), ""), nil
}
return olmDevice.UapiOpen(o.tunnelConfig.InterfaceName)
}()
if err != nil {
logger.Error("UAPI listen error: %v", err)
os.Exit(1)
return
}
o.uapiListener, err = olmDevice.UapiListen(o.tunnelConfig.InterfaceName, fileUAPI)
if err != nil {
logger.Error("Failed to listen on uapi socket: %v", err)
os.Exit(1)
}
go func() {
for {
conn, err := o.uapiListener.Accept()
if err != nil {
return
}
go o.dev.IpcHandle(conn)
}
}()
logger.Info("UAPI listener started")
}
if err = o.dev.Up(); err != nil {
logger.Error("Failed to bring up WireGuard device: %v", err)
}
// Extract interface IP (strip CIDR notation if present)
interfaceIP := wgData.TunnelIP
if strings.Contains(interfaceIP, "/") {
interfaceIP = strings.Split(interfaceIP, "/")[0]
}
// Create and start DNS proxy
o.dnsProxy, err = dns.NewDNSProxy(o.middleDev, o.tunnelConfig.MTU, wgData.UtilitySubnet, o.tunnelConfig.UpstreamDNS, o.tunnelConfig.TunnelDNS, interfaceIP)
if err != nil {
logger.Error("Failed to create DNS proxy: %v", err)
}
if err = network.ConfigureInterface(o.tunnelConfig.InterfaceName, wgData.TunnelIP, o.tunnelConfig.MTU); err != nil {
logger.Error("Failed to o.tunnelConfigure interface: %v", err)
}
if network.AddRoutes([]string{wgData.UtilitySubnet}, o.tunnelConfig.InterfaceName); err != nil { // also route the utility subnet
logger.Error("Failed to add route for utility subnet: %v", err)
}
// Create peer manager with integrated peer monitoring
o.peerManager = peers.NewPeerManager(peers.PeerManagerConfig{
Device: o.dev,
DNSProxy: o.dnsProxy,
InterfaceName: o.tunnelConfig.InterfaceName,
PrivateKey: o.privateKey,
MiddleDev: o.middleDev,
LocalIP: interfaceIP,
SharedBind: o.sharedBind,
WSClient: o.websocket,
APIServer: o.apiServer,
})
for i := range wgData.Sites {
site := wgData.Sites[i]
var siteEndpoint string
// here we are going to take the relay endpoint if it exists which means we requested a relay for this peer
if site.RelayEndpoint != "" {
siteEndpoint = site.RelayEndpoint
} else {
siteEndpoint = site.Endpoint
}
o.apiServer.AddPeerStatus(site.SiteId, site.Name, false, 0, siteEndpoint, false)
if err := o.peerManager.AddPeer(site); err != nil {
logger.Error("Failed to add peer: %v", err)
return
}
logger.Info("Configured peer %s", site.PublicKey)
}
o.peerManager.Start()
if err := o.dnsProxy.Start(); err != nil { // start DNS proxy first so there is no downtime
logger.Error("Failed to start DNS proxy: %v", err)
}
if o.tunnelConfig.OverrideDNS {
// Set up DNS override to use our DNS proxy
if err := dnsOverride.SetupDNSOverride(o.tunnelConfig.InterfaceName, o.dnsProxy.GetProxyIP()); err != nil {
logger.Error("Failed to setup DNS override: %v", err)
return
}
network.SetDNSServers([]string{o.dnsProxy.GetProxyIP().String()})
}
o.apiServer.SetRegistered(true)
o.registered = true
// Start ping monitor now that we are registered and connected
o.websocket.StartPingMonitor()
// Invoke onConnected callback if configured
if o.olmConfig.OnConnected != nil {
go o.olmConfig.OnConnected()
}
logger.Info("WireGuard device created.")
}
func (o *Olm) handleOlmError(msg websocket.WSMessage) {
logger.Debug("Received olm error message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring olm error message")
return
}
var errorData OlmErrorData
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling olm error data: %v", err)
return
}
if err := json.Unmarshal(jsonData, &errorData); err != nil {
logger.Error("Error unmarshaling olm error data: %v", err)
return
}
logger.Error("Olm error (code: %s): %s", errorData.Code, errorData.Message)
// Set the olm error in the API server so it can be exposed via status
o.apiServer.SetOlmError(errorData.Code, errorData.Message)
// Invoke onOlmError callback if configured
if o.olmConfig.OnOlmError != nil {
go o.olmConfig.OnOlmError(errorData.Code, errorData.Message)
}
}
func (o *Olm) handleTerminate(msg websocket.WSMessage) {
logger.Info("Received terminate message")
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring terminate message")
return
}
var errorData OlmErrorData
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling terminate error data: %v", err)
} else {
if err := json.Unmarshal(jsonData, &errorData); err != nil {
logger.Error("Error unmarshaling terminate error data: %v", err)
} else {
logger.Info("Terminate reason (code: %s): %s", errorData.Code, errorData.Message)
if errorData.Code == "TERMINATED_INACTIVITY" {
logger.Info("Ignoring...")
return
}
// Set the olm error in the API server so it can be exposed via status
o.apiServer.SetOlmError(errorData.Code, errorData.Message)
}
}
o.apiServer.SetTerminated(true)
o.apiServer.SetConnectionStatus(false)
o.apiServer.SetRegistered(false)
o.apiServer.ClearPeerStatuses()
network.ClearNetworkSettings()
o.Close()
if o.olmConfig.OnTerminated != nil {
go o.olmConfig.OnTerminated()
}
}

365
olm/data.go Normal file
View File

@@ -0,0 +1,365 @@
package olm
import (
"encoding/json"
"time"
"github.com/fosrl/newt/holepunch"
"github.com/fosrl/newt/logger"
"github.com/fosrl/olm/peers"
"github.com/fosrl/olm/websocket"
)
func (o *Olm) handleWgPeerAddData(msg websocket.WSMessage) {
logger.Debug("Received add-remote-subnets-aliases message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring add-remote-subnets-aliases message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var addSubnetsData peers.PeerAdd
if err := json.Unmarshal(jsonData, &addSubnetsData); err != nil {
logger.Error("Error unmarshaling add-remote-subnets data: %v", err)
return
}
if _, exists := o.peerManager.GetPeer(addSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for removing remote subnets and aliases", addSubnetsData.SiteId)
return
}
// Add new subnets
for _, subnet := range addSubnetsData.RemoteSubnets {
if err := o.peerManager.AddRemoteSubnet(addSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
}
}
// Add new aliases
for _, alias := range addSubnetsData.Aliases {
if err := o.peerManager.AddAlias(addSubnetsData.SiteId, alias); err != nil {
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
}
}
}
func (o *Olm) handleWgPeerRemoveData(msg websocket.WSMessage) {
logger.Debug("Received remove-remote-subnets-aliases message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring remove-remote-subnets-aliases message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var removeSubnetsData peers.RemovePeerData
if err := json.Unmarshal(jsonData, &removeSubnetsData); err != nil {
logger.Error("Error unmarshaling remove-remote-subnets data: %v", err)
return
}
if _, exists := o.peerManager.GetPeer(removeSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for removing remote subnets and aliases", removeSubnetsData.SiteId)
return
}
// Remove subnets
for _, subnet := range removeSubnetsData.RemoteSubnets {
if err := o.peerManager.RemoveRemoteSubnet(removeSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
}
}
// Remove aliases
for _, alias := range removeSubnetsData.Aliases {
if err := o.peerManager.RemoveAlias(removeSubnetsData.SiteId, alias.Alias); err != nil {
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
}
}
}
func (o *Olm) handleWgPeerUpdateData(msg websocket.WSMessage) {
logger.Debug("Received update-remote-subnets-aliases message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring update-remote-subnets-aliases message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var updateSubnetsData peers.UpdatePeerData
if err := json.Unmarshal(jsonData, &updateSubnetsData); err != nil {
logger.Error("Error unmarshaling update-remote-subnets data: %v", err)
return
}
if _, exists := o.peerManager.GetPeer(updateSubnetsData.SiteId); !exists {
logger.Debug("Peer %d not found for updating remote subnets and aliases", updateSubnetsData.SiteId)
return
}
// Add new subnets BEFORE removing old ones to preserve shared subnets
// This ensures that if an old and new subnet are the same on different peers,
// the route won't be temporarily removed
for _, subnet := range updateSubnetsData.NewRemoteSubnets {
if err := o.peerManager.AddRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to add allowed IP %s: %v", subnet, err)
}
}
// Remove old subnets after new ones are added
for _, subnet := range updateSubnetsData.OldRemoteSubnets {
if err := o.peerManager.RemoveRemoteSubnet(updateSubnetsData.SiteId, subnet); err != nil {
logger.Error("Failed to remove allowed IP %s: %v", subnet, err)
}
}
// Add new aliases BEFORE removing old ones to preserve shared IP addresses
// This ensures that if an old and new alias share the same IP, the IP won't be
// temporarily removed from the allowed IPs list
for _, alias := range updateSubnetsData.NewAliases {
if err := o.peerManager.AddAlias(updateSubnetsData.SiteId, alias); err != nil {
logger.Error("Failed to add alias %s: %v", alias.Alias, err)
}
}
// Remove old aliases after new ones are added
for _, alias := range updateSubnetsData.OldAliases {
if err := o.peerManager.RemoveAlias(updateSubnetsData.SiteId, alias.Alias); err != nil {
logger.Error("Failed to remove alias %s: %v", alias.Alias, err)
}
}
logger.Info("Successfully updated remote subnets and aliases for peer %d", updateSubnetsData.SiteId)
}
// Handler for syncing peer configuration - reconciles expected state with actual state
func (o *Olm) handleSync(msg websocket.WSMessage) {
logger.Debug("Received sync message: %v", msg.Data)
if !o.registered {
logger.Warn("Not connected, ignoring sync request")
return
}
if o.peerManager == nil {
logger.Warn("Peer manager not initialized, ignoring sync request")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling sync data: %v", err)
return
}
var syncData SyncData
if err := json.Unmarshal(jsonData, &syncData); err != nil {
logger.Error("Error unmarshaling sync data: %v", err)
return
}
// Sync exit nodes for hole punching
o.syncExitNodes(syncData.ExitNodes)
// Build a map of expected peers from the incoming data
expectedPeers := make(map[int]peers.SiteConfig)
for _, site := range syncData.Sites {
expectedPeers[site.SiteId] = site
}
// Get all current peers
currentPeers := o.peerManager.GetAllPeers()
currentPeerMap := make(map[int]peers.SiteConfig)
for _, peer := range currentPeers {
currentPeerMap[peer.SiteId] = peer
}
// Find peers to remove (in current but not in expected)
for siteId := range currentPeerMap {
if _, exists := expectedPeers[siteId]; !exists {
logger.Info("Sync: Removing peer for site %d (no longer in expected config)", siteId)
if err := o.peerManager.RemovePeer(siteId); err != nil {
logger.Error("Sync: Failed to remove peer %d: %v", siteId, err)
} else {
// Remove any exit nodes associated with this peer from hole punching
if o.holePunchManager != nil {
removed := o.holePunchManager.RemoveExitNodesByPeer(siteId)
if removed > 0 {
logger.Info("Sync: Removed %d exit nodes associated with peer %d from hole punch rotation", removed, siteId)
}
}
}
}
}
// Find peers to add (in expected but not in current) and peers to update
for siteId, expectedSite := range expectedPeers {
if _, exists := currentPeerMap[siteId]; !exists {
// New peer - add it using the add flow (with holepunch)
logger.Info("Sync: Adding new peer for site %d", siteId)
o.holePunchManager.TriggerHolePunch()
// // TODO: do we need to send the message to the cloud to add the peer that way?
// if err := o.peerManager.AddPeer(expectedSite); err != nil {
// logger.Error("Sync: Failed to add peer %d: %v", siteId, err)
// } else {
// logger.Info("Sync: Successfully added peer for site %d", siteId)
// }
// add the peer via the server
// this is important because newt needs to get triggered as well to add the peer once the hp is complete
o.stopPeerSend, _ = o.websocket.SendMessageInterval("olm/wg/server/peer/add", map[string]interface{}{
"siteId": expectedSite.SiteId,
}, 1*time.Second, 10)
} else {
// Existing peer - check if update is needed
currentSite := currentPeerMap[siteId]
needsUpdate := false
// Check if any fields have changed
if expectedSite.Endpoint != "" && expectedSite.Endpoint != currentSite.Endpoint {
needsUpdate = true
}
if expectedSite.RelayEndpoint != "" && expectedSite.RelayEndpoint != currentSite.RelayEndpoint {
needsUpdate = true
}
if expectedSite.PublicKey != "" && expectedSite.PublicKey != currentSite.PublicKey {
needsUpdate = true
}
if expectedSite.ServerIP != "" && expectedSite.ServerIP != currentSite.ServerIP {
needsUpdate = true
}
if expectedSite.ServerPort != 0 && expectedSite.ServerPort != currentSite.ServerPort {
needsUpdate = true
}
// Check remote subnets
if expectedSite.RemoteSubnets != nil && !slicesEqual(expectedSite.RemoteSubnets, currentSite.RemoteSubnets) {
needsUpdate = true
}
// Check aliases
if expectedSite.Aliases != nil && !aliasesEqual(expectedSite.Aliases, currentSite.Aliases) {
needsUpdate = true
}
if needsUpdate {
logger.Info("Sync: Updating peer for site %d", siteId)
// Merge expected data with current data
siteConfig := currentSite
if expectedSite.Endpoint != "" {
siteConfig.Endpoint = expectedSite.Endpoint
}
if expectedSite.RelayEndpoint != "" {
siteConfig.RelayEndpoint = expectedSite.RelayEndpoint
}
if expectedSite.PublicKey != "" {
siteConfig.PublicKey = expectedSite.PublicKey
}
if expectedSite.ServerIP != "" {
siteConfig.ServerIP = expectedSite.ServerIP
}
if expectedSite.ServerPort != 0 {
siteConfig.ServerPort = expectedSite.ServerPort
}
if expectedSite.RemoteSubnets != nil {
siteConfig.RemoteSubnets = expectedSite.RemoteSubnets
}
if expectedSite.Aliases != nil {
siteConfig.Aliases = expectedSite.Aliases
}
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
logger.Error("Sync: Failed to update peer %d: %v", siteId, err)
} else {
// If the endpoint changed, trigger holepunch to refresh NAT mappings
if expectedSite.Endpoint != "" && expectedSite.Endpoint != currentSite.Endpoint {
logger.Info("Sync: Endpoint changed for site %d, triggering holepunch to refresh NAT mappings", siteId)
o.holePunchManager.TriggerHolePunch()
o.holePunchManager.ResetServerHolepunchInterval()
}
logger.Info("Sync: Successfully updated peer for site %d", siteId)
}
}
}
}
logger.Info("Sync completed: processed %d expected peers, had %d current peers", len(expectedPeers), len(currentPeers))
}
// syncExitNodes reconciles the expected exit nodes with the current ones in the hole punch manager
func (o *Olm) syncExitNodes(expectedExitNodes []SyncExitNode) {
if o.holePunchManager == nil {
logger.Warn("Hole punch manager not initialized, skipping exit node sync")
return
}
// Build a map of expected exit nodes by endpoint
expectedExitNodeMap := make(map[string]SyncExitNode)
for _, exitNode := range expectedExitNodes {
expectedExitNodeMap[exitNode.Endpoint] = exitNode
}
// Get current exit nodes from hole punch manager
currentExitNodes := o.holePunchManager.GetExitNodes()
currentExitNodeMap := make(map[string]holepunch.ExitNode)
for _, exitNode := range currentExitNodes {
currentExitNodeMap[exitNode.Endpoint] = exitNode
}
// Find exit nodes to remove (in current but not in expected)
for endpoint := range currentExitNodeMap {
if _, exists := expectedExitNodeMap[endpoint]; !exists {
logger.Info("Sync: Removing exit node %s (no longer in expected config)", endpoint)
o.holePunchManager.RemoveExitNode(endpoint)
}
}
// Find exit nodes to add (in expected but not in current)
for endpoint, expectedExitNode := range expectedExitNodeMap {
if _, exists := currentExitNodeMap[endpoint]; !exists {
logger.Info("Sync: Adding new exit node %s", endpoint)
relayPort := expectedExitNode.RelayPort
if relayPort == 0 {
relayPort = 21820 // default relay port
}
hpExitNode := holepunch.ExitNode{
Endpoint: expectedExitNode.Endpoint,
RelayPort: relayPort,
PublicKey: expectedExitNode.PublicKey,
SiteIds: expectedExitNode.SiteIds,
}
if o.holePunchManager.AddExitNode(hpExitNode) {
logger.Info("Sync: Successfully added exit node %s", endpoint)
}
o.holePunchManager.TriggerHolePunch()
}
}
logger.Info("Sync exit nodes completed: processed %d expected exit nodes, had %d current exit nodes", len(expectedExitNodeMap), len(currentExitNodeMap))
}

1389
olm/olm.go

File diff suppressed because it is too large Load Diff

10
olm/olm_unix.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build !windows
package olm
import "syscall"
// closeFD closes a file descriptor in a platform-specific way
func closeFD(fd uint32) error {
return syscall.Close(int(fd))
}

10
olm/olm_windows.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build windows
package olm
import "syscall"
// closeFD closes a file descriptor in a platform-specific way
func closeFD(fd uint32) error {
return syscall.Close(syscall.Handle(fd))
}

282
olm/peer.go Normal file
View File

@@ -0,0 +1,282 @@
package olm
import (
"encoding/json"
"time"
"github.com/fosrl/newt/holepunch"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/util"
"github.com/fosrl/olm/peers"
"github.com/fosrl/olm/websocket"
)
func (o *Olm) handleWgPeerAdd(msg websocket.WSMessage) {
logger.Debug("Received add-peer message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring add-peer message")
return
}
if o.stopPeerSend != nil {
o.stopPeerSend()
o.stopPeerSend = nil
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var siteConfig peers.SiteConfig
if err := json.Unmarshal(jsonData, &siteConfig); err != nil {
logger.Error("Error unmarshaling add data: %v", err)
return
}
_ = o.holePunchManager.TriggerHolePunch() // Trigger immediate hole punch attempt so that if the peer decides to relay we have already punched close to when we need it
if err := o.peerManager.AddPeer(siteConfig); err != nil {
logger.Error("Failed to add peer: %v", err)
return
}
logger.Info("Successfully added peer for site %d", siteConfig.SiteId)
}
func (o *Olm) handleWgPeerRemove(msg websocket.WSMessage) {
logger.Debug("Received remove-peer message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring remove-peer message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var removeData peers.PeerRemove
if err := json.Unmarshal(jsonData, &removeData); err != nil {
logger.Error("Error unmarshaling remove data: %v", err)
return
}
if err := o.peerManager.RemovePeer(removeData.SiteId); err != nil {
logger.Error("Failed to remove peer: %v", err)
return
}
// Remove any exit nodes associated with this peer from hole punching
if o.holePunchManager != nil {
removed := o.holePunchManager.RemoveExitNodesByPeer(removeData.SiteId)
if removed > 0 {
logger.Info("Removed %d exit nodes associated with peer %d from hole punch rotation", removed, removeData.SiteId)
}
}
logger.Info("Successfully removed peer for site %d", removeData.SiteId)
}
func (o *Olm) handleWgPeerUpdate(msg websocket.WSMessage) {
logger.Debug("Received update-peer message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring update-peer message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var updateData peers.SiteConfig
if err := json.Unmarshal(jsonData, &updateData); err != nil {
logger.Error("Error unmarshaling update data: %v", err)
return
}
// Get existing peer from PeerManager
existingPeer, exists := o.peerManager.GetPeer(updateData.SiteId)
if !exists {
logger.Warn("Peer with site ID %d not found", updateData.SiteId)
return
}
// Create updated site config by merging with existing data
siteConfig := existingPeer
if updateData.Endpoint != "" {
siteConfig.Endpoint = updateData.Endpoint
}
if updateData.RelayEndpoint != "" {
siteConfig.RelayEndpoint = updateData.RelayEndpoint
}
if updateData.PublicKey != "" {
siteConfig.PublicKey = updateData.PublicKey
}
if updateData.ServerIP != "" {
siteConfig.ServerIP = updateData.ServerIP
}
if updateData.ServerPort != 0 {
siteConfig.ServerPort = updateData.ServerPort
}
if updateData.RemoteSubnets != nil {
siteConfig.RemoteSubnets = updateData.RemoteSubnets
}
if err := o.peerManager.UpdatePeer(siteConfig); err != nil {
logger.Error("Failed to update peer: %v", err)
return
}
// If the endpoint changed, trigger holepunch to refresh NAT mappings
if updateData.Endpoint != "" && updateData.Endpoint != existingPeer.Endpoint {
logger.Info("Endpoint changed for site %d, triggering holepunch to refresh NAT mappings", updateData.SiteId)
_ = o.holePunchManager.TriggerHolePunch()
o.holePunchManager.ResetServerHolepunchInterval()
}
logger.Info("Successfully updated peer for site %d", updateData.SiteId)
}
func (o *Olm) handleWgPeerRelay(msg websocket.WSMessage) {
logger.Debug("Received relay-peer message: %v", msg.Data)
// Check if peerManager is still valid (may be nil during shutdown)
if o.peerManager == nil {
logger.Debug("Ignoring relay message: peerManager is nil (shutdown in progress)")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var relayData peers.RelayPeerData
if err := json.Unmarshal(jsonData, &relayData); err != nil {
logger.Error("Error unmarshaling relay data: %v", err)
return
}
primaryRelay, err := util.ResolveDomain(relayData.RelayEndpoint)
if err != nil {
logger.Error("Failed to resolve primary relay endpoint: %v", err)
return
}
// Update HTTP server to mark this peer as using relay
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.RelayEndpoint, true)
o.peerManager.RelayPeer(relayData.SiteId, primaryRelay, relayData.RelayPort)
}
func (o *Olm) handleWgPeerUnrelay(msg websocket.WSMessage) {
logger.Debug("Received unrelay-peer message: %v", msg.Data)
// Check if peerManager is still valid (may be nil during shutdown)
if o.peerManager == nil {
logger.Debug("Ignoring unrelay message: peerManager is nil (shutdown in progress)")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling data: %v", err)
return
}
var relayData peers.UnRelayPeerData
if err := json.Unmarshal(jsonData, &relayData); err != nil {
logger.Error("Error unmarshaling relay data: %v", err)
return
}
primaryRelay, err := util.ResolveDomain(relayData.Endpoint)
if err != nil {
logger.Warn("Failed to resolve primary relay endpoint: %v", err)
}
// Update HTTP server to mark this peer as using relay
o.apiServer.UpdatePeerRelayStatus(relayData.SiteId, relayData.Endpoint, false)
o.peerManager.UnRelayPeer(relayData.SiteId, primaryRelay)
}
func (o *Olm) handleWgPeerHolepunchAddSite(msg websocket.WSMessage) {
logger.Debug("Received peer-handshake message: %v", msg.Data)
// Check if tunnel is still running
if !o.tunnelRunning {
logger.Debug("Tunnel stopped, ignoring peer-handshake message")
return
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Error("Error marshaling handshake data: %v", err)
return
}
var handshakeData struct {
SiteId int `json:"siteId"`
ExitNode struct {
PublicKey string `json:"publicKey"`
Endpoint string `json:"endpoint"`
RelayPort uint16 `json:"relayPort"`
} `json:"exitNode"`
}
if err := json.Unmarshal(jsonData, &handshakeData); err != nil {
logger.Error("Error unmarshaling handshake data: %v", err)
return
}
// Get existing peer from PeerManager
_, exists := o.peerManager.GetPeer(handshakeData.SiteId)
if exists {
logger.Warn("Peer with site ID %d already added", handshakeData.SiteId)
return
}
relayPort := handshakeData.ExitNode.RelayPort
if relayPort == 0 {
relayPort = 21820 // default relay port
}
siteId := handshakeData.SiteId
exitNode := holepunch.ExitNode{
Endpoint: handshakeData.ExitNode.Endpoint,
RelayPort: relayPort,
PublicKey: handshakeData.ExitNode.PublicKey,
SiteIds: []int{siteId},
}
added := o.holePunchManager.AddExitNode(exitNode)
if added {
logger.Info("Added exit node %s to holepunch rotation for handshake", exitNode.Endpoint)
} else {
logger.Debug("Exit node %s already in holepunch rotation", exitNode.Endpoint)
}
o.holePunchManager.TriggerHolePunch() // Trigger immediate hole punch attempt
o.holePunchManager.ResetServerHolepunchInterval() // start sending immediately again so we fill in the endpoint on the cloud
// Send handshake acknowledgment back to server with retry
o.stopPeerSend, _ = o.websocket.SendMessageInterval("olm/wg/server/peer/add", map[string]interface{}{
"siteId": handshakeData.SiteId,
}, 1*time.Second, 10)
logger.Info("Initiated handshake for site %d with exit node %s", handshakeData.SiteId, handshakeData.ExitNode.Endpoint)
}

View File

@@ -12,9 +12,22 @@ type WgData struct {
UtilitySubnet string `json:"utilitySubnet"` // this is for things like the DNS server, and alias addresses UtilitySubnet string `json:"utilitySubnet"` // this is for things like the DNS server, and alias addresses
} }
type GlobalConfig struct { type SyncData struct {
Sites []peers.SiteConfig `json:"sites"`
ExitNodes []SyncExitNode `json:"exitNodes"`
}
type SyncExitNode struct {
Endpoint string `json:"endpoint"`
RelayPort uint16 `json:"relayPort"`
PublicKey string `json:"publicKey"`
SiteIds []int `json:"siteIds"`
}
type OlmConfig struct {
// Logging // Logging
LogLevel string LogLevel string
LogFilePath string
// HTTP server // HTTP server
EnableAPI bool EnableAPI bool
@@ -23,11 +36,17 @@ type GlobalConfig struct {
Version string Version string
Agent string Agent string
WakeUpDebounce time.Duration
// Debugging
PprofAddr string // Address to serve pprof on (e.g., "localhost:6060")
// Callbacks // Callbacks
OnRegistered func() OnRegistered func()
OnConnected func() OnConnected func()
OnTerminated func() OnTerminated func()
OnAuthError func(statusCode int, message string) // Called when auth fails (401/403) OnAuthError func(statusCode int, message string) // Called when auth fails (401/403)
OnOlmError func(code string, message string) // Called when registration fails
OnExit func() // Called when exit is requested via API OnExit func() // Called when exit is requested via API
} }
@@ -61,6 +80,10 @@ type TunnelConfig struct {
EnableUAPI bool EnableUAPI bool
OverrideDNS bool OverrideDNS bool
TunnelDNS bool
InitialFingerprint map[string]any
InitialPostures map[string]any
DisableRelay bool DisableRelay bool
} }

View File

@@ -1,96 +1,45 @@
package olm package olm
import ( import (
"fmt" "github.com/fosrl/olm/peers"
"net"
"strings"
"time"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/network"
"github.com/fosrl/olm/websocket"
) )
// Helper function to format endpoints correctly // slicesEqual compares two string slices for equality (order-independent)
func formatEndpoint(endpoint string) string { func slicesEqual(a, b []string) bool {
if endpoint == "" {
return ""
}
// Check if it's already a valid host:port that SplitHostPort can parse (e.g., [::1]:8080 or 1.2.3.4:8080)
_, _, err := net.SplitHostPort(endpoint)
if err == nil {
return endpoint // Already valid, no change needed
}
// If it failed, it might be our malformed "ipv6:port" string. Let's check and fix it.
lastColon := strings.LastIndex(endpoint, ":")
if lastColon > 0 { // Ensure there is a colon and it's not the first character
hostPart := endpoint[:lastColon]
// Check if the host part is a literal IPv6 address
if ip := net.ParseIP(hostPart); ip != nil && ip.To4() == nil {
// It is! Reformat it with brackets.
portPart := endpoint[lastColon+1:]
return fmt.Sprintf("[%s]:%s", hostPart, portPart)
}
}
// If it's not the specific malformed case, return it as is.
return endpoint
}
func sendPing(olm *websocket.Client) error {
err := olm.SendMessage("olm/ping", map[string]interface{}{
"timestamp": time.Now().Unix(),
"userToken": olm.GetConfig().UserToken,
})
if err != nil {
logger.Error("Failed to send ping message: %v", err)
return err
}
logger.Debug("Sent ping message")
return nil
}
func keepSendingPing(olm *websocket.Client) {
// Send ping immediately on startup
if err := sendPing(olm); err != nil {
logger.Error("Failed to send initial ping: %v", err)
} else {
logger.Info("Sent initial ping message")
}
// Set up ticker for one minute intervals
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-stopPing:
logger.Info("Stopping ping messages")
return
case <-ticker.C:
if err := sendPing(olm); err != nil {
logger.Error("Failed to send periodic ping: %v", err)
}
}
}
}
func GetNetworkSettingsJSON() (string, error) {
return network.GetJSON()
}
func GetNetworkSettingsIncrementor() int {
return network.GetIncrementor()
}
// stringSlicesEqual compares two string slices for equality
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
} }
for i := range a { // Create a map to count occurrences in slice a
if a[i] != b[i] { counts := make(map[string]int)
for _, v := range a {
counts[v]++
}
// Check if slice b has the same elements
for _, v := range b {
counts[v]--
if counts[v] < 0 {
return false
}
}
return true
}
// aliasesEqual compares two Alias slices for equality (order-independent)
func aliasesEqual(a, b []peers.Alias) bool {
if len(a) != len(b) {
return false
}
// Create a map to count occurrences in slice a (using alias+address as key)
counts := make(map[string]int)
for _, v := range a {
key := v.Alias + "|" + v.AliasAddress
counts[key]++
}
// Check if slice b has the same elements
for _, v := range b {
key := v.Alias + "|" + v.AliasAddress
counts[key]--
if counts[key] < 0 {
return false return false
} }
} }

View File

@@ -50,6 +50,8 @@ type PeerManager struct {
// key is the CIDR string, value is a set of siteIds that want this IP // key is the CIDR string, value is a set of siteIds that want this IP
allowedIPClaims map[string]map[int]bool allowedIPClaims map[string]map[int]bool
APIServer *api.API APIServer *api.API
PersistentKeepalive int
} }
// NewPeerManager creates a new PeerManager with an internal PeerMonitor // NewPeerManager creates a new PeerManager with an internal PeerMonitor
@@ -84,6 +86,13 @@ func (pm *PeerManager) GetPeer(siteId int) (SiteConfig, bool) {
return peer, ok return peer, ok
} }
// GetPeerMonitor returns the internal peer monitor instance
func (pm *PeerManager) GetPeerMonitor() *monitor.PeerMonitor {
pm.mu.RLock()
defer pm.mu.RUnlock()
return pm.peerMonitor
}
func (pm *PeerManager) GetAllPeers() []SiteConfig { func (pm *PeerManager) GetAllPeers() []SiteConfig {
pm.mu.RLock() pm.mu.RLock()
defer pm.mu.RUnlock() defer pm.mu.RUnlock()
@@ -120,7 +129,7 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
wgConfig := siteConfig wgConfig := siteConfig
wgConfig.AllowedIps = ownedIPs wgConfig.AllowedIps = ownedIPs
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId)); err != nil { if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive); err != nil {
return err return err
} }
@@ -150,6 +159,8 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
pm.peers[siteConfig.SiteId] = siteConfig pm.peers[siteConfig.SiteId] = siteConfig
pm.APIServer.AddPeerStatus(siteConfig.SiteId, siteConfig.Name, false, 0, siteConfig.Endpoint, false)
// Perform rapid initial holepunch test (outside of lock to avoid blocking) // Perform rapid initial holepunch test (outside of lock to avoid blocking)
// This quickly determines if holepunch is viable and triggers relay if not // This quickly determines if holepunch is viable and triggers relay if not
go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint) go pm.performRapidInitialTest(siteConfig.SiteId, siteConfig.Endpoint)
@@ -157,6 +168,29 @@ func (pm *PeerManager) AddPeer(siteConfig SiteConfig) error {
return nil return nil
} }
// UpdateAllPeersPersistentKeepalive updates the persistent keepalive interval for all peers at once
// without recreating them. Returns a map of siteId to error for any peers that failed to update.
func (pm *PeerManager) UpdateAllPeersPersistentKeepalive(interval int) map[int]error {
pm.mu.RLock()
defer pm.mu.RUnlock()
pm.PersistentKeepalive = interval
errors := make(map[int]error)
for siteId, peer := range pm.peers {
err := UpdatePersistentKeepalive(pm.device, peer.PublicKey, interval)
if err != nil {
errors[siteId] = err
}
}
if len(errors) == 0 {
return nil
}
return errors
}
func (pm *PeerManager) RemovePeer(siteId int) error { func (pm *PeerManager) RemovePeer(siteId int) error {
pm.mu.Lock() pm.mu.Lock()
defer pm.mu.Unlock() defer pm.mu.Unlock()
@@ -236,7 +270,7 @@ func (pm *PeerManager) RemovePeer(siteId int) error {
ownedIPs := pm.getOwnedAllowedIPs(promotedPeerId) ownedIPs := pm.getOwnedAllowedIPs(promotedPeerId)
wgConfig := promotedPeer wgConfig := promotedPeer
wgConfig.AllowedIps = ownedIPs wgConfig.AllowedIps = ownedIPs
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId)); err != nil { if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId), pm.PersistentKeepalive); err != nil {
logger.Error("Failed to update promoted peer %d: %v", promotedPeerId, err) logger.Error("Failed to update promoted peer %d: %v", promotedPeerId, err)
} }
} }
@@ -312,7 +346,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
wgConfig := siteConfig wgConfig := siteConfig
wgConfig.AllowedIps = ownedIPs wgConfig.AllowedIps = ownedIPs
if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId)); err != nil { if err := ConfigurePeer(pm.device, wgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(siteConfig.SiteId), pm.PersistentKeepalive); err != nil {
return err return err
} }
@@ -322,7 +356,7 @@ func (pm *PeerManager) UpdatePeer(siteConfig SiteConfig) error {
promotedOwnedIPs := pm.getOwnedAllowedIPs(promotedPeerId) promotedOwnedIPs := pm.getOwnedAllowedIPs(promotedPeerId)
promotedWgConfig := promotedPeer promotedWgConfig := promotedPeer
promotedWgConfig.AllowedIps = promotedOwnedIPs promotedWgConfig.AllowedIps = promotedOwnedIPs
if err := ConfigurePeer(pm.device, promotedWgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId)); err != nil { if err := ConfigurePeer(pm.device, promotedWgConfig, pm.privateKey, pm.peerMonitor.IsPeerRelayed(promotedPeerId), pm.PersistentKeepalive); err != nil {
logger.Error("Failed to update promoted peer %d: %v", promotedPeerId, err) logger.Error("Failed to update promoted peer %d: %v", promotedPeerId, err)
} }
} }
@@ -741,7 +775,7 @@ func (pm *PeerManager) RemoveAlias(siteId int, aliasName string) error {
} }
// RelayPeer handles failover to the relay server when a peer is disconnected // RelayPeer handles failover to the relay server when a peer is disconnected
func (pm *PeerManager) RelayPeer(siteId int, relayEndpoint string) { func (pm *PeerManager) RelayPeer(siteId int, relayEndpoint string, relayPort uint16) {
pm.mu.Lock() pm.mu.Lock()
peer, exists := pm.peers[siteId] peer, exists := pm.peers[siteId]
if exists { if exists {
@@ -762,10 +796,14 @@ func (pm *PeerManager) RelayPeer(siteId int, relayEndpoint string) {
formattedEndpoint = fmt.Sprintf("[%s]", relayEndpoint) formattedEndpoint = fmt.Sprintf("[%s]", relayEndpoint)
} }
if relayPort == 0 {
relayPort = 21820 // fall back to 21820 for backward compatibility
}
// Update only the endpoint for this peer (update_only preserves other settings) // Update only the endpoint for this peer (update_only preserves other settings)
wgConfig := fmt.Sprintf(`public_key=%s wgConfig := fmt.Sprintf(`public_key=%s
update_only=true update_only=true
endpoint=%s:21820`, util.FixKey(peer.PublicKey), formattedEndpoint) endpoint=%s:%d`, util.FixKey(peer.PublicKey), formattedEndpoint, relayPort)
err := pm.device.IpcSet(wgConfig) err := pm.device.IpcSet(wgConfig)
if err != nil { if err != nil {

View File

@@ -31,8 +31,7 @@ type PeerMonitor struct {
monitors map[int]*Client monitors map[int]*Client
mutex sync.Mutex mutex sync.Mutex
running bool running bool
interval time.Duration timeout time.Duration
timeout time.Duration
maxAttempts int maxAttempts int
wsClient *websocket.Client wsClient *websocket.Client
@@ -42,7 +41,7 @@ type PeerMonitor struct {
stack *stack.Stack stack *stack.Stack
ep *channel.Endpoint ep *channel.Endpoint
activePorts map[uint16]bool activePorts map[uint16]bool
portsLock sync.Mutex portsLock sync.RWMutex
nsCtx context.Context nsCtx context.Context
nsCancel context.CancelFunc nsCancel context.CancelFunc
nsWg sync.WaitGroup nsWg sync.WaitGroup
@@ -50,17 +49,26 @@ type PeerMonitor struct {
// Holepunch testing fields // Holepunch testing fields
sharedBind *bind.SharedBind sharedBind *bind.SharedBind
holepunchTester *holepunch.HolepunchTester holepunchTester *holepunch.HolepunchTester
holepunchInterval time.Duration
holepunchTimeout time.Duration holepunchTimeout time.Duration
holepunchEndpoints map[int]string // siteID -> endpoint for holepunch testing holepunchEndpoints map[int]string // siteID -> endpoint for holepunch testing
holepunchStatus map[int]bool // siteID -> connected status holepunchStatus map[int]bool // siteID -> connected status
holepunchStopChan chan struct{} holepunchStopChan chan struct{}
holepunchUpdateChan chan struct{}
// Relay tracking fields // Relay tracking fields
relayedPeers map[int]bool // siteID -> whether the peer is currently relayed relayedPeers map[int]bool // siteID -> whether the peer is currently relayed
holepunchMaxAttempts int // max consecutive failures before triggering relay holepunchMaxAttempts int // max consecutive failures before triggering relay
holepunchFailures map[int]int // siteID -> consecutive failure count holepunchFailures map[int]int // siteID -> consecutive failure count
// Exponential backoff fields for holepunch monitor
defaultHolepunchMinInterval time.Duration // Minimum interval (initial)
defaultHolepunchMaxInterval time.Duration
holepunchMinInterval time.Duration // Minimum interval (initial)
holepunchMaxInterval time.Duration // Maximum interval (cap for backoff)
holepunchBackoffMultiplier float64 // Multiplier for each stable check
holepunchStableCount map[int]int // siteID -> consecutive stable status count
holepunchCurrentInterval time.Duration // Current interval with backoff applied
// Rapid initial test fields // Rapid initial test fields
rapidTestInterval time.Duration // interval between rapid test attempts rapidTestInterval time.Duration // interval between rapid test attempts
rapidTestTimeout time.Duration // timeout for each rapid test attempt rapidTestTimeout time.Duration // timeout for each rapid test attempt
@@ -78,7 +86,6 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
pm := &PeerMonitor{ pm := &PeerMonitor{
monitors: make(map[int]*Client), monitors: make(map[int]*Client),
interval: 2 * time.Second, // Default check interval (faster)
timeout: 3 * time.Second, timeout: 3 * time.Second,
maxAttempts: 3, maxAttempts: 3,
wsClient: wsClient, wsClient: wsClient,
@@ -88,7 +95,6 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
nsCtx: ctx, nsCtx: ctx,
nsCancel: cancel, nsCancel: cancel,
sharedBind: sharedBind, sharedBind: sharedBind,
holepunchInterval: 2 * time.Second, // Check holepunch every 2 seconds
holepunchTimeout: 2 * time.Second, // Faster timeout holepunchTimeout: 2 * time.Second, // Faster timeout
holepunchEndpoints: make(map[int]string), holepunchEndpoints: make(map[int]string),
holepunchStatus: make(map[int]bool), holepunchStatus: make(map[int]bool),
@@ -101,6 +107,15 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
rapidTestMaxAttempts: 5, // 5 attempts = ~1-1.5 seconds total rapidTestMaxAttempts: 5, // 5 attempts = ~1-1.5 seconds total
apiServer: apiServer, apiServer: apiServer,
wgConnectionStatus: make(map[int]bool), wgConnectionStatus: make(map[int]bool),
// Exponential backoff settings for holepunch monitor
defaultHolepunchMinInterval: 2 * time.Second,
defaultHolepunchMaxInterval: 30 * time.Second,
holepunchMinInterval: 2 * time.Second,
holepunchMaxInterval: 30 * time.Second,
holepunchBackoffMultiplier: 1.5,
holepunchStableCount: make(map[int]int),
holepunchCurrentInterval: 2 * time.Second,
holepunchUpdateChan: make(chan struct{}, 1),
} }
if err := pm.initNetstack(); err != nil { if err := pm.initNetstack(); err != nil {
@@ -116,41 +131,75 @@ func NewPeerMonitor(wsClient *websocket.Client, middleDev *middleDevice.MiddleDe
} }
// SetInterval changes how frequently peers are checked // SetInterval changes how frequently peers are checked
func (pm *PeerMonitor) SetInterval(interval time.Duration) { func (pm *PeerMonitor) SetPeerInterval(minInterval, maxInterval time.Duration) {
pm.mutex.Lock() pm.mutex.Lock()
defer pm.mutex.Unlock() defer pm.mutex.Unlock()
pm.interval = interval
// Update interval for all existing monitors // Update interval for all existing monitors
for _, client := range pm.monitors { for _, client := range pm.monitors {
client.SetPacketInterval(interval) client.SetPacketInterval(minInterval, maxInterval)
} }
logger.Info("Set peer monitor interval to min: %s, max: %s", minInterval, maxInterval)
} }
// SetTimeout changes the timeout for waiting for responses func (pm *PeerMonitor) ResetPeerInterval() {
func (pm *PeerMonitor) SetTimeout(timeout time.Duration) {
pm.mutex.Lock() pm.mutex.Lock()
defer pm.mutex.Unlock() defer pm.mutex.Unlock()
pm.timeout = timeout // Update interval for all existing monitors
// Update timeout for all existing monitors
for _, client := range pm.monitors { for _, client := range pm.monitors {
client.SetTimeout(timeout) client.ResetPacketInterval()
} }
} }
// SetMaxAttempts changes the maximum number of attempts for TestConnection // SetPeerHolepunchInterval sets both the minimum and maximum intervals for holepunch monitoring
func (pm *PeerMonitor) SetMaxAttempts(attempts int) { func (pm *PeerMonitor) SetPeerHolepunchInterval(minInterval, maxInterval time.Duration) {
pm.mutex.Lock()
pm.holepunchMinInterval = minInterval
pm.holepunchMaxInterval = maxInterval
// Reset current interval to the new minimum
pm.holepunchCurrentInterval = minInterval
updateChan := pm.holepunchUpdateChan
pm.mutex.Unlock()
logger.Info("Set holepunch interval to min: %s, max: %s", minInterval, maxInterval)
// Signal the goroutine to apply the new interval if running
if updateChan != nil {
select {
case updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
}
}
// GetPeerHolepunchIntervals returns the current minimum and maximum intervals for holepunch monitoring
func (pm *PeerMonitor) GetPeerHolepunchIntervals() (minInterval, maxInterval time.Duration) {
pm.mutex.Lock() pm.mutex.Lock()
defer pm.mutex.Unlock() defer pm.mutex.Unlock()
pm.maxAttempts = attempts return pm.holepunchMinInterval, pm.holepunchMaxInterval
}
// Update max attempts for all existing monitors func (pm *PeerMonitor) ResetPeerHolepunchInterval() {
for _, client := range pm.monitors { pm.mutex.Lock()
client.SetMaxAttempts(attempts) pm.holepunchMinInterval = pm.defaultHolepunchMinInterval
pm.holepunchMaxInterval = pm.defaultHolepunchMaxInterval
pm.holepunchCurrentInterval = pm.defaultHolepunchMinInterval
updateChan := pm.holepunchUpdateChan
pm.mutex.Unlock()
logger.Info("Reset holepunch interval to defaults: min=%v, max=%v", pm.defaultHolepunchMinInterval, pm.defaultHolepunchMaxInterval)
// Signal the goroutine to apply the new interval if running
if updateChan != nil {
select {
case updateChan <- struct{}{}:
default:
// Channel full or closed, skip
}
} }
} }
@@ -169,10 +218,6 @@ func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint st
return err return err
} }
client.SetPacketInterval(pm.interval)
client.SetTimeout(pm.timeout)
client.SetMaxAttempts(pm.maxAttempts)
pm.monitors[siteID] = client pm.monitors[siteID] = client
pm.holepunchEndpoints[siteID] = holepunchEndpoint pm.holepunchEndpoints[siteID] = holepunchEndpoint
@@ -191,12 +236,12 @@ func (pm *PeerMonitor) AddPeer(siteID int, endpoint string, holepunchEndpoint st
// update holepunch endpoint for a peer // update holepunch endpoint for a peer
func (pm *PeerMonitor) UpdateHolepunchEndpoint(siteID int, endpoint string) { func (pm *PeerMonitor) UpdateHolepunchEndpoint(siteID int, endpoint string) {
go func() { // Short delay to allow WireGuard peer reconfiguration to complete
time.Sleep(3 * time.Second) // The NAT mapping refresh is handled separately by TriggerHolePunch in olm.go
pm.mutex.Lock() pm.mutex.Lock()
defer pm.mutex.Unlock() defer pm.mutex.Unlock()
pm.holepunchEndpoints[siteID] = endpoint pm.holepunchEndpoints[siteID] = endpoint
}() logger.Debug("Updated holepunch endpoint for site %d to %s", siteID, endpoint)
} }
// RapidTestPeer performs a rapid connectivity test for a newly added peer. // RapidTestPeer performs a rapid connectivity test for a newly added peer.
@@ -294,6 +339,12 @@ func (pm *PeerMonitor) RemovePeer(siteID int) {
pm.removePeerUnlocked(siteID) pm.removePeerUnlocked(siteID)
} }
func (pm *PeerMonitor) RemoveHolepunchEndpoint(siteID int) {
pm.mutex.Lock()
defer pm.mutex.Unlock()
delete(pm.holepunchEndpoints, siteID)
}
// Start begins monitoring all peers // Start begins monitoring all peers
func (pm *PeerMonitor) Start() { func (pm *PeerMonitor) Start() {
pm.mutex.Lock() pm.mutex.Lock()
@@ -464,31 +515,59 @@ func (pm *PeerMonitor) stopHolepunchMonitor() {
logger.Info("Stopped holepunch connection monitor") logger.Info("Stopped holepunch connection monitor")
} }
// runHolepunchMonitor runs the holepunch monitoring loop // runHolepunchMonitor runs the holepunch monitoring loop with exponential backoff
func (pm *PeerMonitor) runHolepunchMonitor() { func (pm *PeerMonitor) runHolepunchMonitor() {
ticker := time.NewTicker(pm.holepunchInterval) pm.mutex.Lock()
defer ticker.Stop() pm.holepunchCurrentInterval = pm.holepunchMinInterval
pm.mutex.Unlock()
// Do initial check immediately timer := time.NewTimer(0) // Fire immediately for initial check
pm.checkHolepunchEndpoints() defer timer.Stop()
for { for {
select { select {
case <-pm.holepunchStopChan: case <-pm.holepunchStopChan:
return return
case <-ticker.C: case <-pm.holepunchUpdateChan:
pm.checkHolepunchEndpoints() // Interval settings changed, reset to minimum
pm.mutex.Lock()
pm.holepunchCurrentInterval = pm.holepunchMinInterval
currentInterval := pm.holepunchCurrentInterval
pm.mutex.Unlock()
timer.Reset(currentInterval)
logger.Debug("Holepunch monitor interval updated, reset to %v", currentInterval)
case <-timer.C:
anyStatusChanged := pm.checkHolepunchEndpoints()
pm.mutex.Lock()
if anyStatusChanged {
// Reset to minimum interval on any status change
pm.holepunchCurrentInterval = pm.holepunchMinInterval
} else {
// Apply exponential backoff when stable
newInterval := time.Duration(float64(pm.holepunchCurrentInterval) * pm.holepunchBackoffMultiplier)
if newInterval > pm.holepunchMaxInterval {
newInterval = pm.holepunchMaxInterval
}
pm.holepunchCurrentInterval = newInterval
}
currentInterval := pm.holepunchCurrentInterval
pm.mutex.Unlock()
timer.Reset(currentInterval)
} }
} }
} }
// checkHolepunchEndpoints tests all holepunch endpoints // checkHolepunchEndpoints tests all holepunch endpoints
func (pm *PeerMonitor) checkHolepunchEndpoints() { // Returns true if any endpoint's status changed
func (pm *PeerMonitor) checkHolepunchEndpoints() bool {
pm.mutex.Lock() pm.mutex.Lock()
// Check if we're still running before doing any work // Check if we're still running before doing any work
if !pm.running { if !pm.running {
pm.mutex.Unlock() pm.mutex.Unlock()
return return false
} }
endpoints := make(map[int]string, len(pm.holepunchEndpoints)) endpoints := make(map[int]string, len(pm.holepunchEndpoints))
for siteID, endpoint := range pm.holepunchEndpoints { for siteID, endpoint := range pm.holepunchEndpoints {
@@ -498,8 +577,10 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() {
maxAttempts := pm.holepunchMaxAttempts maxAttempts := pm.holepunchMaxAttempts
pm.mutex.Unlock() pm.mutex.Unlock()
anyStatusChanged := false
for siteID, endpoint := range endpoints { for siteID, endpoint := range endpoints {
logger.Debug("Testing holepunch endpoint for site %d: %s", siteID, endpoint) // logger.Debug("holepunchTester: testing endpoint for site %d: %s", siteID, endpoint)
result := pm.holepunchTester.TestEndpoint(endpoint, timeout) result := pm.holepunchTester.TestEndpoint(endpoint, timeout)
pm.mutex.Lock() pm.mutex.Lock()
@@ -523,7 +604,9 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() {
pm.mutex.Unlock() pm.mutex.Unlock()
// Log status changes // Log status changes
if !exists || previousStatus != result.Success { statusChanged := !exists || previousStatus != result.Success
if statusChanged {
anyStatusChanged = true
if result.Success { if result.Success {
logger.Info("Holepunch to site %d (%s) is CONNECTED (RTT: %v)", siteID, endpoint, result.RTT) logger.Info("Holepunch to site %d (%s) is CONNECTED (RTT: %v)", siteID, endpoint, result.RTT)
} else { } else {
@@ -556,7 +639,7 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() {
pm.mutex.Unlock() pm.mutex.Unlock()
if !stillRunning { if !stillRunning {
return // Stop processing if shutdown is in progress return anyStatusChanged // Stop processing if shutdown is in progress
} }
if !result.Success && !isRelayed && failureCount >= maxAttempts { if !result.Success && !isRelayed && failureCount >= maxAttempts {
@@ -573,6 +656,8 @@ func (pm *PeerMonitor) checkHolepunchEndpoints() {
} }
} }
} }
return anyStatusChanged
} }
// GetHolepunchStatus returns the current holepunch status for all endpoints // GetHolepunchStatus returns the current holepunch status for all endpoints
@@ -644,55 +729,55 @@ func (pm *PeerMonitor) Close() {
logger.Debug("PeerMonitor: Cleanup complete") logger.Debug("PeerMonitor: Cleanup complete")
} }
// TestPeer tests connectivity to a specific peer // // TestPeer tests connectivity to a specific peer
func (pm *PeerMonitor) TestPeer(siteID int) (bool, time.Duration, error) { // func (pm *PeerMonitor) TestPeer(siteID int) (bool, time.Duration, error) {
pm.mutex.Lock() // pm.mutex.Lock()
client, exists := pm.monitors[siteID] // client, exists := pm.monitors[siteID]
pm.mutex.Unlock() // pm.mutex.Unlock()
if !exists { // if !exists {
return false, 0, fmt.Errorf("peer with siteID %d not found", siteID) // return false, 0, fmt.Errorf("peer with siteID %d not found", siteID)
} // }
ctx, cancel := context.WithTimeout(context.Background(), pm.timeout*time.Duration(pm.maxAttempts)) // ctx, cancel := context.WithTimeout(context.Background(), pm.timeout*time.Duration(pm.maxAttempts))
defer cancel() // defer cancel()
connected, rtt := client.TestConnection(ctx) // connected, rtt := client.TestPeerConnection(ctx)
return connected, rtt, nil // return connected, rtt, nil
} // }
// TestAllPeers tests connectivity to all peers // // TestAllPeers tests connectivity to all peers
func (pm *PeerMonitor) TestAllPeers() map[int]struct { // func (pm *PeerMonitor) TestAllPeers() map[int]struct {
Connected bool // Connected bool
RTT time.Duration // RTT time.Duration
} { // } {
pm.mutex.Lock() // pm.mutex.Lock()
peers := make(map[int]*Client, len(pm.monitors)) // peers := make(map[int]*Client, len(pm.monitors))
for siteID, client := range pm.monitors { // for siteID, client := range pm.monitors {
peers[siteID] = client // peers[siteID] = client
} // }
pm.mutex.Unlock() // pm.mutex.Unlock()
results := make(map[int]struct { // results := make(map[int]struct {
Connected bool // Connected bool
RTT time.Duration // RTT time.Duration
}) // })
for siteID, client := range peers { // for siteID, client := range peers {
ctx, cancel := context.WithTimeout(context.Background(), pm.timeout*time.Duration(pm.maxAttempts)) // ctx, cancel := context.WithTimeout(context.Background(), pm.timeout*time.Duration(pm.maxAttempts))
connected, rtt := client.TestConnection(ctx) // connected, rtt := client.TestPeerConnection(ctx)
cancel() // cancel()
results[siteID] = struct { // results[siteID] = struct {
Connected bool // Connected bool
RTT time.Duration // RTT time.Duration
}{ // }{
Connected: connected, // Connected: connected,
RTT: rtt, // RTT: rtt,
} // }
} // }
return results // return results
} // }
// initNetstack initializes the gvisor netstack // initNetstack initializes the gvisor netstack
func (pm *PeerMonitor) initNetstack() error { func (pm *PeerMonitor) initNetstack() error {
@@ -764,9 +849,9 @@ func (pm *PeerMonitor) handlePacket(packet []byte) bool {
} }
// Check if we are listening on this port // Check if we are listening on this port
pm.portsLock.Lock() pm.portsLock.RLock()
active := pm.activePorts[uint16(port)] active := pm.activePorts[uint16(port)]
pm.portsLock.Unlock() pm.portsLock.RUnlock()
if !active { if !active {
return false return false
@@ -797,13 +882,12 @@ func (pm *PeerMonitor) runPacketSender() {
defer pm.nsWg.Done() defer pm.nsWg.Done()
logger.Debug("PeerMonitor: Packet sender goroutine started") logger.Debug("PeerMonitor: Packet sender goroutine started")
// Use a ticker to periodically check for packets without blocking indefinitely
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for { for {
select { // Use blocking ReadContext instead of polling - much more CPU efficient
case <-pm.nsCtx.Done(): // This will block until a packet is available or context is cancelled
pkt := pm.ep.ReadContext(pm.nsCtx)
if pkt == nil {
// Context was cancelled or endpoint closed
logger.Debug("PeerMonitor: Packet sender context cancelled, draining packets") logger.Debug("PeerMonitor: Packet sender context cancelled, draining packets")
// Drain any remaining packets before exiting // Drain any remaining packets before exiting
for { for {
@@ -815,36 +899,28 @@ func (pm *PeerMonitor) runPacketSender() {
} }
logger.Debug("PeerMonitor: Packet sender goroutine exiting") logger.Debug("PeerMonitor: Packet sender goroutine exiting")
return return
case <-ticker.C:
// Try to read packets in batches
for i := 0; i < 10; i++ {
pkt := pm.ep.Read()
if pkt == nil {
break
}
// Extract packet data
slices := pkt.AsSlices()
if len(slices) > 0 {
var totalSize int
for _, slice := range slices {
totalSize += len(slice)
}
buf := make([]byte, totalSize)
pos := 0
for _, slice := range slices {
copy(buf[pos:], slice)
pos += len(slice)
}
// Inject into MiddleDevice (outbound to WG)
pm.middleDev.InjectOutbound(buf)
}
pkt.DecRef()
}
} }
// Extract packet data
slices := pkt.AsSlices()
if len(slices) > 0 {
var totalSize int
for _, slice := range slices {
totalSize += len(slice)
}
buf := make([]byte, totalSize)
pos := 0
for _, slice := range slices {
copy(buf[pos:], slice)
pos += len(slice)
}
// Inject into MiddleDevice (outbound to WG)
pm.middleDev.InjectOutbound(buf)
}
pkt.DecRef()
} }
} }

View File

@@ -32,10 +32,19 @@ type Client struct {
monitorLock sync.Mutex monitorLock sync.Mutex
connLock sync.Mutex // Protects connection operations connLock sync.Mutex // Protects connection operations
shutdownCh chan struct{} shutdownCh chan struct{}
updateCh chan struct{}
packetInterval time.Duration packetInterval time.Duration
timeout time.Duration timeout time.Duration
maxAttempts int maxAttempts int
dialer Dialer dialer Dialer
// Exponential backoff fields
defaultMinInterval time.Duration // Default minimum interval (initial)
defaultMaxInterval time.Duration // Default maximum interval (cap for backoff)
minInterval time.Duration // Minimum interval (initial)
maxInterval time.Duration // Maximum interval (cap for backoff)
backoffMultiplier float64 // Multiplier for each stable check
stableCountToBackoff int // Number of stable checks before backing off
} }
// Dialer is a function that creates a connection // Dialer is a function that creates a connection
@@ -50,28 +59,59 @@ type ConnectionStatus struct {
// NewClient creates a new connection test client // NewClient creates a new connection test client
func NewClient(serverAddr string, dialer Dialer) (*Client, error) { func NewClient(serverAddr string, dialer Dialer) (*Client, error) {
return &Client{ return &Client{
serverAddr: serverAddr, serverAddr: serverAddr,
shutdownCh: make(chan struct{}), shutdownCh: make(chan struct{}),
packetInterval: 2 * time.Second, updateCh: make(chan struct{}, 1),
timeout: 500 * time.Millisecond, // Timeout for individual packets packetInterval: 2 * time.Second,
maxAttempts: 3, // Default max attempts defaultMinInterval: 2 * time.Second,
dialer: dialer, defaultMaxInterval: 30 * time.Second,
minInterval: 2 * time.Second,
maxInterval: 30 * time.Second,
backoffMultiplier: 1.5,
stableCountToBackoff: 3, // After 3 consecutive same-state results, start backing off
timeout: 500 * time.Millisecond, // Timeout for individual packets
maxAttempts: 3, // Default max attempts
dialer: dialer,
}, nil }, nil
} }
// SetPacketInterval changes how frequently packets are sent in monitor mode // SetPacketInterval changes how frequently packets are sent in monitor mode
func (c *Client) SetPacketInterval(interval time.Duration) { func (c *Client) SetPacketInterval(minInterval, maxInterval time.Duration) {
c.packetInterval = interval c.monitorLock.Lock()
c.packetInterval = minInterval
c.minInterval = minInterval
c.maxInterval = maxInterval
updateCh := c.updateCh
monitorRunning := c.monitorRunning
c.monitorLock.Unlock()
// Signal the goroutine to apply the new interval if running
if monitorRunning && updateCh != nil {
select {
case updateCh <- struct{}{}:
default:
// Channel full or closed, skip
}
}
} }
// SetTimeout changes the timeout for waiting for responses func (c *Client) ResetPacketInterval() {
func (c *Client) SetTimeout(timeout time.Duration) { c.monitorLock.Lock()
c.timeout = timeout c.packetInterval = c.defaultMinInterval
} c.minInterval = c.defaultMinInterval
c.maxInterval = c.defaultMaxInterval
updateCh := c.updateCh
monitorRunning := c.monitorRunning
c.monitorLock.Unlock()
// SetMaxAttempts changes the maximum number of attempts for TestConnection // Signal the goroutine to apply the new interval if running
func (c *Client) SetMaxAttempts(attempts int) { if monitorRunning && updateCh != nil {
c.maxAttempts = attempts select {
case updateCh <- struct{}{}:
default:
// Channel full or closed, skip
}
}
} }
// UpdateServerAddr updates the server address and resets the connection // UpdateServerAddr updates the server address and resets the connection
@@ -125,9 +165,10 @@ func (c *Client) ensureConnection() error {
return nil return nil
} }
// TestConnection checks if the connection to the server is working // TestPeerConnection checks if the connection to the server is working
// Returns true if connected, false otherwise // Returns true if connected, false otherwise
func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) { func (c *Client) TestPeerConnection(ctx context.Context) (bool, time.Duration) {
// logger.Debug("wgtester: testing connection to peer %s", c.serverAddr)
if err := c.ensureConnection(); err != nil { if err := c.ensureConnection(); err != nil {
logger.Warn("Failed to ensure connection: %v", err) logger.Warn("Failed to ensure connection: %v", err)
return false, 0 return false, 0
@@ -138,6 +179,9 @@ func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) {
binary.BigEndian.PutUint32(packet[0:4], magicHeader) binary.BigEndian.PutUint32(packet[0:4], magicHeader)
packet[4] = packetTypeRequest packet[4] = packetTypeRequest
// Reusable response buffer
responseBuffer := make([]byte, packetSize)
// Send multiple attempts as specified // Send multiple attempts as specified
for attempt := 0; attempt < c.maxAttempts; attempt++ { for attempt := 0; attempt < c.maxAttempts; attempt++ {
select { select {
@@ -157,20 +201,17 @@ func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) {
return false, 0 return false, 0
} }
// logger.Debug("Attempting to send monitor packet to %s", c.serverAddr)
_, err := c.conn.Write(packet) _, err := c.conn.Write(packet)
if err != nil { if err != nil {
c.connLock.Unlock() c.connLock.Unlock()
logger.Info("Error sending packet: %v", err) logger.Info("Error sending packet: %v", err)
continue continue
} }
// logger.Debug("Successfully sent monitor packet")
// Set read deadline // Set read deadline
c.conn.SetReadDeadline(time.Now().Add(c.timeout)) c.conn.SetReadDeadline(time.Now().Add(c.timeout))
// Wait for response // Wait for response
responseBuffer := make([]byte, packetSize)
n, err := c.conn.Read(responseBuffer) n, err := c.conn.Read(responseBuffer)
c.connLock.Unlock() c.connLock.Unlock()
@@ -211,7 +252,7 @@ func (c *Client) TestConnection(ctx context.Context) (bool, time.Duration) {
func (c *Client) TestConnectionWithTimeout(timeout time.Duration) (bool, time.Duration) { func (c *Client) TestConnectionWithTimeout(timeout time.Duration) (bool, time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
return c.TestConnection(ctx) return c.TestPeerConnection(ctx)
} }
// MonitorCallback is the function type for connection status change callbacks // MonitorCallback is the function type for connection status change callbacks
@@ -238,28 +279,61 @@ func (c *Client) StartMonitor(callback MonitorCallback) error {
go func() { go func() {
var lastConnected bool var lastConnected bool
firstRun := true firstRun := true
stableCount := 0
currentInterval := c.minInterval
ticker := time.NewTicker(c.packetInterval) timer := time.NewTimer(currentInterval)
defer ticker.Stop() defer timer.Stop()
for { for {
select { select {
case <-c.shutdownCh: case <-c.shutdownCh:
return return
case <-ticker.C: case <-c.updateCh:
// Interval settings changed, reset to minimum
c.monitorLock.Lock()
currentInterval = c.minInterval
c.monitorLock.Unlock()
// Reset backoff state
stableCount = 0
timer.Reset(currentInterval)
logger.Debug("Packet interval updated, reset to %v", currentInterval)
case <-timer.C:
ctx, cancel := context.WithTimeout(context.Background(), c.timeout) ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
connected, rtt := c.TestConnection(ctx) connected, rtt := c.TestPeerConnection(ctx)
cancel() cancel()
statusChanged := connected != lastConnected
// Callback if status changed or it's the first check // Callback if status changed or it's the first check
if connected != lastConnected || firstRun { if statusChanged || firstRun {
callback(ConnectionStatus{ callback(ConnectionStatus{
Connected: connected, Connected: connected,
RTT: rtt, RTT: rtt,
}) })
lastConnected = connected lastConnected = connected
firstRun = false firstRun = false
// Reset backoff on status change
stableCount = 0
currentInterval = c.minInterval
} else {
// Status is stable, increment counter
stableCount++
// Apply exponential backoff after stable threshold
if stableCount >= c.stableCountToBackoff {
newInterval := time.Duration(float64(currentInterval) * c.backoffMultiplier)
if newInterval > c.maxInterval {
newInterval = c.maxInterval
}
currentInterval = newInterval
}
} }
// Reset timer with current interval
timer.Reset(currentInterval)
} }
} }
}() }()

View File

@@ -11,7 +11,7 @@ import (
) )
// ConfigurePeer sets up or updates a peer within the WireGuard device // ConfigurePeer sets up or updates a peer within the WireGuard device
func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool) error { func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes.Key, relay bool, persistentKeepalive int) error {
var endpoint string var endpoint string
if relay && siteConfig.RelayEndpoint != "" { if relay && siteConfig.RelayEndpoint != "" {
endpoint = formatEndpoint(siteConfig.RelayEndpoint) endpoint = formatEndpoint(siteConfig.RelayEndpoint)
@@ -61,7 +61,7 @@ func ConfigurePeer(dev *device.Device, siteConfig SiteConfig, privateKey wgtypes
} }
configBuilder.WriteString(fmt.Sprintf("endpoint=%s\n", siteHost)) configBuilder.WriteString(fmt.Sprintf("endpoint=%s\n", siteHost))
configBuilder.WriteString("persistent_keepalive_interval=5\n") configBuilder.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", persistentKeepalive))
config := configBuilder.String() config := configBuilder.String()
logger.Debug("Configuring peer with config: %s", config) logger.Debug("Configuring peer with config: %s", config)
@@ -134,6 +134,24 @@ func RemoveAllowedIP(dev *device.Device, publicKey string, remainingAllowedIPs [
return nil return nil
} }
// UpdatePersistentKeepalive updates the persistent keepalive interval for a peer without recreating it
func UpdatePersistentKeepalive(dev *device.Device, publicKey string, interval int) error {
var configBuilder strings.Builder
configBuilder.WriteString(fmt.Sprintf("public_key=%s\n", util.FixKey(publicKey)))
configBuilder.WriteString("update_only=true\n")
configBuilder.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", interval))
config := configBuilder.String()
logger.Debug("Updating persistent keepalive for peer with config: %s", config)
err := dev.IpcSet(config)
if err != nil {
return fmt.Errorf("failed to update persistent keepalive for WireGuard peer: %v", err)
}
return nil
}
func formatEndpoint(endpoint string) string { func formatEndpoint(endpoint string) string {
if strings.Contains(endpoint, ":") { if strings.Contains(endpoint, ":") {
return endpoint return endpoint

View File

@@ -9,6 +9,7 @@ type PeerAction struct {
// UpdatePeerData represents the data needed to update a peer // UpdatePeerData represents the data needed to update a peer
type SiteConfig struct { type SiteConfig struct {
SiteId int `json:"siteId"` SiteId int `json:"siteId"`
Name string `json:"name,omitempty"`
Endpoint string `json:"endpoint,omitempty"` Endpoint string `json:"endpoint,omitempty"`
RelayEndpoint string `json:"relayEndpoint,omitempty"` RelayEndpoint string `json:"relayEndpoint,omitempty"`
PublicKey string `json:"publicKey,omitempty"` PublicKey string `json:"publicKey,omitempty"`
@@ -32,6 +33,7 @@ type PeerRemove struct {
type RelayPeerData struct { type RelayPeerData struct {
SiteId int `json:"siteId"` SiteId int `json:"siteId"`
RelayEndpoint string `json:"relayEndpoint"` RelayEndpoint string `json:"relayEndpoint"`
RelayPort uint16 `json:"relayPort"`
} }
type UnRelayPeerData struct { type UnRelayPeerData struct {

View File

@@ -99,15 +99,32 @@ func (s *olmService) Execute(args []string, r <-chan svc.ChangeRequest, changes
// Continue with empty args if loading fails // Continue with empty args if loading fails
savedArgs = []string{} savedArgs = []string{}
} }
s.elog.Info(1, fmt.Sprintf("Loaded saved service args: %v", savedArgs))
// Combine service start args with saved args, giving priority to service start args // Combine service start args with saved args, giving priority to service start args
// Note: When the service is started via SCM, args[0] is the service name
// When started via s.Start(args...), the args passed are exactly what we provide
finalArgs := []string{} finalArgs := []string{}
// Check if we have args passed directly to Execute (from s.Start())
if len(args) > 0 { if len(args) > 0 {
// Skip the first arg which is typically the service name // The first arg from SCM is the service name, but when we call s.Start(args...),
if len(args) > 1 { // the args we pass become args[1:] in Execute. However, if started by SCM without
// args, args[0] will be the service name.
// We need to check if args[0] looks like the service name or a flag
if len(args) == 1 && args[0] == serviceName {
// Only service name, no actual args
s.elog.Info(1, "Only service name in args, checking saved args")
} else if len(args) > 1 && args[0] == serviceName {
// Service name followed by actual args
finalArgs = append(finalArgs, args[1:]...) finalArgs = append(finalArgs, args[1:]...)
s.elog.Info(1, fmt.Sprintf("Using service start parameters (after service name): %v", finalArgs))
} else {
// Args don't start with service name, use them all
// This happens when args are passed via s.Start(args...)
finalArgs = append(finalArgs, args...)
s.elog.Info(1, fmt.Sprintf("Using service start parameters (direct): %v", finalArgs))
} }
s.elog.Info(1, fmt.Sprintf("Using service start parameters: %v", finalArgs))
} }
// If no service start parameters, use saved args // If no service start parameters, use saved args
@@ -116,6 +133,7 @@ func (s *olmService) Execute(args []string, r <-chan svc.ChangeRequest, changes
s.elog.Info(1, fmt.Sprintf("Using saved service args: %v", finalArgs)) s.elog.Info(1, fmt.Sprintf("Using saved service args: %v", finalArgs))
} }
s.elog.Info(1, fmt.Sprintf("Final args to use: %v", finalArgs))
s.args = finalArgs s.args = finalArgs
// Start the main olm functionality // Start the main olm functionality
@@ -325,12 +343,15 @@ func removeService() error {
} }
func startService(args []string) error { func startService(args []string) error {
// Save the service arguments as backup fmt.Printf("Starting service with args: %v\n", args)
if len(args) > 0 {
err := saveServiceArgs(args) // Always save the service arguments so they can be loaded on service restart
if err != nil { err := saveServiceArgs(args)
return fmt.Errorf("failed to save service args: %v", err) if err != nil {
} fmt.Printf("Warning: failed to save service args: %v\n", err)
// Continue anyway, args will still be passed directly
} else {
fmt.Printf("Saved service args to: %s\n", getServiceArgsPath())
} }
m, err := mgr.Connect() m, err := mgr.Connect()
@@ -346,6 +367,7 @@ func startService(args []string) error {
defer s.Close() defer s.Close()
// Pass arguments directly to the service start call // Pass arguments directly to the service start call
// Note: These args will appear in Execute() after the service name
err = s.Start(args...) err = s.Start(args...)
if err != nil { if err != nil {
return fmt.Errorf("failed to start service: %v", err) return fmt.Errorf("failed to start service: %v", err)

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -38,8 +39,9 @@ func IsAuthError(err error) bool {
type TokenResponse struct { type TokenResponse struct {
Data struct { Data struct {
Token string `json:"token"` Token string `json:"token"`
ExitNodes []ExitNode `json:"exitNodes"` ExitNodes []ExitNode `json:"exitNodes"`
ServerVersion string `json:"serverVersion"`
} `json:"data"` } `json:"data"`
Success bool `json:"success"` Success bool `json:"success"`
Message string `json:"message"` Message string `json:"message"`
@@ -47,12 +49,15 @@ type TokenResponse struct {
type ExitNode struct { type ExitNode struct {
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
RelayPort uint16 `json:"relayPort"`
PublicKey string `json:"publicKey"` PublicKey string `json:"publicKey"`
SiteIds []int `json:"siteIds"`
} }
type WSMessage struct { type WSMessage struct {
Type string `json:"type"` Type string `json:"type"`
Data interface{} `json:"data"` Data interface{} `json:"data"`
ConfigVersion int `json:"configVersion,omitempty"`
} }
// this is not json anymore // this is not json anymore
@@ -74,6 +79,7 @@ type Client struct {
handlersMux sync.RWMutex handlersMux sync.RWMutex
reconnectInterval time.Duration reconnectInterval time.Duration
isConnected bool isConnected bool
isDisconnected bool // Flag to track if client is intentionally disconnected
reconnectMux sync.RWMutex reconnectMux sync.RWMutex
pingInterval time.Duration pingInterval time.Duration
pingTimeout time.Duration pingTimeout time.Duration
@@ -84,6 +90,19 @@ type Client struct {
clientType string // Type of client (e.g., "newt", "olm") clientType string // Type of client (e.g., "newt", "olm")
tlsConfig TLSConfig tlsConfig TLSConfig
configNeedsSave bool // Flag to track if config needs to be saved configNeedsSave bool // Flag to track if config needs to be saved
configVersion int // Latest config version received from server
configVersionMux sync.RWMutex
token string // Cached authentication token
exitNodes []ExitNode // Cached exit nodes from token response
tokenMux sync.RWMutex // Protects token and exitNodes
forceNewToken bool // Flag to force fetching a new token on next connection
processingMessage bool // Flag to track if a message is currently being processed
processingMux sync.RWMutex // Protects processingMessage
processingWg sync.WaitGroup // WaitGroup to wait for message processing to complete
getPingData func() map[string]any // Callback to get additional ping data
pingStarted bool // Flag to track if ping monitor has been started
pingStartedMux sync.Mutex // Protects pingStarted
pingDone chan struct{} // Channel to stop the ping monitor independently
} }
type ClientOption func(*Client) type ClientOption func(*Client)
@@ -119,6 +138,13 @@ func WithTLSConfig(config TLSConfig) ClientOption {
} }
} }
// WithPingDataProvider sets a callback to provide additional data for ping messages
func WithPingDataProvider(fn func() map[string]any) ClientOption {
return func(c *Client) {
c.getPingData = fn
}
}
func (c *Client) OnConnect(callback func() error) { func (c *Client) OnConnect(callback func() error) {
c.onConnect = callback c.onConnect = callback
} }
@@ -151,6 +177,7 @@ func NewClient(ID, secret, userToken, orgId, endpoint string, pingInterval time.
pingInterval: pingInterval, pingInterval: pingInterval,
pingTimeout: pingTimeout, pingTimeout: pingTimeout,
clientType: "olm", clientType: "olm",
pingDone: make(chan struct{}),
} }
// Apply options before loading config // Apply options before loading config
@@ -170,6 +197,9 @@ func (c *Client) GetConfig() *Config {
// Connect establishes the WebSocket connection // Connect establishes the WebSocket connection
func (c *Client) Connect() error { func (c *Client) Connect() error {
if c.isDisconnected {
c.isDisconnected = false
}
go c.connectWithRetry() go c.connectWithRetry()
return nil return nil
} }
@@ -202,9 +232,31 @@ func (c *Client) Close() error {
return nil return nil
} }
// Disconnect cleanly closes the websocket connection and suspends message intervals, but allows reconnecting later.
func (c *Client) Disconnect() error {
c.isDisconnected = true
c.setConnected(false)
// Stop the ping monitor
c.stopPingMonitor()
// Wait for any message currently being processed to complete
c.processingWg.Wait()
if c.conn != nil {
c.writeMux.Lock()
c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
c.writeMux.Unlock()
err := c.conn.Close()
c.conn = nil
return err
}
return nil
}
// SendMessage sends a message through the WebSocket connection // SendMessage sends a message through the WebSocket connection
func (c *Client) SendMessage(messageType string, data interface{}) error { func (c *Client) SendMessage(messageType string, data interface{}) error {
if c.conn == nil { if c.isDisconnected || c.conn == nil {
return fmt.Errorf("not connected") return fmt.Errorf("not connected")
} }
@@ -213,14 +265,14 @@ func (c *Client) SendMessage(messageType string, data interface{}) error {
Data: data, Data: data,
} }
logger.Debug("Sending message: %s, data: %+v", messageType, data) logger.Debug("websocket: Sending message: %s, data: %+v", messageType, data)
c.writeMux.Lock() c.writeMux.Lock()
defer c.writeMux.Unlock() defer c.writeMux.Unlock()
return c.conn.WriteJSON(msg) return c.conn.WriteJSON(msg)
} }
func (c *Client) SendMessageInterval(messageType string, data interface{}, interval time.Duration) (stop func(), update func(newData interface{})) { func (c *Client) SendMessageInterval(messageType string, data interface{}, interval time.Duration, maxAttempts int) (stop func(), update func(newData interface{})) {
stopChan := make(chan struct{}) stopChan := make(chan struct{})
updateChan := make(chan interface{}) updateChan := make(chan interface{})
var dataMux sync.Mutex var dataMux sync.Mutex
@@ -228,30 +280,32 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
go func() { go func() {
count := 0 count := 0
maxAttempts := 10
err := c.SendMessage(messageType, currentData) // Send immediately send := func() {
if err != nil { if c.isDisconnected || c.conn == nil {
logger.Error("Failed to send initial message: %v", err) return
}
err := c.SendMessage(messageType, currentData)
if err != nil {
logger.Error("websocket: Failed to send message: %v", err)
}
count++
} }
count++
send() // Send immediately
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if count >= maxAttempts { if maxAttempts != -1 && count >= maxAttempts {
logger.Info("SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType) logger.Info("websocket: SendMessageInterval timed out after %d attempts for message type: %s", maxAttempts, messageType)
return return
} }
dataMux.Lock() dataMux.Lock()
err = c.SendMessage(messageType, currentData) send()
dataMux.Unlock() dataMux.Unlock()
if err != nil {
logger.Error("Failed to send message: %v", err)
}
count++
case newData := <-updateChan: case newData := <-updateChan:
dataMux.Lock() dataMux.Lock()
// Merge newData into currentData if both are maps // Merge newData into currentData if both are maps
@@ -274,6 +328,14 @@ func (c *Client) SendMessageInterval(messageType string, data interface{}, inter
case <-stopChan: case <-stopChan:
return return
} }
// Suspend sending if disconnected
for c.isDisconnected {
select {
case <-stopChan:
return
case <-time.After(500 * time.Millisecond):
}
}
} }
}() }()
return func() { return func() {
@@ -320,7 +382,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
tlsConfig = &tls.Config{} tlsConfig = &tls.Config{}
} }
tlsConfig.InsecureSkipVerify = true tlsConfig.InsecureSkipVerify = true
logger.Debug("TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable") logger.Debug("websocket: TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
} }
tokenData := map[string]interface{}{ tokenData := map[string]interface{}{
@@ -348,6 +410,9 @@ func (c *Client) getToken() (string, []ExitNode, error) {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CSRF-Token", "x-csrf-protection") req.Header.Set("X-CSRF-Token", "x-csrf-protection")
// print out the request for debugging
logger.Debug("websocket: Requesting token from %s with body: %s", req.URL.String(), string(jsonData))
// Make the request // Make the request
client := &http.Client{} client := &http.Client{}
if tlsConfig != nil { if tlsConfig != nil {
@@ -363,7 +428,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
logger.Error("Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body)) logger.Error("websocket: Failed to get token with status code: %d, body: %s", resp.StatusCode, string(body))
// Return AuthError for 401/403 status codes // Return AuthError for 401/403 status codes
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
@@ -379,7 +444,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
var tokenResp TokenResponse var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
logger.Error("Failed to decode token response.") logger.Error("websocket: Failed to decode token response.")
return "", nil, fmt.Errorf("failed to decode token response: %w", err) return "", nil, fmt.Errorf("failed to decode token response: %w", err)
} }
@@ -391,7 +456,7 @@ func (c *Client) getToken() (string, []ExitNode, error) {
return "", nil, fmt.Errorf("received empty token from server") return "", nil, fmt.Errorf("received empty token from server")
} }
logger.Debug("Received token: %s", tokenResp.Data.Token) logger.Debug("websocket: Received token: %s", tokenResp.Data.Token)
return tokenResp.Data.Token, tokenResp.Data.ExitNodes, nil return tokenResp.Data.Token, tokenResp.Data.ExitNodes, nil
} }
@@ -405,7 +470,8 @@ func (c *Client) connectWithRetry() {
err := c.establishConnection() err := c.establishConnection()
if err != nil { if err != nil {
// Check if this is an auth error (401/403) // Check if this is an auth error (401/403)
if authErr, ok := err.(*AuthError); ok { var authErr *AuthError
if errors.As(err, &authErr) {
logger.Error("Authentication failed: %v. Terminating tunnel and retrying...", authErr) logger.Error("Authentication failed: %v. Terminating tunnel and retrying...", authErr)
// Trigger auth error callback if set (this should terminate the tunnel) // Trigger auth error callback if set (this should terminate the tunnel)
if c.onAuthError != nil { if c.onAuthError != nil {
@@ -416,7 +482,7 @@ func (c *Client) connectWithRetry() {
continue continue
} }
// For other errors (5xx, network issues), continue retrying // For other errors (5xx, network issues), continue retrying
logger.Error("Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval) logger.Error("websocket: Failed to connect: %v. Retrying in %v...", err, c.reconnectInterval)
time.Sleep(c.reconnectInterval) time.Sleep(c.reconnectInterval)
continue continue
} }
@@ -426,15 +492,25 @@ func (c *Client) connectWithRetry() {
} }
func (c *Client) establishConnection() error { func (c *Client) establishConnection() error {
// Get token for authentication // Get token for authentication - reuse cached token unless forced to get new one
token, exitNodes, err := c.getToken() c.tokenMux.Lock()
if err != nil { needNewToken := c.token == "" || c.forceNewToken
return fmt.Errorf("failed to get token: %w", err) if needNewToken {
} token, exitNodes, err := c.getToken()
if err != nil {
c.tokenMux.Unlock()
return fmt.Errorf("failed to get token: %w", err)
}
c.token = token
c.exitNodes = exitNodes
c.forceNewToken = false
if c.onTokenUpdate != nil { if c.onTokenUpdate != nil {
c.onTokenUpdate(token, exitNodes) c.onTokenUpdate(token, exitNodes)
}
} }
token := c.token
c.tokenMux.Unlock()
// Parse the base URL to determine protocol and hostname // Parse the base URL to determine protocol and hostname
baseURL, err := url.Parse(c.baseURL) baseURL, err := url.Parse(c.baseURL)
@@ -469,7 +545,7 @@ func (c *Client) establishConnection() error {
// Use new TLS configuration method // Use new TLS configuration method
if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" { if c.tlsConfig.ClientCertFile != "" || c.tlsConfig.ClientKeyFile != "" || len(c.tlsConfig.CAFiles) > 0 || c.tlsConfig.PKCS12File != "" {
logger.Info("Setting up TLS configuration for WebSocket connection") logger.Info("websocket: Setting up TLS configuration for WebSocket connection")
tlsConfig, err := c.setupTLS() tlsConfig, err := c.setupTLS()
if err != nil { if err != nil {
return fmt.Errorf("failed to setup TLS configuration: %w", err) return fmt.Errorf("failed to setup TLS configuration: %w", err)
@@ -483,25 +559,38 @@ func (c *Client) establishConnection() error {
dialer.TLSClientConfig = &tls.Config{} dialer.TLSClientConfig = &tls.Config{}
} }
dialer.TLSClientConfig.InsecureSkipVerify = true dialer.TLSClientConfig.InsecureSkipVerify = true
logger.Debug("WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable") logger.Debug("websocket: WebSocket TLS certificate verification disabled via SKIP_TLS_VERIFY environment variable")
} }
conn, _, err := dialer.Dial(u.String(), nil) conn, resp, err := dialer.Dial(u.String(), nil)
if err != nil { if err != nil {
// Check if this is an unauthorized error (401)
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
logger.Error("websocket: WebSocket connection rejected with 401 Unauthorized")
// Force getting a new token on next reconnect attempt
c.tokenMux.Lock()
c.forceNewToken = true
c.tokenMux.Unlock()
return &AuthError{
StatusCode: http.StatusUnauthorized,
Message: "WebSocket connection unauthorized",
}
}
return fmt.Errorf("failed to connect to WebSocket: %w", err) return fmt.Errorf("failed to connect to WebSocket: %w", err)
} }
c.conn = conn c.conn = conn
c.setConnected(true) c.setConnected(true)
// Start the ping monitor // Note: ping monitor is NOT started here - it will be started when
go c.pingMonitor() // StartPingMonitor() is called after registration completes
// Start the read pump with disconnect detection // Start the read pump with disconnect detection
go c.readPumpWithDisconnectDetection() go c.readPumpWithDisconnectDetection()
if c.onConnect != nil { if c.onConnect != nil {
if err := c.onConnect(); err != nil { if err := c.onConnect(); err != nil {
logger.Error("OnConnect callback failed: %v", err) logger.Error("websocket: OnConnect callback failed: %v", err)
} }
} }
@@ -514,9 +603,9 @@ func (c *Client) setupTLS() (*tls.Config, error) {
// Handle new separate certificate configuration // Handle new separate certificate configuration
if c.tlsConfig.ClientCertFile != "" && c.tlsConfig.ClientKeyFile != "" { if c.tlsConfig.ClientCertFile != "" && c.tlsConfig.ClientKeyFile != "" {
logger.Info("Loading separate certificate files for mTLS") logger.Info("websocket: Loading separate certificate files for mTLS")
logger.Debug("Client cert: %s", c.tlsConfig.ClientCertFile) logger.Debug("websocket: Client cert: %s", c.tlsConfig.ClientCertFile)
logger.Debug("Client key: %s", c.tlsConfig.ClientKeyFile) logger.Debug("websocket: Client key: %s", c.tlsConfig.ClientKeyFile)
// Load client certificate and key // Load client certificate and key
cert, err := tls.LoadX509KeyPair(c.tlsConfig.ClientCertFile, c.tlsConfig.ClientKeyFile) cert, err := tls.LoadX509KeyPair(c.tlsConfig.ClientCertFile, c.tlsConfig.ClientKeyFile)
@@ -527,7 +616,7 @@ func (c *Client) setupTLS() (*tls.Config, error) {
// Load CA certificates for remote validation if specified // Load CA certificates for remote validation if specified
if len(c.tlsConfig.CAFiles) > 0 { if len(c.tlsConfig.CAFiles) > 0 {
logger.Debug("Loading CA certificates: %v", c.tlsConfig.CAFiles) logger.Debug("websocket: Loading CA certificates: %v", c.tlsConfig.CAFiles)
caCertPool := x509.NewCertPool() caCertPool := x509.NewCertPool()
for _, caFile := range c.tlsConfig.CAFiles { for _, caFile := range c.tlsConfig.CAFiles {
caCert, err := os.ReadFile(caFile) caCert, err := os.ReadFile(caFile)
@@ -553,13 +642,13 @@ func (c *Client) setupTLS() (*tls.Config, error) {
// Fallback to existing PKCS12 implementation for backward compatibility // Fallback to existing PKCS12 implementation for backward compatibility
if c.tlsConfig.PKCS12File != "" { if c.tlsConfig.PKCS12File != "" {
logger.Info("Loading PKCS12 certificate for mTLS (deprecated)") logger.Info("websocket: Loading PKCS12 certificate for mTLS (deprecated)")
return c.setupPKCS12TLS() return c.setupPKCS12TLS()
} }
// Legacy fallback using config.TlsClientCert // Legacy fallback using config.TlsClientCert
if c.config.TlsClientCert != "" { if c.config.TlsClientCert != "" {
logger.Info("Loading legacy PKCS12 certificate for mTLS (deprecated)") logger.Info("websocket: Loading legacy PKCS12 certificate for mTLS (deprecated)")
return loadClientCertificate(c.config.TlsClientCert) return loadClientCertificate(c.config.TlsClientCert)
} }
@@ -571,6 +660,59 @@ func (c *Client) setupPKCS12TLS() (*tls.Config, error) {
return loadClientCertificate(c.tlsConfig.PKCS12File) return loadClientCertificate(c.tlsConfig.PKCS12File)
} }
// sendPing sends a single ping message
func (c *Client) sendPing() {
if c.isDisconnected || c.conn == nil {
return
}
// Skip ping if a message is currently being processed
c.processingMux.RLock()
isProcessing := c.processingMessage
c.processingMux.RUnlock()
if isProcessing {
logger.Debug("websocket: Skipping ping, message is being processed")
return
}
// Send application-level ping with config version
c.configVersionMux.RLock()
configVersion := c.configVersion
c.configVersionMux.RUnlock()
pingData := map[string]any{
"timestamp": time.Now().Unix(),
"userToken": c.config.UserToken,
}
if c.getPingData != nil {
for k, v := range c.getPingData() {
pingData[k] = v
}
}
pingMsg := WSMessage{
Type: "olm/ping",
Data: pingData,
ConfigVersion: configVersion,
}
logger.Debug("websocket: Sending ping: %+v", pingMsg)
c.writeMux.Lock()
err := c.conn.WriteJSON(pingMsg)
c.writeMux.Unlock()
if err != nil {
// Check if we're shutting down before logging error and reconnecting
select {
case <-c.done:
// Expected during shutdown
return
default:
logger.Error("websocket: Ping failed: %v", err)
c.reconnect()
return
}
}
}
// pingMonitor sends pings at a short interval and triggers reconnect on failure // pingMonitor sends pings at a short interval and triggers reconnect on failure
func (c *Client) pingMonitor() { func (c *Client) pingMonitor() {
ticker := time.NewTicker(c.pingInterval) ticker := time.NewTicker(c.pingInterval)
@@ -580,29 +722,65 @@ func (c *Client) pingMonitor() {
select { select {
case <-c.done: case <-c.done:
return return
case <-c.pingDone:
return
case <-ticker.C: case <-ticker.C:
if c.conn == nil { c.sendPing()
return
}
c.writeMux.Lock()
err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(c.pingTimeout))
c.writeMux.Unlock()
if err != nil {
// Check if we're shutting down before logging error and reconnecting
select {
case <-c.done:
// Expected during shutdown
return
default:
logger.Error("Ping failed: %v", err)
c.reconnect()
return
}
}
} }
} }
} }
// StartPingMonitor starts the ping monitor goroutine.
// This should be called after the client is registered and connected.
// It is safe to call multiple times - only the first call will start the monitor.
func (c *Client) StartPingMonitor() {
c.pingStartedMux.Lock()
defer c.pingStartedMux.Unlock()
if c.pingStarted {
return
}
c.pingStarted = true
// Create a new pingDone channel for this ping monitor instance
c.pingDone = make(chan struct{})
// Send an initial ping immediately
go func() {
c.sendPing()
c.pingMonitor()
}()
}
// stopPingMonitor stops the ping monitor goroutine if it's running.
func (c *Client) stopPingMonitor() {
c.pingStartedMux.Lock()
defer c.pingStartedMux.Unlock()
if !c.pingStarted {
return
}
// Close the pingDone channel to stop the monitor
close(c.pingDone)
c.pingStarted = false
}
// GetConfigVersion returns the current config version
func (c *Client) GetConfigVersion() int {
c.configVersionMux.RLock()
defer c.configVersionMux.RUnlock()
return c.configVersion
}
// setConfigVersion updates the config version if the new version is higher
func (c *Client) setConfigVersion(version int) {
c.configVersionMux.Lock()
defer c.configVersionMux.Unlock()
logger.Debug("websocket: setting config version to %d", version)
c.configVersion = version
}
// readPumpWithDisconnectDetection reads messages and triggers reconnect on error // readPumpWithDisconnectDetection reads messages and triggers reconnect on error
func (c *Client) readPumpWithDisconnectDetection() { func (c *Client) readPumpWithDisconnectDetection() {
defer func() { defer func() {
@@ -627,26 +805,47 @@ func (c *Client) readPumpWithDisconnectDetection() {
var msg WSMessage var msg WSMessage
err := c.conn.ReadJSON(&msg) err := c.conn.ReadJSON(&msg)
if err != nil { if err != nil {
// Check if we're shutting down before logging error // Check if we're shutting down or explicitly disconnected before logging error
select { select {
case <-c.done: case <-c.done:
// Expected during shutdown, don't log as error // Expected during shutdown, don't log as error
logger.Debug("WebSocket connection closed during shutdown") logger.Debug("websocket: connection closed during shutdown")
return return
default: default:
// Check if explicitly disconnected
if c.isDisconnected {
logger.Debug("websocket: connection closed: client was explicitly disconnected")
return
}
// Unexpected error during normal operation // Unexpected error during normal operation
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNormalClosure) { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNormalClosure) {
logger.Error("WebSocket read error: %v", err) logger.Error("websocket: read error: %v", err)
} else { } else {
logger.Debug("WebSocket connection closed: %v", err) logger.Debug("websocket: connection closed: %v", err)
} }
return // triggers reconnect via defer return // triggers reconnect via defer
} }
} }
// Update config version from incoming message
c.setConfigVersion(msg.ConfigVersion)
c.handlersMux.RLock() c.handlersMux.RLock()
if handler, ok := c.handlers[msg.Type]; ok { if handler, ok := c.handlers[msg.Type]; ok {
// Mark that we're processing a message
c.processingMux.Lock()
c.processingMessage = true
c.processingMux.Unlock()
c.processingWg.Add(1)
handler(msg) handler(msg)
// Mark that we're done processing
c.processingWg.Done()
c.processingMux.Lock()
c.processingMessage = false
c.processingMux.Unlock()
} }
c.handlersMux.RUnlock() c.handlersMux.RUnlock()
} }
@@ -660,6 +859,12 @@ func (c *Client) reconnect() {
c.conn = nil c.conn = nil
} }
// Don't reconnect if explicitly disconnected
if c.isDisconnected {
logger.Debug("websocket: websocket: Not reconnecting: client was explicitly disconnected")
return
}
// Only reconnect if we're not shutting down // Only reconnect if we're not shutting down
select { select {
case <-c.done: case <-c.done:
@@ -677,7 +882,7 @@ func (c *Client) setConnected(status bool) {
// LoadClientCertificate Helper method to load client certificates (PKCS12 format) // LoadClientCertificate Helper method to load client certificates (PKCS12 format)
func loadClientCertificate(p12Path string) (*tls.Config, error) { func loadClientCertificate(p12Path string) (*tls.Config, error) {
logger.Info("Loading tls-client-cert %s", p12Path) logger.Info("websocket: Loading tls-client-cert %s", p12Path)
// Read the PKCS12 file // Read the PKCS12 file
p12Data, err := os.ReadFile(p12Path) p12Data, err := os.ReadFile(p12Path)
if err != nil { if err != nil {