mirror of
https://github.com/slackhq/nebula.git
synced 2026-08-16 01:27:04 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df43dc218 | |||
| 72bf111209 | |||
| 1617897043 |
+30
-10
@@ -25,19 +25,39 @@ jobs:
|
||||
go-version: '1.26'
|
||||
check-latest: true
|
||||
|
||||
- name: Smoke Docker
|
||||
run: make smoke-docker
|
||||
- name: build
|
||||
run: make bin-docker CGO_ENABLED=1 BUILD_ARGS=-race
|
||||
|
||||
- name: Smoke Docker IPv6 overlay
|
||||
run: make smoke-docker-ipv6
|
||||
- name: setup docker image
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: ./build.sh
|
||||
|
||||
- name: Smoke Relay Docker
|
||||
run: make smoke-relay-docker
|
||||
- name: run smoke
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: ./smoke.sh
|
||||
|
||||
- name: Smoke Docker boringcrypto
|
||||
run: make boringcrypto smoke-docker
|
||||
- name: setup docker image ipv6
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: SMOKE_OVERLAY_IPV6=1 ./build.sh
|
||||
|
||||
- name: Smoke Docker fips140
|
||||
run: make fips140-all GOALS=smoke-docker
|
||||
- name: run smoke ipv6
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: SMOKE_OVERLAY_IPV6=1 ./smoke.sh
|
||||
|
||||
- name: setup relay docker image
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: ./build-relay.sh
|
||||
|
||||
- name: run smoke relay
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: ./smoke-relay.sh
|
||||
|
||||
- name: setup docker image for P256
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: NAME="smoke-p256" CURVE=P256 ./build.sh
|
||||
|
||||
- name: run smoke-p256
|
||||
working-directory: ./.github/workflows/smoke
|
||||
run: NAME="smoke-p256" ./smoke.sh
|
||||
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -58,14 +58,9 @@ jobs:
|
||||
e2e-cmd: make e2evv
|
||||
- name: linux-boringcrypto
|
||||
os: ubuntu-latest
|
||||
build-cmd: make boringcrypto
|
||||
test-cmd: make boringcrypto test
|
||||
e2e-cmd: make boringcrypto e2evv
|
||||
- name: linux-fips140
|
||||
os: ubuntu-latest
|
||||
build-cmd: make fips140-all
|
||||
test-cmd: make fips140-all GOALS=test
|
||||
e2e-cmd: make fips140-all GOALS=e2evv
|
||||
build-cmd: make bin-boringcrypto
|
||||
test-cmd: make test-boringcrypto
|
||||
e2e-cmd: make e2e GOEXPERIMENT=boringcrypto CGO_ENABLED=1 TEST_ENV="TEST_LOGS=1" TEST_FLAGS="-v -ldflags -checklinkname=0"
|
||||
- name: linux-pkcs11
|
||||
os: ubuntu-latest
|
||||
build-cmd: make bin-pkcs11
|
||||
|
||||
@@ -7,6 +7,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.11.0] - 2026-07-23
|
||||
|
||||
See the [v1.11.0](https://github.com/slackhq/nebula/milestone/25?closed=1) milestone for a complete list of changes.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Logging has switched from logrus to Go's structured `slog`. Log output changes: levels are upper case
|
||||
(`level=INFO`), trace prints as `level=DEBUG-4`, timestamps are always RFC3339Nano and `logging.timestamp_format`
|
||||
is ignored, and some messages were reworded. Review any log parsing before upgrading. This is also an API break
|
||||
for embedders, as constructors now take a `*slog.Logger`. (#1672, #1734, #1621)
|
||||
- `firewall.inbound_action` and `firewall.outbound_action` (used to set reject vs. drop policy) were each being
|
||||
applied to the opposite direction, that is now corrected. This only affects how blocked packets are answered, not
|
||||
which packets the firewall allows or denies. If you set either of these you are getting the behavior of the other
|
||||
one today and likely want to swap them before upgrading. (#1798)
|
||||
- On Windows, Nebula now installs WFP PERMIT filters for the nebula adapter and the listener port by default. WFP
|
||||
sits below Windows Defender Firewall, so any WDF inbound rules you rely on for either will no longer apply. Set
|
||||
`tun.windows_bypass_wdf` and `listen.windows_bypass_wdf` to false to leave WDF in charge. (#1710)
|
||||
- On Windows, the nebula device is now set to the `private` network category instead of whatever Windows decided,
|
||||
which is usually `Public`. This makes the host firewall less restrictive on the overlay. Set
|
||||
`tun.network_category` to `unset` to keep the old behavior. (#1710)
|
||||
- Reject packets for non-TCP now use ICMP code 13, communication administratively prohibited, instead of code 3,
|
||||
port unreachable. Anything keying off the old code needs updating. (#1766, #1768)
|
||||
- The SSH debug server's profiling commands are now confined to `sshd.sandbox_dir`, which defaults to
|
||||
`$TMP/nebula-debug`. Relative paths resolve inside it and absolute paths outside it are rejected, so anything
|
||||
scripting `start-cpu-profile`, `save-heap-profile`, or `save-mutex-profile` with a path elsewhere needs the
|
||||
directory set. The directory is not created for you. (#1622)
|
||||
|
||||
### Added
|
||||
|
||||
- Sign the Windows release binaries. (#1718)
|
||||
- Generate IPv6 reject packets, matching the existing IPv4 behavior. (#1766, #1767, #1768)
|
||||
- Accept `-` in `nebula-cert` to read from stdin or write to stdout. (#1714)
|
||||
- Search for both `config.yml` and `config.yaml` in service and command line modes. (#1717)
|
||||
- Add version labels to the Docker/OCI images. (#1772)
|
||||
- Rebind the listener and re-query lighthouses on macOS when the underlay network changes, so devices moving
|
||||
between wifi and wired or between networks recover without waiting for dead tunnel detection. Controlled by
|
||||
`listen.rebind_on_network_change` (default `true`, not reloadable). (#1816)
|
||||
|
||||
### Changed
|
||||
|
||||
- Reload the firewall when the unsafe networks in the certificate change. (#1719)
|
||||
- Reconfigure, start, and stop the stats listener on a config reload instead of requiring a restart. (#1670)
|
||||
- Update a static host's addresses when they change on reload. (#1713)
|
||||
- Don't require a port on ICMP firewall rules. (#1609)
|
||||
- Connection track ICMP traffic. (#1602)
|
||||
- Return `NODATA` instead of `NXDOMAIN` from the DNS server for a name that exists but has no record of the
|
||||
requested type, so clients that query `AAAA` first (busybox/Alpine) fall through to `A`. (#1668)
|
||||
- Record the local host's details in the DNS server. (#1716)
|
||||
- Install Windows unsafe routes as link routes. (#1709)
|
||||
- Reduce relay handshake log spam, and only log a handshake send error at error level when the remote list
|
||||
changes. (#1733, #1765, #1810)
|
||||
- Start, stop, and reload subsystems (DNS, stats, conntrack, ssh, punchy) cleanly without leaking goroutines. (#1640, #1654, #1661, #1667, #1669, #1708, #1806, #1815)
|
||||
- `Control` is now safe to stop and wait on from any lifecycle state, and a new `Control.Wait` blocks until nebula
|
||||
has fully stopped and returns the first fatal reader error. Failed starts release the udp sockets and tun fd
|
||||
instead of leaking them. (#1794)
|
||||
- Trigger an immediate lighthouse update when reconnecting to or adding a lighthouse instead of waiting for the next update tick. (#1645)
|
||||
- Bring the Darwin and OpenBSD tun implementations in line with the other BSDs. (#1703)
|
||||
- Update to build against go v1.26. (#1818)
|
||||
- Various dependency updates. (#1586, #1587, #1604, #1617, #1618, #1627, #1628, #1629, #1652, #1664, #1665, #1697, #1721, #1732, #1742, #1743, #1750, #1763, #1771, #1782, #1800, #1807)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix a data race on a host's remote address that could send packets to the wrong address during a roam. (#1773)
|
||||
- Fix tunnels that could permanently escape connection manager monitoring. (#1752)
|
||||
- Fix a crash when reloading the SSH server's trusted keys. (#1787)
|
||||
- Fix hostmap corruption when a host has multiple overlay addresses. Each address now gets its own list instead of
|
||||
a single shared chain, which also fixes two latent bugs on the add and makePrimary paths. (#1788, #1790)
|
||||
- Apply `remote_allow_list` IPv4 rules to 4-in-6 mapped addresses. (#1786)
|
||||
- Don't panic in the DNS server on a short or empty query name. (#1635)
|
||||
- Advance the replay window on relayed packets so a relay drops replayed frames instead of re-forwarding them. (#1751)
|
||||
- Fix a race in relay state handling. (#1753)
|
||||
- Lock replay window updates so concurrent readers can't corrupt it. (#1802)
|
||||
- Reject malformed handshakes more reliably, including invalid ed25519 key lengths. (#1601, #1756)
|
||||
- Properly handle `closetunnel` packets. (#1638)
|
||||
- Fix an IPv6 extension-header length overflow that could make the firewall parse the wrong protocol and ports. (#1789)
|
||||
- Fix relay re-establishment when a handshake arrives over a relay entry that a one-sided teardown left
|
||||
`Disestablished`, which silently dropped every send until dead tunnel detection forced a re-handshake. (#1805)
|
||||
- Don't build new relay state on a tunnel that was just discarded. (#1796)
|
||||
- Don't delete the wrong pending hostinfo in the handshake manager. (#1811)
|
||||
- Don't call the packet reader after a UDP error on Darwin. (#1755)
|
||||
- Open the FreeBSD tun device non blocking. (#1666)
|
||||
|
||||
## [1.10.3] - 2026-02-06
|
||||
|
||||
### Security
|
||||
|
||||
@@ -72,17 +72,6 @@ ALL_CROSS_LINUX_ARM = linux-arm-5 linux-arm-6 linux-arm-7 linux-arm64
|
||||
ALL_CROSS_LINUX_MIPS = linux-mips linux-mipsle linux-mips64 linux-mips64le linux-mips-softfloat
|
||||
ALL_CROSS_LINUX_OTHER = linux-386 linux-ppc64le linux-riscv64 linux-loong64
|
||||
|
||||
# Based on section 2.2 of the Go Cryptographic Module CVMP Security Policy #5247
|
||||
ALL_FIPS140 = linux-amd64-fips140 \
|
||||
linux-arm64-fips140 \
|
||||
windows-amd64-fips140 \
|
||||
windows-arm64-fips140 \
|
||||
darwin-arm64-fips140 \
|
||||
freebsd-amd64-fips140 \
|
||||
linux-arm-7-fips140 \
|
||||
linux-mips64-fips140 \
|
||||
linux-ppc64le-fips140
|
||||
|
||||
e2e:
|
||||
$(TEST_ENV) go test -tags=e2e_testing -count=1 $(TEST_FLAGS) ./e2e
|
||||
|
||||
@@ -148,8 +137,6 @@ release-netbsd: $(ALL_NETBSD:%=build/nebula-%.tar.gz)
|
||||
|
||||
release-boringcrypto: build/nebula-linux-$(shell go env GOARCH)-boringcrypto.tar.gz
|
||||
|
||||
release-fips140: $(ALL_FIPS140:%=build/nebula-%.tar.gz)
|
||||
|
||||
BUILD_ARGS += -trimpath
|
||||
|
||||
bin-windows: build/windows-amd64/nebula.exe build/windows-amd64/nebula-cert.exe
|
||||
@@ -170,20 +157,17 @@ bin-freebsd-arm64: build/freebsd-arm64/nebula build/freebsd-arm64/nebula-cert
|
||||
bin-boringcrypto: build/linux-$(shell go env GOARCH)-boringcrypto/nebula build/linux-$(shell go env GOARCH)-boringcrypto/nebula-cert
|
||||
mv $? .
|
||||
|
||||
bin-fips140: build/linux-$(shell go env GOARCH)-fips140/nebula build/linux-$(shell go env GOARCH)-fips140/nebula-cert
|
||||
mv $? .
|
||||
|
||||
bin-pkcs11: BUILD_ARGS += -tags pkcs11
|
||||
bin-pkcs11: CGO_ENABLED = 1
|
||||
bin-pkcs11: bin
|
||||
|
||||
bin:
|
||||
$(GOENV) go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula${NEBULA_CMD_SUFFIX} ${NEBULA_CMD_PATH}
|
||||
$(GOENV) go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula-cert${NEBULA_CMD_SUFFIX} ./cmd/nebula-cert
|
||||
go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula${NEBULA_CMD_SUFFIX} ${NEBULA_CMD_PATH}
|
||||
go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula-cert${NEBULA_CMD_SUFFIX} ./cmd/nebula-cert
|
||||
|
||||
install:
|
||||
$(GOENV) go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ${NEBULA_CMD_PATH}
|
||||
$(GOENV) go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ./cmd/nebula-cert
|
||||
go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ${NEBULA_CMD_PATH}
|
||||
go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ./cmd/nebula-cert
|
||||
|
||||
build/linux-arm-%: GOENV += GOARM=$(word 3, $(subst -, ,$*))
|
||||
build/linux-mips-%: GOENV += GOMIPS=$(word 3, $(subst -, ,$*))
|
||||
@@ -194,11 +178,8 @@ build/linux-mips-softfloat/%: LDFLAGS += -s -w
|
||||
# boringcrypto
|
||||
build/linux-amd64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1
|
||||
build/linux-arm64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1
|
||||
|
||||
# fips140
|
||||
FIPSVERSION = v1.0.0
|
||||
$(foreach _rule, $(ALL_FIPS140), build/$(_rule)/%): GOENV += GOFIPS140=$(FIPSVERSION)
|
||||
$(foreach _rule, $(ALL_FIPS140), build/$(_rule)/%): BUILD_ARGS += -tags fips140enforce
|
||||
build/linux-amd64-boringcrypto/%: LDFLAGS += -checklinkname=0
|
||||
build/linux-arm64-boringcrypto/%: LDFLAGS += -checklinkname=0
|
||||
|
||||
build/%/nebula: .FORCE
|
||||
GOOS=$(firstword $(subst -, , $*)) \
|
||||
@@ -229,7 +210,10 @@ vet:
|
||||
go vet $(VET_FLAGS) -v ./...
|
||||
|
||||
test:
|
||||
$(TEST_ENV) go test $(TEST_FLAGS) -v ./...
|
||||
go test -v ./...
|
||||
|
||||
test-boringcrypto:
|
||||
GOEXPERIMENT=boringcrypto CGO_ENABLED=1 go test -ldflags "-checklinkname=0" -v ./...
|
||||
|
||||
test-pkcs11:
|
||||
CGO_ENABLED=1 go test -v -tags pkcs11 ./...
|
||||
@@ -272,72 +256,29 @@ ifeq ($(words $(MAKECMDGOALS)),1)
|
||||
@$(MAKE) service ${.DEFAULT_GOAL} --no-print-directory
|
||||
endif
|
||||
|
||||
# Useful to chain together, like:
|
||||
# - make fips140 e2evv
|
||||
# - make fips140 smoke-docker
|
||||
# Use `release-fips140` to build release binaries
|
||||
fips140:
|
||||
@echo > $(NULL_FILE)
|
||||
ifeq ($(strip $(GOFIPS140)),)
|
||||
$(eval GOFIPS140 = $(FIPSVERSION))
|
||||
endif
|
||||
$(eval GOENV += GOFIPS140=$(GOFIPS140))
|
||||
$(eval BUILD_ARGS += -tags fips140enforce)
|
||||
$(eval TEST_ENV += $(GOENV))
|
||||
$(eval CURVE = P256)
|
||||
ifeq ($(words $(MAKECMDGOALS)),1)
|
||||
@$(MAKE) fips140 GOFIPS140=$(GOFIPS140) ${.DEFAULT_GOAL} --no-print-directory
|
||||
endif
|
||||
|
||||
# To test the future pending module, use like `make fips140-latest test`
|
||||
ALL_GOFIPS140 = v1.0.0 v1.26.0 latest
|
||||
define FIPS140_rule
|
||||
fips140-$(1): GOFIPS140 = $(1)
|
||||
fips140-$(1): fips140
|
||||
endef
|
||||
$(foreach _rule, $(ALL_GOFIPS140), $(eval $(call FIPS140_rule,$(_rule))))
|
||||
|
||||
# Iterate and run the goals for all fips versions, like `make fips140-all GOALS=test`
|
||||
fips140-all:
|
||||
@$(foreach _v,$(ALL_GOFIPS140),$(MAKE) fips140-$(_v) $(GOALS) &&) true
|
||||
|
||||
# Useful to chain together, like:
|
||||
# - make boringcrypto e2evv
|
||||
# - make boringcrypto smoke-docker
|
||||
# Use `release-boringcrypto` or `bin-boringcrypto` to build release binaries
|
||||
boringcrypto:
|
||||
@echo > $(NULL_FILE)
|
||||
$(eval GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1)
|
||||
$(eval TEST_ENV += $(GOENV))
|
||||
$(eval CURVE = P256)
|
||||
ifeq ($(words $(MAKECMDGOALS)),1)
|
||||
@$(MAKE) boringcrypto ${.DEFAULT_GOAL} --no-print-directory
|
||||
endif
|
||||
|
||||
bin-docker: bin build/linux-amd64/nebula build/linux-amd64/nebula-cert
|
||||
|
||||
smoke-docker: BUILD_ARGS += -race
|
||||
smoke-docker: GOENV += CGO_ENABLED=1
|
||||
smoke-docker: bin-docker
|
||||
# This is so we can limit `fips140` smoke test to just P256 curve.
|
||||
if [ "$(CURVE)" != "P256" ]; then cd .github/workflows/smoke/ && $(GOENV) ./build.sh; fi
|
||||
if [ "$(CURVE)" != "P256" ]; then cd .github/workflows/smoke/ && $(GOENV) ./smoke.sh; fi
|
||||
cd .github/workflows/smoke/ && $(GOENV) NAME="smoke-p256" CURVE="P256" ./build.sh
|
||||
cd .github/workflows/smoke/ && $(GOENV) NAME="smoke-p256" ./smoke.sh
|
||||
cd .github/workflows/smoke/ && ./build.sh
|
||||
cd .github/workflows/smoke/ && ./smoke.sh
|
||||
cd .github/workflows/smoke/ && NAME="smoke-p256" CURVE="P256" ./build.sh
|
||||
cd .github/workflows/smoke/ && NAME="smoke-p256" ./smoke.sh
|
||||
|
||||
smoke-relay-docker: BUILD_ARGS += -race
|
||||
smoke-relay-docker: GOENV += CGO_ENABLED=1
|
||||
smoke-relay-docker: bin-docker
|
||||
cd .github/workflows/smoke/ && $(GOENV) ./build-relay.sh
|
||||
cd .github/workflows/smoke/ && $(GOENV) ./smoke-relay.sh
|
||||
cd .github/workflows/smoke/ && ./build-relay.sh
|
||||
cd .github/workflows/smoke/ && ./smoke-relay.sh
|
||||
|
||||
smoke-docker-ipv6: export SMOKE_OVERLAY_IPV6 = 1
|
||||
smoke-docker-ipv6: smoke-docker
|
||||
|
||||
smoke-docker-race: BUILD_ARGS = -race
|
||||
smoke-docker-race: CGO_ENABLED = 1
|
||||
smoke-docker-race: smoke-docker
|
||||
|
||||
smoke-vagrant/%: bin-docker build/%/nebula
|
||||
cd .github/workflows/smoke/ && ./build.sh $*
|
||||
cd .github/workflows/smoke/ && ./smoke-vagrant.sh $*
|
||||
|
||||
.FORCE:
|
||||
.PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin bin-windows bin-windows-arm64 bin-darwin bin-freebsd bin-freebsd-arm64 bin-boringcrypto bin-fips140 bin-pkcs11 bin-docker boringcrypto build-test-mobile docker e2e e2ev e2evv e2evvv e2evvvv e2e-bench fips140 fips140-all $(ALL_GOFIPS140:%=fips140-%) install proto release release-linux release-freebsd release-openbsd release-netbsd release-boringcrypto release-fips140 service smoke-docker smoke-relay-docker smoke-docker-ipv6 test test-pkcs11 test-cov-html vet smoke-vagrant/%
|
||||
.PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin build-test-mobile e2e e2ev e2evv e2evvv e2evvvv proto release service smoke-docker smoke-docker-race test test-cov-html smoke-vagrant/%
|
||||
.DEFAULT_GOAL := bin
|
||||
|
||||
@@ -145,27 +145,17 @@ To build nebula for a specific platform (ex, Windows):
|
||||
|
||||
See the [Makefile](Makefile) for more details on build targets
|
||||
|
||||
## Curve P256 and FIPS 140-3 mode
|
||||
## Curve P256 and BoringCrypto
|
||||
|
||||
The default curve used for cryptographic handshakes and signatures is Curve25519. This is the recommended setting for most users. If your deployment has certain compliance requirements, you have the option of creating your CA using `nebula-cert ca -curve P256` to use NIST Curve P256. The CA will then sign certificates using ECDSA P256, and any hosts using these certificates will use P256 for ECDH handshakes.
|
||||
|
||||
Nebula can be built to support the [FIPS 140-3](https://go.dev/doc/security/fips140) mode of Go by running either of the following make targets. (This sets GOFIPS140=v1.0.0, which must be done at compile time so that the correct AES-GCM can be used for FIPS 140-3 enforcement mode).
|
||||
|
||||
```sh
|
||||
make fips140
|
||||
make fips140 test
|
||||
make release-fips140
|
||||
```
|
||||
|
||||
Nebula can also be built using the [BoringCrypto GOEXPERIMENT](https://github.com/golang/go/blob/go1.20/src/crypto/internal/boring/README.md) by running either of the following make targets.
|
||||
In addition, Nebula can be built using the [BoringCrypto GOEXPERIMENT](https://github.com/golang/go/blob/go1.20/src/crypto/internal/boring/README.md) by running either of the following make targets:
|
||||
|
||||
```sh
|
||||
make bin-boringcrypto
|
||||
make release-boringcrypto
|
||||
```
|
||||
|
||||
NOTE: boringcrypto support is deprecated and will be removed in the next release. Users should migrate to the native FIPS 140-3 mode described above.
|
||||
|
||||
This is not the recommended default deployment, but may be useful based on your compliance requirements.
|
||||
|
||||
## Credits
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/fips140"
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -44,13 +43,6 @@ type caFlags struct {
|
||||
subnets *string
|
||||
}
|
||||
|
||||
func defaultCurve() string {
|
||||
if fips140.Enforced() {
|
||||
return "P256"
|
||||
}
|
||||
return "25519"
|
||||
}
|
||||
|
||||
func newCaFlags() *caFlags {
|
||||
cf := caFlags{set: flag.NewFlagSet("ca", flag.ContinueOnError)}
|
||||
cf.set.Usage = func() {}
|
||||
@@ -67,7 +59,7 @@ func newCaFlags() *caFlags {
|
||||
cf.argonParallelism = cf.set.Uint("argon-parallelism", 4, "Optional: Argon2 parallelism parameter used for encrypted private key passphrase")
|
||||
cf.argonIterations = cf.set.Uint("argon-iterations", 1, "Optional: Argon2 iterations parameter used for encrypted private key passphrase")
|
||||
cf.encryption = cf.set.Bool("encrypt", false, "Optional: prompt for passphrase and write out-key in an encrypted format")
|
||||
cf.curve = cf.set.String("curve", defaultCurve(), "EdDSA/ECDSA Curve (25519, P256)")
|
||||
cf.curve = cf.set.String("curve", "25519", "EdDSA/ECDSA Curve (25519, P256)")
|
||||
cf.p11url = p11Flag(cf.set)
|
||||
|
||||
cf.ips = cf.set.String("ips", "", "Deprecated, see -networks")
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build fips140enforce
|
||||
|
||||
//go:debug fips140=only
|
||||
|
||||
package main
|
||||
@@ -24,7 +24,7 @@ func newKeygenFlags() *keygenFlags {
|
||||
cf.set.Usage = func() {}
|
||||
cf.outPubPath = cf.set.String("out-pub", "", "Required: path to write the public key to")
|
||||
cf.outKeyPath = cf.set.String("out-key", "", "Required: path to write the private key to")
|
||||
cf.curve = cf.set.String("curve", defaultCurve(), "ECDH Curve (25519, P256)")
|
||||
cf.curve = cf.set.String("curve", "25519", "ECDH Curve (25519, P256)")
|
||||
cf.p11url = p11Flag(cf.set)
|
||||
return &cf
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build fips140enforce
|
||||
|
||||
//go:debug fips140=only
|
||||
|
||||
package main
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build fips140enforce
|
||||
|
||||
//go:debug fips140=only
|
||||
|
||||
package main
|
||||
@@ -52,6 +52,7 @@ type Control struct {
|
||||
sshStart func()
|
||||
statsStart func()
|
||||
dnsStart func()
|
||||
infoAPIStart func()
|
||||
lighthouseStart func()
|
||||
networkChangeStart func(rebind func())
|
||||
connectionManagerStart func(context.Context)
|
||||
@@ -108,6 +109,9 @@ func (c *Control) Start() error {
|
||||
if c.networkChangeStart != nil {
|
||||
go c.networkChangeStart(c.RebindUDPServer)
|
||||
}
|
||||
if c.infoAPIStart != nil {
|
||||
go c.infoAPIStart()
|
||||
}
|
||||
if c.connectionManagerStart != nil {
|
||||
go c.connectionManagerStart(c.ctx)
|
||||
}
|
||||
|
||||
+3
-22
@@ -258,31 +258,12 @@ func (d *dnsServer) QueryCert(data string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves.
|
||||
// Answer self lookups straight from the local cert state.
|
||||
if cs := d.certState(); cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) {
|
||||
c := cs.GetDefaultCertificate()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
b, err := c.MarshalJSON()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
hostinfo := d.hostMap.QueryVpnAddr(ip)
|
||||
if hostinfo == nil {
|
||||
crt := findCertificateForVpnAddr(d.certState(), d.hostMap, ip)
|
||||
if crt == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
q := hostinfo.GetCert()
|
||||
if q == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := q.Certificate.MarshalJSON()
|
||||
b, err := crt.MarshalJSON()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build e2e_testing
|
||||
// +build e2e_testing
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/slackhq/nebula"
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/slackhq/nebula/cert_test"
|
||||
"github.com/slackhq/nebula/e2e/router"
|
||||
"github.com/slackhq/nebula/udp"
|
||||
)
|
||||
|
||||
// TestRecoveryTiming measures how long a tunnel takes to come back after the peer stops accepting our traffic,
|
||||
// which is what a laptop waking on a new network looks like from the peer's side: its NAT has no state for where
|
||||
// we are now, so everything we send disappears.
|
||||
//
|
||||
// It is a measurement, not a pass/fail assertion. Recovery is timed to the moment the peer punches back at us,
|
||||
// since that is when its NAT opens and the tunnel is usable again.
|
||||
//
|
||||
// go test -tags e2e_testing -v -run TestRecoveryTiming ./e2e/
|
||||
func TestRecoveryTiming(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rebind bool
|
||||
}{
|
||||
{"no trigger", false},
|
||||
{"rebind counter", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d, lost := measureRecovery(t, tc.rebind)
|
||||
t.Logf("RESULT %-16s recovered in %-9v (%d packets lost)", tc.name, d.Round(time.Millisecond), lost)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// measureRecovery returns how long until the peer punched back, and how many of our packets died meanwhile. When
|
||||
// rebind is true we call RebindUDPServer once the tunnel goes dark, which is what the darwin network change
|
||||
// monitor does and what iOS has always done. When false, nothing tells nebula anything is wrong.
|
||||
func measureRecovery(t *testing.T, rebind bool) (time.Duration, int) {
|
||||
t.Helper()
|
||||
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
|
||||
|
||||
lhControl, lhVpnIpNet, lhUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "lh", "10.128.0.1/24", m{
|
||||
"lighthouse": m{"am_lighthouse": true},
|
||||
})
|
||||
|
||||
peerCfg := m{
|
||||
"lighthouse": m{
|
||||
"hosts": []any{lhVpnIpNet[0].Addr().String()},
|
||||
"interval": 600,
|
||||
"local_allow_list": m{
|
||||
"10.0.0.0/24": true,
|
||||
"::/0": false,
|
||||
},
|
||||
},
|
||||
"static_host_map": m{
|
||||
lhVpnIpNet[0].Addr().String(): []any{lhUdpAddr.String()},
|
||||
},
|
||||
}
|
||||
|
||||
myControl, myVpnIpNet, myUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.2/24", peerCfg)
|
||||
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.3/24", peerCfg)
|
||||
|
||||
r := router.NewR(t, lhControl, myControl, theirControl)
|
||||
defer r.RenderFlow()
|
||||
defer func() {
|
||||
lhControl.Stop()
|
||||
myControl.Stop()
|
||||
theirControl.Stop()
|
||||
}()
|
||||
|
||||
lhControl.Start()
|
||||
myControl.Start()
|
||||
theirControl.Start()
|
||||
r.RouteFor(time.Millisecond * 500)
|
||||
|
||||
myControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
|
||||
theirControl.InjectLightHouseAddr(myVpnIpNet[0].Addr(), myUdpAddr)
|
||||
|
||||
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("establish")))
|
||||
r.RouteFor(time.Second)
|
||||
if myControl.GetHostInfoByVpnAddr(theirVpnIpNet[0].Addr(), false) == nil {
|
||||
t.Fatal("failed to establish the tunnel we are measuring")
|
||||
}
|
||||
r.RouteFor(time.Millisecond * 500)
|
||||
|
||||
// From here the peer's NAT has no state for us, everything we send it disappears
|
||||
start := time.Now()
|
||||
blackholed := 0
|
||||
var recovered time.Duration
|
||||
|
||||
if rebind {
|
||||
myControl.RebindUDPServer()
|
||||
}
|
||||
|
||||
// Keep the tun busy the way someone retrying a stalled connection would
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
tick := time.NewTicker(time.Millisecond * 200)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-tick.C:
|
||||
myControl.InjectTunPacket(BuildTunUDPPacket(
|
||||
theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("retry")))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
r.RouteForAllExitFuncOrTimeout(time.Second*30, func(p *udp.Packet, c *nebula.Control) router.ExitType {
|
||||
if c == theirControl && p.From == myControl.GetUDPAddr() {
|
||||
blackholed++
|
||||
return router.Drop
|
||||
}
|
||||
|
||||
// The peer reaching us directly is the moment its NAT opened, whether that is a punch or a handshake
|
||||
if c == myControl && p.From == theirUdpAddr {
|
||||
recovered = time.Since(start)
|
||||
return router.RouteAndExit
|
||||
}
|
||||
|
||||
return router.KeepRouting
|
||||
})
|
||||
|
||||
if recovered == 0 {
|
||||
t.Fatalf("no recovery within 30s (%d packets blackholed)", blackholed)
|
||||
}
|
||||
return recovered, blackholed
|
||||
}
|
||||
+19
-2
@@ -153,6 +153,9 @@ const (
|
||||
ExitNow ExitType = 1
|
||||
// RouteAndExit routes this packet and exits immediately afterwards
|
||||
RouteAndExit ExitType = 2
|
||||
// Drop discards this packet without delivering it and keeps routing. Use it to simulate a blackhole, such as
|
||||
// a restrictive NAT refusing traffic from an address it has not seen.
|
||||
Drop ExitType = 3
|
||||
)
|
||||
|
||||
type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType
|
||||
@@ -163,7 +166,9 @@ type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType
|
||||
func NewR(t testing.TB, controls ...*nebula.Control) *R {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
if err := os.MkdirAll("mermaid", 0755); err != nil {
|
||||
// t.Name() contains a slash for subtests, so the flow log can land in a nested directory
|
||||
fn := filepath.Join("mermaid", fmt.Sprintf("%s.md", t.Name()))
|
||||
if err := os.MkdirAll(filepath.Dir(fn), 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -174,7 +179,7 @@ func NewR(t testing.TB, controls ...*nebula.Control) *R {
|
||||
outNat: make(map[outNatKey]netip.AddrPort),
|
||||
flow: []flowEntry{},
|
||||
ignoreFlows: []ignoreFlow{},
|
||||
fn: filepath.Join("mermaid", fmt.Sprintf("%s.md", t.Name())),
|
||||
fn: fn,
|
||||
t: t,
|
||||
cancelRender: cancel,
|
||||
}
|
||||
@@ -687,6 +692,10 @@ func (r *R) RouteExitFunc(sender *nebula.Control, whatDo ExitFunc) {
|
||||
p.Release()
|
||||
return
|
||||
|
||||
case Drop:
|
||||
// Record it so the flow log shows the attempt, but never hand it to the receiver
|
||||
r.unlockedInjectFlow(sender, receiver, p, false)
|
||||
|
||||
case KeepRouting:
|
||||
fp := r.unlockedInjectFlow(sender, receiver, p, false)
|
||||
receiver.InjectUDPPacket(p)
|
||||
@@ -779,6 +788,10 @@ func (r *R) RouteForAllExitFuncOrTimeout(timeout time.Duration, whatDo ExitFunc)
|
||||
p.Release()
|
||||
return true
|
||||
|
||||
case Drop:
|
||||
// Record it so the flow log shows the attempt, but never hand it to the receiver
|
||||
r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||
|
||||
case KeepRouting:
|
||||
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||
receiver.InjectUDPPacket(p)
|
||||
@@ -884,6 +897,10 @@ func (r *R) RouteForAllExitFunc(whatDo ExitFunc) {
|
||||
p.Release()
|
||||
return
|
||||
|
||||
case Drop:
|
||||
// Record it so the flow log shows the attempt, but never hand it to the receiver
|
||||
r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||
|
||||
case KeepRouting:
|
||||
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||
receiver.InjectUDPPacket(p)
|
||||
|
||||
@@ -231,6 +231,35 @@ punchy:
|
||||
# Overriding this to "" is the same as "/" and will allow overwriting any path on the host.
|
||||
#sandbox_dir: /var/tmp/nebula-debug
|
||||
|
||||
# EXPERIMENTAL: this feature may change or disappear in the future.
|
||||
# info_api exposes a small local HTTP+JSON API that lets other programs on
|
||||
# this machine resolve a vpn address to its certificate identity (name, vpn
|
||||
# addresses, groups, fingerprint, validity), e.g. for making authorization
|
||||
# decisions about an inbound connection:
|
||||
# GET /v1/host?addr=<vpn addr> - identity of the host owning the address: a
|
||||
# peer with an active tunnel, or this node itself. `addr` may include a
|
||||
# port (`192.168.100.7:54321`), which is ignored, so a connection's remote
|
||||
# address can be passed through as is. Returns 404 when the address is
|
||||
# unknown or has no active tunnel.
|
||||
# GET /v1/self - this node's own identity.
|
||||
# Identity answers can be trusted because nebula drops inbound packets whose
|
||||
# source vpn address is not contained in the sender's certificate, so the
|
||||
# source address of a connection arriving over the nebula interface is
|
||||
# guaranteed to map to the certificate reported here.
|
||||
# There is no authentication in this API; restrict access with unix socket
|
||||
# file permissions.
|
||||
# This whole section is reloadable.
|
||||
#info_api:
|
||||
# Toggles the feature
|
||||
#enabled: false
|
||||
# listen accepts a unix socket path as a unix:// URL with an absolute path:
|
||||
#listen: unix:///var/run/nebula-info-api.sock
|
||||
# File mode for the unix socket, as an octal string.
|
||||
# The socket is created by nebula's user; to grant a group of local services
|
||||
# access, place the socket in a directory with appropriate permissions
|
||||
# (e.g. a systemd RuntimeDirectory) and relax this to "0660".
|
||||
#socket_mode: "0600"
|
||||
|
||||
# EXPERIMENTAL: relay support for networks that can't establish direct connections.
|
||||
relay:
|
||||
# Relays are a list of Nebula IP's that peers can use to relay packets to me.
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/slackhq/nebula/config"
|
||||
)
|
||||
|
||||
// infoAPIServer is a small http+json listener on a unix socket that lets other
|
||||
// programs on this machine resolve a vpn address to its certificate identity (name, groups, networks)
|
||||
// for making authorization decisions. Lifecycle works like statsServer: the constructor wires the
|
||||
// reload callback, reload records config, Start runs the runtime, Stop tears it down
|
||||
type infoAPIServer struct {
|
||||
l *slog.Logger
|
||||
ctx context.Context
|
||||
hostMap *HostMap
|
||||
pki *PKI
|
||||
|
||||
// enabled mirrors `info_api.enabled` so callers of Start don't need to know the gating rules
|
||||
enabled atomic.Bool
|
||||
|
||||
runMu sync.Mutex
|
||||
runCfg *infoAPIConfig
|
||||
run *infoAPIRuntime // non-nil while a runtime is live
|
||||
}
|
||||
|
||||
// infoAPIRuntime is the live state owned by a single Start invocation. Stop and Start's exit path
|
||||
// use pointer equality to tell "my runtime" apart from one that replaced it after a reload
|
||||
type infoAPIRuntime struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// infoAPIConfig is a snapshot of the info_api config section, comparable with == so reload can
|
||||
// detect "no change" cheaply
|
||||
type infoAPIConfig struct {
|
||||
enabled bool
|
||||
listen string // raw config value, for error messages
|
||||
addr string // unix socket path
|
||||
// file mode applied to the unix socket after bind
|
||||
socketMode fs.FileMode
|
||||
}
|
||||
|
||||
// newInfoAPIServerFromConfig builds a infoAPIServer and applies the initial config. The reload
|
||||
// callback is registered first so a SIGHUP can later enable, fix, or disable the listener even if
|
||||
// the initial config was bad. Nothing binds until Start, so config tests are side effect free.
|
||||
// A bad config is logged rather than returned: it must not stop nebula from starting, the feature
|
||||
// just stays disabled until a reload provides a valid config
|
||||
func newInfoAPIServerFromConfig(ctx context.Context, l *slog.Logger, pki *PKI, hostMap *HostMap, c *config.C) *infoAPIServer {
|
||||
h := &infoAPIServer{
|
||||
l: l,
|
||||
ctx: ctx,
|
||||
hostMap: hostMap,
|
||||
pki: pki,
|
||||
}
|
||||
|
||||
c.RegisterReloadCallback(func(c *config.C) {
|
||||
if err := h.reload(c, false); err != nil {
|
||||
h.l.Warn("Failed to reload info API from config", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := h.reload(c, true); err != nil {
|
||||
h.l.Warn("Failed to apply info API config; it will stay disabled until the config is fixed and reloaded", "error", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// reload records the latest config. The initial call only records it, Control.Start launches the
|
||||
// first runtime via infoAPIStart. Later calls reconcile the running listener with the new config:
|
||||
// enable, disable, or restart when the listen config changed
|
||||
func (h *infoAPIServer) reload(c *config.C, initial bool) error {
|
||||
newCfg, err := loadInfoAPIConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.runMu.Lock()
|
||||
sameCfg := h.runCfg != nil && *h.runCfg == newCfg
|
||||
h.runCfg = &newCfg
|
||||
running := h.run != nil
|
||||
h.runMu.Unlock()
|
||||
|
||||
h.enabled.Store(newCfg.enabled)
|
||||
|
||||
if initial || sameCfg {
|
||||
return nil
|
||||
}
|
||||
|
||||
if running {
|
||||
h.Stop()
|
||||
}
|
||||
if newCfg.enabled {
|
||||
go h.Start()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start binds the listener from the latest config and serves until Stop is called or ctx fires.
|
||||
// Safe to call when disabled or already running (both no-op)
|
||||
func (h *infoAPIServer) Start() {
|
||||
if !h.enabled.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
h.runMu.Lock()
|
||||
if h.ctx.Err() != nil || h.run != nil || h.runCfg == nil {
|
||||
h.runMu.Unlock()
|
||||
return
|
||||
}
|
||||
cfg := *h.runCfg
|
||||
ln, err := h.listen(cfg)
|
||||
if err != nil {
|
||||
// drop the cached config so a SIGHUP with the same config retries the bind
|
||||
h.runCfg = nil
|
||||
h.runMu.Unlock()
|
||||
h.l.Error("Failed to start info API listener", "listen", cfg.listen, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/host", h.handleHost)
|
||||
mux.HandleFunc("GET /v1/self", h.handleSelf)
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
rt := &infoAPIRuntime{server: srv, listener: ln}
|
||||
h.run = rt
|
||||
h.runMu.Unlock()
|
||||
|
||||
h.l.Info("Starting info API listener", "addr", ln.Addr())
|
||||
cleanExit := h.serve(srv, ln)
|
||||
|
||||
// A Stop that raced our bind shut the server down before Serve could adopt the listener;
|
||||
// closing it again is harmless and guarantees a unix socket file gets unlinked
|
||||
_ = ln.Close()
|
||||
|
||||
// Clear our runtime only if nothing has replaced it. Stop races through here too but leaves
|
||||
// h.run == nil, so the pointer check skips
|
||||
h.runMu.Lock()
|
||||
if h.run == rt {
|
||||
h.run = nil
|
||||
// an error exit leaves runCfg cached as if it were applied, drop it so a SIGHUP with the
|
||||
// same config re-triggers Start once the user fixes the underlying problem
|
||||
if !cleanExit {
|
||||
h.runCfg = nil
|
||||
}
|
||||
}
|
||||
h.runMu.Unlock()
|
||||
}
|
||||
|
||||
// serve runs srv.Serve and ensures ctx cancellation unblocks it. Returns true if the listener
|
||||
// exited cleanly (Stop, ctx cancellation), false on an unexpected error
|
||||
func (h *infoAPIServer) serve(srv *http.Server, ln net.Listener) bool {
|
||||
// ctx cancellation triggers a server shutdown which in turn unblocks Serve, closing `done` on
|
||||
// exit keeps the watcher from outliving this call
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-h.ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
h.l.Warn("Failed to shut down info API listener", "error", err)
|
||||
}
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
defer close(done)
|
||||
|
||||
err := srv.Serve(ln)
|
||||
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
||||
return true
|
||||
}
|
||||
h.l.Error("Info API listener exited", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Stop tears down the active runtime, if any. Idempotent
|
||||
func (h *infoAPIServer) Stop() {
|
||||
h.runMu.Lock()
|
||||
rt := h.run
|
||||
h.run = nil
|
||||
h.runMu.Unlock()
|
||||
if rt == nil {
|
||||
return
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := rt.server.Shutdown(shutdownCtx); err != nil {
|
||||
h.l.Warn("Failed to shut down info API listener", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// listen binds the configured unix socket. It also clears a stale socket file left by an unclean
|
||||
// exit and applies the configured file mode
|
||||
func (h *infoAPIServer) listen(cfg infoAPIConfig) (net.Listener, error) {
|
||||
if fi, err := os.Stat(cfg.addr); err == nil {
|
||||
if fi.Mode()&os.ModeSocket == 0 {
|
||||
return nil, fmt.Errorf("info_api.listen path %s exists and is not a socket, refusing to replace it", cfg.addr)
|
||||
}
|
||||
// a normal shutdown unlinks the socket, so a file here means a previous process exited
|
||||
// uncleanly, remove it so the bind below can succeed
|
||||
if err = os.Remove(cfg.addr); err != nil {
|
||||
return nil, fmt.Errorf("failed to remove stale socket %s: %w", cfg.addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", cfg.addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The socket is briefly live with umask-derived permissions before this chmod lands, tolerated
|
||||
// because connections accepted in that window still only reach this read-only API
|
||||
if err = os.Chmod(cfg.addr, cfg.socketMode); err != nil {
|
||||
_ = ln.Close()
|
||||
return nil, fmt.Errorf("failed to set mode on socket %s: %w", cfg.addr, err)
|
||||
}
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
func (h *infoAPIServer) certState() *CertState {
|
||||
if h.pki == nil {
|
||||
return nil
|
||||
}
|
||||
return h.pki.getCertState()
|
||||
}
|
||||
|
||||
// handleHost serves GET /v1/host?addr=<vpn addr>, answering with the identity of the host that
|
||||
// owns the address: a peer with an active tunnel, or this node itself. addr may include a port,
|
||||
// which is ignored, so clients can pass a connection's remote address through without parsing it
|
||||
func (h *infoAPIServer) handleHost(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("addr")
|
||||
if q == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "missing addr parameter")
|
||||
return
|
||||
}
|
||||
ip, err := parseQueryAddrParam(q)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "invalid address")
|
||||
return
|
||||
}
|
||||
|
||||
crt := findCertificateForVpnAddr(h.certState(), h.hostMap, ip)
|
||||
if crt == nil {
|
||||
writeJSONError(w, http.StatusNotFound, "no active tunnel for address")
|
||||
return
|
||||
}
|
||||
h.writeHostIdentity(w, crt)
|
||||
}
|
||||
|
||||
// handleSelf serves GET /v1/self, answering with this node's own identity
|
||||
func (h *infoAPIServer) handleSelf(w http.ResponseWriter, r *http.Request) {
|
||||
var crt cert.Certificate
|
||||
if cs := h.certState(); cs != nil {
|
||||
crt = cs.getCertificate(cs.initiatingVersion)
|
||||
}
|
||||
if crt == nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "no certificate available")
|
||||
return
|
||||
}
|
||||
h.writeHostIdentity(w, crt)
|
||||
}
|
||||
|
||||
func (h *infoAPIServer) writeHostIdentity(w http.ResponseWriter, crt cert.Certificate) {
|
||||
id, err := newHostIdentity(crt)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "failed to fingerprint certificate")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err = json.NewEncoder(w).Encode(id); err != nil {
|
||||
h.l.Debug("Failed to write info API response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// findCertificateForVpnAddr answers "who owns this vpn address": ourselves (from local cert state,
|
||||
// the hostmap never carries an entry for this node) or a peer with an active tunnel. Returns nil
|
||||
// when the address is unknown or the tunnel is mid-teardown
|
||||
func findCertificateForVpnAddr(cs *CertState, hostMap *HostMap, ip netip.Addr) cert.Certificate {
|
||||
if cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) {
|
||||
return cs.getCertificate(cs.initiatingVersion)
|
||||
}
|
||||
|
||||
hostinfo := hostMap.QueryVpnAddr(ip)
|
||||
if hostinfo == nil {
|
||||
return nil
|
||||
}
|
||||
cc := hostinfo.GetCert()
|
||||
if cc == nil {
|
||||
return nil
|
||||
}
|
||||
return cc.Certificate
|
||||
}
|
||||
|
||||
// hostIdentity is the json document served for both /v1/host and /v1/self, every field is derived
|
||||
// from the authenticated certificate alone
|
||||
type hostIdentity struct {
|
||||
Name string `json:"name"`
|
||||
VpnAddrs []netip.Addr `json:"vpnAddrs"`
|
||||
Networks []netip.Prefix `json:"networks"`
|
||||
UnsafeNetworks []netip.Prefix `json:"unsafeNetworks"`
|
||||
Groups []string `json:"groups"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Issuer string `json:"issuer"`
|
||||
NotBefore time.Time `json:"notBefore"`
|
||||
NotAfter time.Time `json:"notAfter"`
|
||||
CertVersion int `json:"certVersion"`
|
||||
}
|
||||
|
||||
func newHostIdentity(crt cert.Certificate) (hostIdentity, error) {
|
||||
fp, err := crt.Fingerprint()
|
||||
if err != nil {
|
||||
return hostIdentity{}, err
|
||||
}
|
||||
|
||||
// slices are always allocated so they marshal as [] rather than null
|
||||
networks := crt.Networks()
|
||||
id := hostIdentity{
|
||||
Name: crt.Name(),
|
||||
VpnAddrs: make([]netip.Addr, 0, len(networks)),
|
||||
Networks: append(make([]netip.Prefix, 0, len(networks)), networks...),
|
||||
UnsafeNetworks: append(make([]netip.Prefix, 0, len(crt.UnsafeNetworks())), crt.UnsafeNetworks()...),
|
||||
Groups: append(make([]string, 0, len(crt.Groups())), crt.Groups()...),
|
||||
Fingerprint: fp,
|
||||
Issuer: crt.Issuer(),
|
||||
NotBefore: crt.NotBefore(),
|
||||
NotAfter: crt.NotAfter(),
|
||||
CertVersion: int(crt.Version()),
|
||||
}
|
||||
for _, n := range networks {
|
||||
id.VpnAddrs = append(id.VpnAddrs, n.Addr())
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// parseQueryAddrParam parses the addr query parameter, accepting a bare address or an address with
|
||||
// a port (`192.168.100.7:54321`, `[fd00::1]:443`) so callers can pass a connection's RemoteAddr
|
||||
// straight through. The result is unmapped, 4in6 addresses (::ffff:a.b.c.d) become ipv4
|
||||
func parseQueryAddrParam(s string) (netip.Addr, error) {
|
||||
if ip, err := netip.ParseAddr(s); err == nil {
|
||||
return ip.Unmap(), nil
|
||||
}
|
||||
ap, err := netip.ParseAddrPort(s)
|
||||
if err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
return ap.Addr().Unmap(), nil
|
||||
}
|
||||
|
||||
func loadInfoAPIConfig(c *config.C) (infoAPIConfig, error) {
|
||||
cfg := infoAPIConfig{
|
||||
enabled: c.GetBool("info_api.enabled", false),
|
||||
listen: c.GetString("info_api.listen", ""),
|
||||
}
|
||||
if !cfg.enabled {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if cfg.listen == "" {
|
||||
return cfg, errors.New("info_api.listen can not be empty when info_api is enabled")
|
||||
}
|
||||
addr, err := parseInfoAPIListen(cfg.listen)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.addr = addr
|
||||
|
||||
// read as a string so yaml can't reinterpret the octal literal
|
||||
modeStr := c.GetString("info_api.socket_mode", "0600")
|
||||
mode, err := strconv.ParseUint(modeStr, 8, 32)
|
||||
if err != nil || fs.FileMode(mode)&^fs.ModePerm != 0 {
|
||||
return cfg, fmt.Errorf("info_api.socket_mode was not a valid octal file mode: %s", modeStr)
|
||||
}
|
||||
cfg.socketMode = fs.FileMode(mode)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// parseInfoAPIListen extracts the unix socket path from the info_api.listen config value, which
|
||||
// must be a `unix://` URL with an absolute path, e.g. `unix:///var/run/nebula.sock`
|
||||
func parseInfoAPIListen(listen string) (addr string, err error) {
|
||||
path, ok := strings.CutPrefix(listen, "unix://")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("info_api.listen must be a unix:// socket path: %s", listen)
|
||||
} else if !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("info_api.listen unix socket path must be absolute: %s", listen)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/slackhq/nebula/cert_test"
|
||||
"github.com/slackhq/nebula/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_parseInfoAPIListen(t *testing.T) {
|
||||
type testCase struct {
|
||||
listen string
|
||||
addr string
|
||||
wantErr bool
|
||||
}
|
||||
tests := []testCase{
|
||||
{listen: "", wantErr: true},
|
||||
{listen: "unix://", wantErr: true},
|
||||
{listen: "unix://relative/path.sock", wantErr: true},
|
||||
{listen: "not an address", wantErr: true},
|
||||
// tcp host:port addresses are no longer accepted
|
||||
{listen: "127.0.0.1:8085", wantErr: true},
|
||||
{listen: "[::1]:8085", wantErr: true},
|
||||
{listen: "localhost:8085", wantErr: true},
|
||||
}
|
||||
|
||||
// A unix socket path must be absolute for the OS that will bind it, and filepath.IsAbs is
|
||||
// GOOS-specific. CI runs the suite separately on each OS, so assert the platform's own native
|
||||
// absolute path is accepted while the other platform's is rejected.
|
||||
posixPath := "unix:///var/run/nebula.sock"
|
||||
winPath := `unix://C:\nebula\hq.sock`
|
||||
if runtime.GOOS == "windows" {
|
||||
tests = append(tests,
|
||||
testCase{listen: winPath, addr: `C:\nebula\hq.sock`},
|
||||
testCase{listen: posixPath, wantErr: true},
|
||||
)
|
||||
} else {
|
||||
tests = append(tests,
|
||||
testCase{listen: posixPath, addr: "/var/run/nebula.sock"},
|
||||
testCase{listen: winPath, wantErr: true},
|
||||
)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
addr, err := parseInfoAPIListen(tt.listen)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err, "listen=%q", tt.listen)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err, "listen=%q", tt.listen)
|
||||
assert.Equal(t, tt.addr, addr, "listen=%q", tt.listen)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_loadInfoAPIConfig(t *testing.T) {
|
||||
c := config.NewC(nil)
|
||||
|
||||
// the listen path must be absolute for the OS running the test (CI is per-OS)
|
||||
listen, wantAddr := "unix:///tmp/hq.sock", "/tmp/hq.sock"
|
||||
if runtime.GOOS == "windows" {
|
||||
listen, wantAddr = `unix://C:\tmp\hq.sock`, `C:\tmp\hq.sock`
|
||||
}
|
||||
|
||||
// absent section means disabled, no error
|
||||
cfg, err := loadInfoAPIConfig(c)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.enabled)
|
||||
|
||||
// enabled without a listen address is an error
|
||||
setInfoAPIConfig(c, true, "", "")
|
||||
_, err = loadInfoAPIConfig(c)
|
||||
require.Error(t, err)
|
||||
|
||||
// a unix socket gets the default mode
|
||||
setInfoAPIConfig(c, true, listen, "")
|
||||
cfg, err = loadInfoAPIConfig(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantAddr, cfg.addr)
|
||||
assert.Equal(t, fs.FileMode(0o600), cfg.socketMode)
|
||||
|
||||
setInfoAPIConfig(c, true, listen, "0660")
|
||||
cfg, err = loadInfoAPIConfig(c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fs.FileMode(0o660), cfg.socketMode)
|
||||
|
||||
setInfoAPIConfig(c, true, listen, "withers")
|
||||
_, err = loadInfoAPIConfig(c)
|
||||
require.Error(t, err)
|
||||
|
||||
// mode bits beyond the permission bits are rejected
|
||||
setInfoAPIConfig(c, true, listen, "10600")
|
||||
_, err = loadInfoAPIConfig(c)
|
||||
require.Error(t, err)
|
||||
|
||||
// tcp host:port listen addresses are no longer supported
|
||||
setInfoAPIConfig(c, true, "127.0.0.1:8085", "")
|
||||
_, err = loadInfoAPIConfig(c)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestInfoAPIServer_badConfigIsNonFatal(t *testing.T) {
|
||||
// an enabled-but-invalid config must not stop construction; nebula keeps starting and the
|
||||
// feature simply stays disabled until a reload supplies a valid config
|
||||
c := config.NewC(nil)
|
||||
setInfoAPIConfig(c, true, "not-a-unix-socket", "")
|
||||
h := newInfoAPIServerFromConfig(context.Background(), slog.New(slog.DiscardHandler), nil, newHostMap(slog.New(slog.DiscardHandler)), c)
|
||||
require.NotNil(t, h)
|
||||
assert.False(t, h.enabled.Load())
|
||||
|
||||
// no config was recorded, so Start has nothing to bind and is a no-op
|
||||
h.runMu.Lock()
|
||||
assert.Nil(t, h.runCfg)
|
||||
h.runMu.Unlock()
|
||||
h.Start()
|
||||
h.runMu.Lock()
|
||||
assert.Nil(t, h.run)
|
||||
h.runMu.Unlock()
|
||||
}
|
||||
|
||||
func setInfoAPIConfig(c *config.C, enabled bool, listen, socketMode string) {
|
||||
settings := map[string]any{
|
||||
"enabled": enabled,
|
||||
"listen": listen,
|
||||
}
|
||||
if socketMode != "" {
|
||||
settings["socket_mode"] = socketMode
|
||||
}
|
||||
c.Settings["info_api"] = settings
|
||||
}
|
||||
|
||||
func newTestInfoAPIServer(t *testing.T) (*infoAPIServer, *config.C) {
|
||||
t.Helper()
|
||||
h := &infoAPIServer{
|
||||
l: slog.New(slog.DiscardHandler),
|
||||
ctx: context.Background(),
|
||||
hostMap: newHostMap(slog.New(slog.DiscardHandler)),
|
||||
}
|
||||
h.hostMap.preferredRanges.Store(&[]netip.Prefix{})
|
||||
return h, config.NewC(nil)
|
||||
}
|
||||
|
||||
// addTestPeer creates a certificate for a peer owning each addr (as a /24 or /64) and inserts it
|
||||
// into the hostmap as an established tunnel
|
||||
func addTestPeer(t *testing.T, hm *HostMap, name string, addrs []netip.Addr, unsafeNetworks []netip.Prefix, groups []string) cert.Certificate {
|
||||
t.Helper()
|
||||
networks := make([]netip.Prefix, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
bits := 24
|
||||
if a.Is6() {
|
||||
bits = 64
|
||||
}
|
||||
networks = append(networks, netip.PrefixFrom(a, bits))
|
||||
}
|
||||
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
|
||||
crt, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, name, time.Time{}, time.Time{}, networks, unsafeNetworks, groups)
|
||||
fp, err := crt.Fingerprint()
|
||||
require.NoError(t, err)
|
||||
|
||||
hm.unlockedAddHostInfo(&HostInfo{
|
||||
ConnectionState: &ConnectionState{
|
||||
peerCert: &cert.CachedCertificate{Certificate: crt, Fingerprint: fp},
|
||||
},
|
||||
vpnAddrs: addrs,
|
||||
relayState: RelayState{
|
||||
relayForByAddr: map[netip.Addr]*Relay{},
|
||||
relayForByIdx: map[uint32]*Relay{},
|
||||
},
|
||||
}, &Interface{})
|
||||
return crt
|
||||
}
|
||||
|
||||
func getHost(t *testing.T, h *infoAPIServer, addrParam string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/host?addr="+url.QueryEscape(addrParam), nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleHost(w, r)
|
||||
return decodeResponse(t, w)
|
||||
}
|
||||
|
||||
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) (int, map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
return w.Code, body
|
||||
}
|
||||
|
||||
func TestInfoAPIServer_handleHost(t *testing.T) {
|
||||
h, _ := newTestInfoAPIServer(t)
|
||||
h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
||||
|
||||
peerV4 := netip.MustParseAddr("10.0.0.99")
|
||||
peerV6 := netip.MustParseAddr("fd00::99")
|
||||
addTestPeer(t, h.hostMap, "laptop-alice", []netip.Addr{peerV4, peerV6},
|
||||
[]netip.Prefix{netip.MustParsePrefix("192.168.50.0/24")}, []string{"eng", "ssh"})
|
||||
addTestPeer(t, h.hostMap, "groupless", []netip.Addr{netip.MustParseAddr("10.0.0.77")}, nil, nil)
|
||||
|
||||
// an established peer comes back with its full identity
|
||||
code, body := getHost(t, h, "10.0.0.99")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, "laptop-alice", body["name"])
|
||||
assert.Equal(t, []any{"10.0.0.99", "fd00::99"}, body["vpnAddrs"])
|
||||
assert.Equal(t, []any{"10.0.0.99/24", "fd00::99/64"}, body["networks"])
|
||||
assert.Equal(t, []any{"192.168.50.0/24"}, body["unsafeNetworks"])
|
||||
assert.Equal(t, []any{"eng", "ssh"}, body["groups"])
|
||||
assert.NotEmpty(t, body["fingerprint"])
|
||||
assert.Equal(t, "2", fmt.Sprintf("%v", body["certVersion"]))
|
||||
assert.NotEmpty(t, body["notBefore"])
|
||||
assert.NotEmpty(t, body["notAfter"])
|
||||
|
||||
// empty cert slices marshal as [] rather than null
|
||||
code, body = getHost(t, h, "10.0.0.77")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.NotNil(t, body["groups"])
|
||||
assert.Empty(t, body["groups"])
|
||||
require.NotNil(t, body["unsafeNetworks"])
|
||||
assert.Empty(t, body["unsafeNetworks"])
|
||||
|
||||
// a port in addr is ignored so RemoteAddr can be passed through directly, including the
|
||||
// bracketed v6 and 4in6 forms
|
||||
for _, q := range []string{"10.0.0.99:54321", "[fd00::99]:443", "::ffff:10.0.0.99"} {
|
||||
code, body = getHost(t, h, q)
|
||||
require.Equal(t, http.StatusOK, code, "addr=%q", q)
|
||||
assert.Equal(t, "laptop-alice", body["name"], "addr=%q", q)
|
||||
}
|
||||
|
||||
// our own address answers from the local cert state
|
||||
code, body = getHost(t, h, "10.0.0.1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, "self", body["name"])
|
||||
|
||||
code, body = getHost(t, h, "10.0.0.42")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
assert.NotEmpty(t, body["error"])
|
||||
|
||||
// a tunnel mid-teardown (no peer cert) is treated as unknown
|
||||
h.hostMap.unlockedAddHostInfo(&HostInfo{
|
||||
ConnectionState: &ConnectionState{},
|
||||
vpnAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.66")},
|
||||
relayState: RelayState{
|
||||
relayForByAddr: map[netip.Addr]*Relay{},
|
||||
relayForByIdx: map[uint32]*Relay{},
|
||||
},
|
||||
}, &Interface{})
|
||||
code, _ = getHost(t, h, "10.0.0.66")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
|
||||
code, body = getHost(t, h, "not-an-address")
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
assert.NotEmpty(t, body["error"])
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/host", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleHost(w, r)
|
||||
code, body = decodeResponse(t, w)
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
assert.NotEmpty(t, body["error"])
|
||||
}
|
||||
|
||||
func TestInfoAPIServer_handleSelf(t *testing.T) {
|
||||
h, _ := newTestInfoAPIServer(t)
|
||||
h.pki = newTestPKI(t, "lighthouse", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/self", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSelf(w, r)
|
||||
code, body := decodeResponse(t, w)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, "lighthouse", body["name"])
|
||||
assert.Equal(t, []any{"10.0.0.1"}, body["vpnAddrs"])
|
||||
|
||||
// no cert state available should be an error, not a panic
|
||||
h.pki = nil
|
||||
w = httptest.NewRecorder()
|
||||
h.handleSelf(w, r)
|
||||
code, body = decodeResponse(t, w)
|
||||
assert.Equal(t, http.StatusInternalServerError, code)
|
||||
assert.NotEmpty(t, body["error"])
|
||||
}
|
||||
|
||||
func unixHTTPClient(path string) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", path)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// waitForServe polls until a GET /v1/self through client succeeds
|
||||
func waitForServe(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
waitFor(t, func() bool {
|
||||
resp, err := client.Get("http://hostquery/v1/self")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
})
|
||||
}
|
||||
|
||||
func skipIfNoUnixSockets(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix socket tests are not supported on windows CI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoAPIServer_unixLifecycle(t *testing.T) {
|
||||
skipIfNoUnixSockets(t)
|
||||
h, c := newTestInfoAPIServer(t)
|
||||
h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
||||
|
||||
sock := filepath.Join(t.TempDir(), "hq.sock")
|
||||
setInfoAPIConfig(c, true, "unix://"+sock, "")
|
||||
require.NoError(t, h.reload(c, true))
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.Start()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
client := unixHTTPClient(sock)
|
||||
waitForServe(t, client)
|
||||
|
||||
fi, err := os.Stat(sock)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fs.FileMode(0o600), fi.Mode().Perm())
|
||||
|
||||
resp, err := client.Get("http://hostquery/v1/host?addr=10.0.0.1")
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
h.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Start did not return after Stop")
|
||||
}
|
||||
_, err = os.Stat(sock)
|
||||
assert.True(t, os.IsNotExist(err), "socket file should be unlinked on shutdown")
|
||||
}
|
||||
|
||||
func TestInfoAPIServer_staleSocket(t *testing.T) {
|
||||
skipIfNoUnixSockets(t)
|
||||
h, _ := newTestInfoAPIServer(t)
|
||||
sock := filepath.Join(t.TempDir(), "hq.sock")
|
||||
|
||||
// simulate an unclean exit, a leftover socket file with no listener
|
||||
stale, err := net.ListenUnix("unix", &net.UnixAddr{Name: sock, Net: "unix"})
|
||||
require.NoError(t, err)
|
||||
stale.SetUnlinkOnClose(false)
|
||||
require.NoError(t, stale.Close())
|
||||
_, err = os.Stat(sock)
|
||||
require.NoError(t, err, "stale socket file should exist")
|
||||
|
||||
cfg := infoAPIConfig{addr: sock, socketMode: 0o600}
|
||||
ln, err := h.listen(cfg)
|
||||
require.NoError(t, err, "a stale socket should be removed and rebound")
|
||||
require.NoError(t, ln.Close())
|
||||
}
|
||||
|
||||
func TestInfoAPIServer_existingFileNotReplaced(t *testing.T) {
|
||||
skipIfNoUnixSockets(t)
|
||||
h, _ := newTestInfoAPIServer(t)
|
||||
path := filepath.Join(t.TempDir(), "hq.sock")
|
||||
require.NoError(t, os.WriteFile(path, []byte("precious"), 0o600))
|
||||
|
||||
cfg := infoAPIConfig{addr: path, socketMode: 0o600}
|
||||
_, err := h.listen(cfg)
|
||||
require.Error(t, err, "a non-socket file at the listen path must not be replaced")
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "precious", string(content))
|
||||
}
|
||||
|
||||
func TestInfoAPIServer_reload(t *testing.T) {
|
||||
skipIfNoUnixSockets(t)
|
||||
h, c := newTestInfoAPIServer(t)
|
||||
h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")})
|
||||
dir := t.TempDir()
|
||||
sock1 := filepath.Join(dir, "hq1.sock")
|
||||
sock2 := filepath.Join(dir, "hq2.sock")
|
||||
|
||||
// initial reload only records config, Control.Start is what launches the runtime
|
||||
setInfoAPIConfig(c, false, "unix://"+sock1, "")
|
||||
require.NoError(t, h.reload(c, true))
|
||||
assert.False(t, h.enabled.Load())
|
||||
h.runMu.Lock()
|
||||
assert.Nil(t, h.run)
|
||||
h.runMu.Unlock()
|
||||
|
||||
// enabling via reload spawns the listener
|
||||
setInfoAPIConfig(c, true, "unix://"+sock1, "")
|
||||
require.NoError(t, h.reload(c, false))
|
||||
waitForServe(t, unixHTTPClient(sock1))
|
||||
|
||||
// changing the listen path restarts on the new address
|
||||
setInfoAPIConfig(c, true, "unix://"+sock2, "")
|
||||
require.NoError(t, h.reload(c, false))
|
||||
waitForServe(t, unixHTTPClient(sock2))
|
||||
waitFor(t, func() bool {
|
||||
_, err := os.Stat(sock1)
|
||||
return os.IsNotExist(err)
|
||||
})
|
||||
|
||||
// reloading an unchanged config does not restart the runtime
|
||||
h.runMu.Lock()
|
||||
rt := h.run
|
||||
h.runMu.Unlock()
|
||||
require.NoError(t, h.reload(c, false))
|
||||
h.runMu.Lock()
|
||||
assert.Same(t, rt, h.run)
|
||||
h.runMu.Unlock()
|
||||
|
||||
// disabling stops the listener
|
||||
setInfoAPIConfig(c, false, "unix://"+sock2, "")
|
||||
require.NoError(t, h.reload(c, false))
|
||||
assert.False(t, h.enabled.Load())
|
||||
waitFor(t, func() bool {
|
||||
h.runMu.Lock()
|
||||
defer h.runMu.Unlock()
|
||||
return h.run == nil
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package nebula
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -239,9 +238,6 @@ func (f *Interface) activate() error {
|
||||
"build", f.version,
|
||||
"udpAddr", addr,
|
||||
"boringcrypto", boringEnabled(),
|
||||
"fips140Version", fips140.Version(),
|
||||
"fips140Enabled", fips140.Enabled(),
|
||||
"fips140Enforced", fips140.Enforced(),
|
||||
)
|
||||
|
||||
if f.routines > 1 {
|
||||
|
||||
@@ -260,6 +260,8 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
||||
return nil, util.ContextualizeIfNeeded("Failed to start stats emitter", err)
|
||||
}
|
||||
|
||||
infoAPI := newInfoAPIServerFromConfig(ctx, l, pki, hostMap, c)
|
||||
|
||||
if configTest {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -279,6 +281,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
||||
sshStart: sshStart,
|
||||
statsStart: stats.Start,
|
||||
dnsStart: ds.Start,
|
||||
infoAPIStart: infoAPI.Start,
|
||||
lighthouseStart: lightHouse.StartUpdateWorker,
|
||||
networkChangeStart: networkChanges.Start,
|
||||
connectionManagerStart: connManager.Start,
|
||||
|
||||
+65
-4
@@ -4,16 +4,77 @@
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"crypto/boring"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
|
||||
// unsafe needed for go:linkname
|
||||
_ "unsafe"
|
||||
|
||||
"github.com/flynn/noise"
|
||||
)
|
||||
|
||||
var CipherAESGCM noise.CipherFunc = CipherAESGCMFIPS140
|
||||
|
||||
// EncryptLockNeeded indicates if calls to Encrypt need a lock
|
||||
// This is true for boringcrypto because the Seal function verifies that the
|
||||
// nonce is strictly increasing.
|
||||
const EncryptLockNeeded = true
|
||||
|
||||
var boringEnabled = boring.Enabled()
|
||||
// NewGCMTLS is no longer exposed in go1.19+, so we need to link it in
|
||||
// See: https://github.com/golang/go/issues/56326
|
||||
//
|
||||
// NewGCMTLS is the internal method used with boringcrypto that provides a
|
||||
// validated mode of AES-GCM which enforces the nonce is strictly
|
||||
// monotonically increasing. This is the TLS 1.2 specification for nonce
|
||||
// generation (which also matches the method used by the Noise Protocol)
|
||||
//
|
||||
// - https://github.com/golang/go/blob/go1.19/src/crypto/tls/cipher_suites.go#L520-L522
|
||||
// - https://github.com/golang/go/blob/go1.19/src/crypto/internal/boring/aes.go#L235-L237
|
||||
// - https://github.com/golang/go/blob/go1.19/src/crypto/internal/boring/aes.go#L250
|
||||
// - https://github.com/google/boringssl/blob/ae223d6138807a13006342edfeef32e813246b39/include/openssl/aead.h#L379-L381
|
||||
// - https://github.com/google/boringssl/blob/ae223d6138807a13006342edfeef32e813246b39/crypto/fipsmodule/cipher/e_aes.c#L1082-L1093
|
||||
//
|
||||
//go:linkname newGCMTLS crypto/internal/boring.NewGCMTLS
|
||||
func newGCMTLS(c cipher.Block) (cipher.AEAD, error)
|
||||
|
||||
type cipherFn struct {
|
||||
fn func([32]byte) noise.Cipher
|
||||
name string
|
||||
}
|
||||
|
||||
func (c cipherFn) Cipher(k [32]byte) noise.Cipher { return c.fn(k) }
|
||||
func (c cipherFn) CipherName() string { return c.name }
|
||||
|
||||
// CipherAESGCM is the AES256-GCM AEAD cipher (using NewGCMTLS when GoBoring is present)
|
||||
var CipherAESGCM noise.CipherFunc = cipherFn{cipherAESGCMBoring, "AESGCM"}
|
||||
|
||||
func cipherAESGCMBoring(k [32]byte) noise.Cipher {
|
||||
c, err := aes.NewCipher(k[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
gcm, err := newGCMTLS(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return aeadCipher{
|
||||
gcm,
|
||||
func(n uint64) []byte {
|
||||
var nonce [12]byte
|
||||
binary.BigEndian.PutUint64(nonce[4:], n)
|
||||
return nonce[:]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type aeadCipher struct {
|
||||
cipher.AEAD
|
||||
nonce func(uint64) []byte
|
||||
}
|
||||
|
||||
func (c aeadCipher) Encrypt(out []byte, n uint64, ad, plaintext []byte) []byte {
|
||||
return c.Seal(out, c.nonce(n), plaintext, ad)
|
||||
}
|
||||
|
||||
func (c aeadCipher) Decrypt(out []byte, n uint64, ad, ciphertext []byte) ([]byte, error) {
|
||||
return c.Open(out, c.nonce(n), ciphertext, ad)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"crypto/boring"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -12,3 +14,33 @@ import (
|
||||
func TestEncryptLockNeeded(t *testing.T) {
|
||||
assert.True(t, EncryptLockNeeded)
|
||||
}
|
||||
|
||||
// Ensure NewGCMTLS validates the nonce is non-repeating
|
||||
func TestNewGCMTLS(t *testing.T) {
|
||||
assert.True(t, boring.Enabled())
|
||||
|
||||
// Test Case 16 from GCM Spec:
|
||||
// - (now dead link): http://csrc.nist.gov/groups/ST/toolkit/BCM/documents/proposedmodes/gcm/gcm-spec.pdf
|
||||
// - as listed in boringssl tests: https://github.com/google/boringssl/blob/fips-20220613/crypto/cipher_extra/test/cipher_tests.txt#L412-L418
|
||||
key, _ := hex.DecodeString("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308")
|
||||
iv, _ := hex.DecodeString("cafebabefacedbaddecaf888")
|
||||
plaintext, _ := hex.DecodeString("d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39")
|
||||
aad, _ := hex.DecodeString("feedfacedeadbeeffeedfacedeadbeefabaddad2")
|
||||
expected, _ := hex.DecodeString("522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662")
|
||||
expectedTag, _ := hex.DecodeString("76fc6ece0f4e1768cddf8853bb2d551b")
|
||||
|
||||
expected = append(expected, expectedTag...)
|
||||
|
||||
var keyArray [32]byte
|
||||
copy(keyArray[:], key)
|
||||
c := CipherAESGCM.Cipher(keyArray)
|
||||
aead := c.(aeadCipher).AEAD
|
||||
|
||||
dst := aead.Seal([]byte{}, iv, plaintext, aad)
|
||||
assert.Equal(t, expected, dst)
|
||||
|
||||
// We expect this to fail since we are re-encrypting with a repeat IV
|
||||
assert.PanicsWithError(t, "boringcrypto: EVP_AEAD_CTX_seal failed", func() {
|
||||
dst = aead.Seal([]byte{}, iv, plaintext, aad)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,11 +29,8 @@ type CipherState interface {
|
||||
// NewCipherState wraps the post-handshake noise.CipherState in the per-cipher type that matches cipherFunc.
|
||||
// cipherFunc must be the same cipher used to build the noise CipherSuite that produced s.
|
||||
func NewCipherState(s *noise.CipherState, cipherFunc noise.CipherFunc) CipherState {
|
||||
if cs, ok := s.Cipher().(CipherState); ok {
|
||||
return cs
|
||||
}
|
||||
switch cipherFunc.CipherName() {
|
||||
case noise.CipherAESGCM.CipherName():
|
||||
case CipherAESGCM.CipherName():
|
||||
return NewCipherStateAESGCM(s)
|
||||
case noise.CipherChaChaPoly.CipherName():
|
||||
return NewCipherStateChaChaPoly(s)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"crypto/fips140"
|
||||
"testing"
|
||||
|
||||
"github.com/flynn/noise"
|
||||
@@ -11,30 +10,24 @@ import (
|
||||
|
||||
func TestCipherStateAESGCMRoundtrip(t *testing.T) {
|
||||
enc, dec := buildCipherStates(t, CipherAESGCM)
|
||||
roundtrip(t, NewCipherState(enc, CipherAESGCM), NewCipherState(dec, CipherAESGCM))
|
||||
roundtrip(t, NewCipherStateAESGCM(enc), NewCipherStateAESGCM(dec))
|
||||
}
|
||||
|
||||
func TestCipherStateChaChaPolyRoundtrip(t *testing.T) {
|
||||
enc, dec := buildCipherStates(t, noise.CipherChaChaPoly)
|
||||
roundtrip(t, NewCipherState(enc, noise.CipherChaChaPoly), NewCipherState(dec, noise.CipherChaChaPoly))
|
||||
roundtrip(t, NewCipherStateChaChaPoly(enc), NewCipherStateChaChaPoly(dec))
|
||||
}
|
||||
|
||||
func TestNewCipherStateDispatch(t *testing.T) {
|
||||
encA, _ := buildCipherStates(t, CipherAESGCM)
|
||||
encC, _ := buildCipherStates(t, noise.CipherChaChaPoly)
|
||||
|
||||
if !boringEnabled && !fips140.Enabled() {
|
||||
assert.IsType(t, &CipherStateAESGCM{}, NewCipherState(encA, CipherAESGCM))
|
||||
} else {
|
||||
// fips140
|
||||
assert.IsType(t, encA.Cipher(), NewCipherState(encA, CipherAESGCM))
|
||||
}
|
||||
|
||||
assert.IsType(t, &CipherStateChaChaPoly{}, NewCipherState(encC, noise.CipherChaChaPoly))
|
||||
}
|
||||
|
||||
func TestNewCipherStateUnsupportedPanics(t *testing.T) {
|
||||
enc, _ := buildCipherStates(t, noise.CipherChaChaPoly)
|
||||
enc, _ := buildCipherStates(t, CipherAESGCM)
|
||||
assert.Panics(t, func() {
|
||||
NewCipherState(enc, fakeCipher{})
|
||||
})
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/cipher"
|
||||
"crypto/fips140"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
// unsafe needed for go:linkname
|
||||
_ "crypto/tls"
|
||||
_ "unsafe"
|
||||
|
||||
"github.com/flynn/noise"
|
||||
)
|
||||
|
||||
// TODO: Use NewGCMWithCounterNonce or NewGCMForQUIC once available:
|
||||
// - https://github.com/golang/go/issues/73110
|
||||
// - https://github.com/golang/go/issues/79219
|
||||
// Using tls.aeadAESGCMTLS13 gives us the TLS 1.3 GCM, which also verifies
|
||||
// that the nonce is strictly increasing. This works for both boringcrypto
|
||||
// and fips140.
|
||||
//
|
||||
//go:linkname aeadAESGCMTLS13 crypto/tls.aeadAESGCMTLS13
|
||||
func aeadAESGCMTLS13(key, noncePrefix []byte) cipher.AEAD
|
||||
|
||||
type cipherFn struct {
|
||||
fn func([32]byte) noise.Cipher
|
||||
name string
|
||||
}
|
||||
|
||||
func (c cipherFn) Cipher(k [32]byte) noise.Cipher { return c.fn(k) }
|
||||
func (c cipherFn) CipherName() string { return c.name }
|
||||
|
||||
// CipherAESGCMFIPS140 is the AES256-GCM AEAD cipher (using tls.aeadAESGCMTLS13, for both boringcrypto and fips140)
|
||||
var CipherAESGCMFIPS140 noise.CipherFunc = cipherFn{cipherAESGCMFIPS140, "AESGCM"}
|
||||
|
||||
// tls.aeadAESGCMTLS13 uses a 4 byte static prefix and an 8 byte XOR mask
|
||||
var emptyNonce = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
|
||||
func cipherAESGCMFIPS140(k [32]byte) noise.Cipher {
|
||||
gcm := aeadAESGCMTLS13(k[:], emptyNonce)
|
||||
gcm = extractFIPSAEAD(gcm)
|
||||
return &aeadGCMFIPS140Cipher{
|
||||
AEAD: gcm,
|
||||
}
|
||||
}
|
||||
|
||||
type aeadGCMFIPS140Cipher struct {
|
||||
cipher.AEAD
|
||||
ready bool
|
||||
}
|
||||
|
||||
// Extract the internal FIPS GCM implementation from the tls wrapper. The TLS
|
||||
// wrapper is not thread safe around Open, so instead of locking around it we
|
||||
// can grab the internal implementation that is thread safe. This is the FIPS
|
||||
// module implementation: `crypto/internal/fips140/aes/gcm.GCMWithXORCounterNonce`
|
||||
//
|
||||
// - https://github.com/golang/go/blob/go1.26.4/src/crypto/internal/fips140/aes/gcm/gcm_nonces.go#L212-L287
|
||||
//
|
||||
// The wrapper is struct `crypto/tls.xorNonceAEAD` , with field `aead`:
|
||||
//
|
||||
// - https://github.com/golang/go/blob/go1.26.4/src/crypto/tls/cipher_suites.go#L482-L487
|
||||
//
|
||||
// This can be cleaned up once these FIPS implementations are exposed directly:
|
||||
//
|
||||
// - https://github.com/golang/go/issues/73110
|
||||
func extractFIPSAEAD(xorNonceAEAD cipher.AEAD) cipher.AEAD {
|
||||
r := reflect.ValueOf(xorNonceAEAD)
|
||||
v := r.Elem().FieldByName("aead")
|
||||
if !v.IsValid() {
|
||||
// The internal crypto/tls.xorNonceAEAD struct no longer has an `aead`
|
||||
// field. This can only happen on a Go version this code was not built
|
||||
// against; the package init() self-test guards against ever reaching
|
||||
// this at runtime, so this is a defensive fail-fast.
|
||||
panic(fmt.Sprintf("noiseutil: could not extract FIPS AEAD from %T on %s: no `aead` field (incompatible Go version)", xorNonceAEAD, runtime.Version()))
|
||||
}
|
||||
v2 := reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem()
|
||||
aead, ok := v2.Interface().(cipher.AEAD)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("noiseutil: extracted FIPS `aead` field is %s, not a cipher.AEAD, on %s (incompatible Go version)", v2.Type(), runtime.Version()))
|
||||
}
|
||||
return aead
|
||||
}
|
||||
|
||||
func (c *aeadGCMFIPS140Cipher) init(nonce []byte) {
|
||||
// GCMWithXORCounterNonce expects that the first call to Seal
|
||||
// is with a counter of `0`, this is how it extracts the nonce mask.
|
||||
// We can clean this up in the future when NewGCMWithCounterNonce or
|
||||
// NewGCMForQUIC are available:
|
||||
if !bytes.Equal(emptyNonce, nonce) {
|
||||
c.AEAD.Seal([]byte{}, emptyNonce, []byte{}, []byte{})
|
||||
}
|
||||
c.ready = true
|
||||
}
|
||||
|
||||
func (c *aeadGCMFIPS140Cipher) Seal(dst, nonce, plaintext, additionalData []byte) []byte {
|
||||
if !c.ready {
|
||||
c.init(nonce)
|
||||
}
|
||||
return c.AEAD.Seal(dst, nonce, plaintext, additionalData)
|
||||
}
|
||||
|
||||
func (c *aeadGCMFIPS140Cipher) Encrypt(out []byte, n uint64, ad, plaintext []byte) []byte {
|
||||
return c.Seal(out, aeadGCMFIPS140CipherNonce(n), plaintext, ad)
|
||||
}
|
||||
|
||||
func (c *aeadGCMFIPS140Cipher) Decrypt(out []byte, n uint64, ad, ciphertext []byte) ([]byte, error) {
|
||||
return c.Open(out, aeadGCMFIPS140CipherNonce(n), ciphertext, ad)
|
||||
}
|
||||
|
||||
func (c *aeadGCMFIPS140Cipher) EncryptDanger(out, ad, plaintext []byte, n uint64, nb []byte) ([]byte, error) {
|
||||
binary.BigEndian.PutUint64(nb[4:], n)
|
||||
out = c.Seal(out, nb, plaintext, ad)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *aeadGCMFIPS140Cipher) DecryptDanger(out, ad, ciphertext []byte, n uint64, nb []byte) ([]byte, error) {
|
||||
binary.BigEndian.PutUint64(nb[4:], n)
|
||||
return c.Open(out, nb, ciphertext, ad)
|
||||
}
|
||||
|
||||
func aeadGCMFIPS140CipherNonce(n uint64) []byte {
|
||||
// GCMWithXORCounterNonce uses a 4 byte static prefix and an 8 byte nonce
|
||||
var nonce [12]byte
|
||||
binary.BigEndian.PutUint64(nonce[4:], n)
|
||||
return nonce[:]
|
||||
}
|
||||
|
||||
// init validates the go:linkname + reflection extraction and the nonce-reuse
|
||||
// protection at startup, in every build. cipherAESGCMFIPS140 relies on unexported
|
||||
// crypto/tls and crypto/internal/fips140 internals; if a future Go version changes
|
||||
// those, this fails fast with a clear message instead of panicking per-handshake
|
||||
// (or, worse, silently losing the strictly-increasing nonce check that is the whole
|
||||
// point of using this cipher). Because this file has no build tag, this self-test
|
||||
// runs even in non-FIPS builds, so the default CI lane catches an incompatible Go.
|
||||
func init() {
|
||||
var key [32]byte
|
||||
c := cipherAESGCMFIPS140(key)
|
||||
|
||||
// Verify the extracted AEAD produces a working encrypt/decrypt roundtrip.
|
||||
plaintext := []byte("nebula fips140 self-test")
|
||||
ad := []byte("ad")
|
||||
ct := c.Encrypt(nil, 1, ad, plaintext)
|
||||
pt, err := c.Decrypt(nil, 1, ad, ct)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("noiseutil: FIPS AES-GCM self-test roundtrip failed on %s: %v", runtime.Version(), err))
|
||||
}
|
||||
if !bytes.Equal(pt, plaintext) {
|
||||
panic(fmt.Sprintf("noiseutil: FIPS AES-GCM self-test roundtrip returned wrong plaintext on %s", runtime.Version()))
|
||||
}
|
||||
|
||||
// Verify the nonce-reuse protection still fires: re-encrypting with the same
|
||||
// counter must panic. This is the guarantee we depend on for nonce safety, so
|
||||
// if the extraction ever silently yields an AEAD without it, refuse to start.
|
||||
// The strictly-increasing nonce check only exists under boringcrypto/fips140;
|
||||
// in a plain build aeadAESGCMTLS13 wraps a standard GCM that does not enforce
|
||||
// it (and CipherAESGCMFIPS140 is unused there anyway), so only assert it when
|
||||
// one of those modes is active.
|
||||
if (boringEnabled || fips140.Enabled()) && !reusePanics(c) {
|
||||
panic(fmt.Sprintf("noiseutil: FIPS AES-GCM self-test did not reject a reused nonce on %s; nonce-reuse protection is missing (incompatible Go version)", runtime.Version()))
|
||||
}
|
||||
}
|
||||
|
||||
// reusePanics reports whether re-encrypting with an already-used counter panics,
|
||||
// as GCMWithXORCounterNonce is expected to.
|
||||
func reusePanics(c noise.Cipher) (panicked bool) {
|
||||
c.Encrypt(nil, 2, nil, nil)
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
panicked = true
|
||||
}
|
||||
}()
|
||||
c.Encrypt(nil, 2, nil, nil)
|
||||
return false
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"crypto/fips140"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Ensure NewAESGCM validates the nonce is non-repeating
|
||||
func TestNewAESGCM(t *testing.T) {
|
||||
if !boringEnabled && !fips140.Enabled() {
|
||||
t.Skip("TestNewAESGCM is only for fips140/boringcrypto")
|
||||
}
|
||||
|
||||
key, _ := hex.DecodeString("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308")
|
||||
iv, _ := hex.DecodeString("00000000facedbaddecaf888")
|
||||
plaintext, _ := hex.DecodeString("d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39")
|
||||
aad, _ := hex.DecodeString("feedfacedeadbeeffeedfacedeadbeefabaddad2")
|
||||
expected, _ := hex.DecodeString("6a65c2edd45bd63c7e29f40e3d2ed8ba2b99f4c83135383d5676652f255059ceb24863ff10afb1089db701245da87fb88d3acd5f9dd0770cac220c3c04145caf25e190aeb775e7080401c628")
|
||||
|
||||
var keyArray [32]byte
|
||||
copy(keyArray[:], key)
|
||||
c := CipherAESGCM.Cipher(keyArray)
|
||||
aead := c.(cipher.AEAD)
|
||||
|
||||
dst := aead.Seal([]byte{}, iv, plaintext, aad)
|
||||
t.Logf("%x", dst)
|
||||
assert.Equal(t, expected, dst)
|
||||
|
||||
// We expect this to fail since we are re-encrypting with a repeat IV
|
||||
switch {
|
||||
case boringEnabled:
|
||||
assert.PanicsWithError(t, "boringcrypto: EVP_AEAD_CTX_seal failed", func() {
|
||||
dst = aead.Seal([]byte{}, iv, plaintext, aad)
|
||||
})
|
||||
case fips140.Version() == "v1.0.0":
|
||||
assert.PanicsWithValue(t, "crypto/cipher: counter decreased", func() {
|
||||
dst = aead.Seal([]byte{}, iv, plaintext, aad)
|
||||
})
|
||||
default:
|
||||
assert.PanicsWithValue(t, "crypto/cipher: counter decreased or remained the same", func() {
|
||||
dst = aead.Seal([]byte{}, iv, plaintext, aad)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build fips140enforce
|
||||
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"crypto/fips140"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if !fips140.Enforced() {
|
||||
panic("Nebula compiled with fips140 expects FIPS140 to be enforced. Do not set GODEBUG=fips140, or if you do it must be set as GODEBUG=fips140=only")
|
||||
}
|
||||
}
|
||||
+4
-15
@@ -1,25 +1,14 @@
|
||||
//go:build !boringcrypto
|
||||
// +build !boringcrypto
|
||||
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"crypto/fips140"
|
||||
|
||||
"github.com/flynn/noise"
|
||||
)
|
||||
|
||||
// EncryptLockNeeded indicates if calls to Encrypt need a lock
|
||||
var EncryptLockNeeded = fips140.Enabled()
|
||||
const EncryptLockNeeded = false
|
||||
|
||||
var CipherAESGCM noise.CipherFunc = initAESGCM()
|
||||
|
||||
func initAESGCM() noise.CipherFunc {
|
||||
if fips140.Enabled() {
|
||||
return CipherAESGCMFIPS140
|
||||
} else {
|
||||
return noise.CipherAESGCM
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var boringEnabled = false
|
||||
// CipherAESGCM is the standard noise.CipherAESGCM when boringcrypto is not enabled
|
||||
var CipherAESGCM noise.CipherFunc = noise.CipherAESGCM
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !boringcrypto
|
||||
// +build !boringcrypto
|
||||
|
||||
package noiseutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEncryptLockNeeded(t *testing.T) {
|
||||
assert.False(t, EncryptLockNeeded)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"crypto/fips140"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -238,17 +237,10 @@ func (cs *CertState) getCertificate(v cert.Version) cert.Certificate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newCipherSuite builds the noise.CipherSuite for the given curve and cipher.
|
||||
// When fips140Enforced is true (FIPS 140-only mode), non-approved algorithms
|
||||
// (Curve25519 and ChaChaPoly) are rejected with an error. Callers pass
|
||||
// fips140.Enforced() for fips140Enforced.
|
||||
func newCipherSuite(curve cert.Curve, pkcs11backed bool, cipher string, fips140Enforced bool) (noise.CipherSuite, error) {
|
||||
func newCipherSuite(curve cert.Curve, pkcs11backed bool, cipher string) (noise.CipherSuite, error) {
|
||||
var dhFunc noise.DHFunc
|
||||
switch curve {
|
||||
case cert.Curve_CURVE25519:
|
||||
if fips140Enforced {
|
||||
return nil, errors.New("pki: use of Curve25519 is not allowed in FIPS 140-only mode")
|
||||
}
|
||||
dhFunc = noise.DH25519
|
||||
case cert.Curve_P256:
|
||||
if pkcs11backed {
|
||||
@@ -261,9 +253,6 @@ func newCipherSuite(curve cert.Curve, pkcs11backed bool, cipher string, fips140E
|
||||
}
|
||||
|
||||
if cipher == "chachapoly" {
|
||||
if fips140Enforced {
|
||||
return nil, errors.New("pki: use of ChaChaPoly is not allowed in FIPS 140-only mode")
|
||||
}
|
||||
return noise.NewCipherSuite(dhFunc, noise.CipherChaChaPoly, noise.HashSHA256), nil
|
||||
}
|
||||
return noise.NewCipherSuite(dhFunc, noiseutil.CipherAESGCM, noise.HashSHA256), nil
|
||||
@@ -337,10 +326,6 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fips140.Enforced() && crt.Curve() != cert.Curve_P256 {
|
||||
return nil, fmt.Errorf("pki: use of %s is not allowed in FIPS 140-only mode", crt.Curve())
|
||||
}
|
||||
|
||||
switch crt.Version() {
|
||||
case cert.Version1:
|
||||
if v1 != nil {
|
||||
@@ -420,7 +405,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p
|
||||
//NOTE: We do not currently have a method to verify a public private key pair when the private key is in an hsm
|
||||
} else {
|
||||
if err := v1.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil {
|
||||
return nil, fmt.Errorf("private key is not a pair with public key in nebula cert: %w", err)
|
||||
return nil, fmt.Errorf("private key is not a pair with public key in nebula cert")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,7 +413,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling v1 certificate for handshake: %w", err)
|
||||
}
|
||||
ncs, err := newCipherSuite(v1.Curve(), pkcs11backed, cipher, fips140.Enforced())
|
||||
ncs, err := newCipherSuite(v1.Curve(), pkcs11backed, cipher)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -445,7 +430,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p
|
||||
//NOTE: We do not currently have a method to verify a public private key pair when the private key is in an hsm
|
||||
} else {
|
||||
if err := v2.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil {
|
||||
return nil, fmt.Errorf("private key is not a pair with public key in nebula cert: %w", err)
|
||||
return nil, fmt.Errorf("private key is not a pair with public key in nebula cert")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +438,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling v2 certificate for handshake: %w", err)
|
||||
}
|
||||
ncs, err := newCipherSuite(v2.Curve(), pkcs11backed, cipher, fips140.Enforced())
|
||||
ncs, err := newCipherSuite(v2.Curve(), pkcs11backed, cipher)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package nebula
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/slackhq/nebula/cert"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCipherSuite(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
curve cert.Curve
|
||||
cipher string
|
||||
fips140Enforced bool
|
||||
wantErr string
|
||||
// wantName is the full expected CipherSuite name (<DH>_<Cipher>_<Hash>),
|
||||
// only checked when wantErr is empty. Asserting the whole name makes both
|
||||
// the curve and cipher selection load-bearing.
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "curve25519 aesgcm, not enforced",
|
||||
curve: cert.Curve_CURVE25519,
|
||||
cipher: "aesgcm",
|
||||
wantName: "25519_AESGCM_SHA256",
|
||||
},
|
||||
{
|
||||
name: "curve25519 chachapoly, not enforced",
|
||||
curve: cert.Curve_CURVE25519,
|
||||
cipher: "chachapoly",
|
||||
wantName: "25519_ChaChaPoly_SHA256",
|
||||
},
|
||||
{
|
||||
name: "p256 aesgcm, not enforced",
|
||||
curve: cert.Curve_P256,
|
||||
cipher: "aesgcm",
|
||||
wantName: "P256_AESGCM_SHA256",
|
||||
},
|
||||
{
|
||||
name: "p256 aesgcm, enforced is allowed",
|
||||
curve: cert.Curve_P256,
|
||||
cipher: "aesgcm",
|
||||
fips140Enforced: true,
|
||||
wantName: "P256_AESGCM_SHA256",
|
||||
},
|
||||
{
|
||||
name: "curve25519 rejected when enforced",
|
||||
curve: cert.Curve_CURVE25519,
|
||||
cipher: "aesgcm",
|
||||
fips140Enforced: true,
|
||||
wantErr: "pki: use of Curve25519 is not allowed in FIPS 140-only mode",
|
||||
},
|
||||
{
|
||||
name: "chachapoly rejected when enforced",
|
||||
curve: cert.Curve_P256,
|
||||
cipher: "chachapoly",
|
||||
fips140Enforced: true,
|
||||
wantErr: "pki: use of ChaChaPoly is not allowed in FIPS 140-only mode",
|
||||
},
|
||||
{
|
||||
// Curve is checked before cipher, so a Curve25519+ChaChaPoly
|
||||
// request reports the Curve25519 rejection.
|
||||
name: "curve25519 chachapoly rejected on curve when enforced",
|
||||
curve: cert.Curve_CURVE25519,
|
||||
cipher: "chachapoly",
|
||||
fips140Enforced: true,
|
||||
wantErr: "pki: use of Curve25519 is not allowed in FIPS 140-only mode",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs, err := newCipherSuite(tt.curve, false, tt.cipher, tt.fips140Enforced)
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
assert.Nil(t, cs)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cs)
|
||||
assert.Equal(t, tt.wantName, string(cs.Name()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCipherSuiteUnsupportedCurve(t *testing.T) {
|
||||
cs, err := newCipherSuite(cert.Curve(99), false, "aesgcm", false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.HasPrefix(err.Error(), "unsupported curve:"), "got: %v", err)
|
||||
assert.Nil(t, cs)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package nebula
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -292,9 +291,6 @@ func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
|
||||
"version": s.buildVersion,
|
||||
"goversion": runtime.Version(),
|
||||
"boringcrypto": strconv.FormatBool(boringEnabled()),
|
||||
"fips140Version": fips140.Version(),
|
||||
"fips140Enabled": strconv.FormatBool(fips140.Enabled()),
|
||||
"fips140Enforced": strconv.FormatBool(fips140.Enforced()),
|
||||
},
|
||||
})
|
||||
pr.MustRegister(g)
|
||||
|
||||
Reference in New Issue
Block a user