diff --git a/.github/workflows/Release.yaml b/.github/workflows/Release.yaml index 2875567..84f8def 100644 --- a/.github/workflows/Release.yaml +++ b/.github/workflows/Release.yaml @@ -29,12 +29,12 @@ jobs: steps: - name: Install dependencies run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu - + - name: Set up latest stable Go uses: actions/setup-go@v6 with: go-version: stable - + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -59,7 +59,7 @@ jobs: echo "GO_VERSION=$(go version | awk '{print $3}')" >> $GITHUB_ENV echo "BUILD_USER=$(whoami)" >> $GITHUB_ENV echo "CGO_ENABLED=1" >> $GITHUB_ENV - + if [[ "${{ github.event_name }}" == "pull_request" ]]; then echo "IS_PR_BUILD=true" >> $GITHUB_ENV echo "DOCKER_TAG=pr-${{ github.event.number }}" >> $GITHUB_ENV @@ -137,4 +137,4 @@ jobs: with: subject-name: ghcr.io/openchami/coresmd subject-digest: ${{ steps.process_goreleaser_output.outputs.digest }} - push-to-registry: true \ No newline at end of file + push-to-registry: true diff --git a/.golangci.yml b/.golangci.yml index 701db61..a750881 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -32,4 +32,4 @@ formatters: local-prefixes: - github.com/openchami/coresmd/ exclusions: - generated: lax \ No newline at end of file + generated: lax diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fb7792a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Copyright © 2025 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: [--allow-multiple-documents] + - id: check-added-large-files + +- repo: https://github.com/fsfe/reuse-tool + rev: v6.2.0 + hooks: + - id: reuse-lint-file + +- repo: https://github.com/tekwizely/pre-commit-golang + # See 'pre-commit help autoupdate' + rev: v1.0.0-rc.4 + hooks: + + # Disabled go-mod-tidy in favor of custom hook below that properly excludes examples + # - id: go-mod-tidy + # exclude: '^(test/integration|examples)/' + # Disabled go-test-mod in favor of custom hook below that excludes examples + # - id: go-test-mod + # exclude: '^(test/integration|examples)/' + # Disabled go-vet-mod in favor of custom hook below that excludes examples + # - id: go-vet-mod + # exclude: '^(test/integration|examples)/' + + # + # Formatters + # + - id: go-fmt + - id: go-fmt-repo + + # + # Style Checkers + # Disabled golangci-lint-mod in favor of custom hook below that excludes examples + # - id: golangci-lint-mod + # exclude: '^(test/integration|examples)/' + +# Custom local hooks +- repo: local + hooks: + - id: go-mod-tidy-exclude-examples + name: go mod tidy (excluding examples) + # Temporarily rename examples to hide it from go mod tidy, then restore it + entry: bash -c 'if [ -d examples ]; then mv examples .examples.tmp && trap "rm -rf examples; mv .examples.tmp examples" EXIT; go mod tidy; else go mod tidy; fi' + language: system + pass_filenames: false + types: [go] + files: '^(go\.(mod|sum)|cmd/|pkg/|internal/).*' + + - id: go-vet-mod-exclude-examples + name: go vet (excluding examples) + # Temporarily rename examples to hide it from go vet, then restore it + entry: bash -c 'if [ -d examples ]; then mv examples .examples.tmp && trap "rm -rf examples; mv .examples.tmp examples" EXIT; go vet -mod=mod ./...; else go vet -mod=mod ./...; fi' + language: system + pass_filenames: false + types: [go] + files: '^(cmd/|pkg/|internal/).*\.go$' + + - id: golangci-lint-mod-exclude-examples + name: golangci-lint (excluding examples) + # Temporarily rename examples to hide it from golangci-lint, then restore it + entry: bash -c 'if [ -d examples ]; then mv examples .examples.tmp && trap "rm -rf examples; mv .examples.tmp examples" EXIT; golangci-lint run --fix=false; else golangci-lint run --fix=false; fi' + language: system + pass_filenames: false + types: [go] + files: '^(cmd/|pkg/|internal/).*\.go$' + + - id: go-test-mod-exclude-examples + name: go test (excluding examples and integration tests) + entry: bash -c 'go test -mod=readonly -race $(go list ./... 2>/dev/null | grep -v -e "/examples/" -e "/test/integration")' + language: system + pass_filenames: false + types: [go] + + # diff --git a/Dockerfile b/Dockerfile index 972075b..35aa4a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ RUN set -ex \ # Both coredns and coredhcp are built and added to the same container. -# By default, coredhcp is started and coredns is not. To start coredns, override the CMD in the +# By default, coredhcp is started and coredns is not. To start coredns, override the CMD in the # container runtime configuration and provide a volume with the appropriate configuration file. COPY coredhcp /coredhcp COPY coredns /coredns diff --git a/README.md b/README.md index 9c2b405..ed9659c 100644 --- a/README.md +++ b/README.md @@ -401,4 +401,3 @@ Once all prerequisites are set, you can run CoreDHCP or CoreDNS. - [SMD GitHub](https://github.com/OpenCHAMI/smd) - [GoReleaser Documentation](https://goreleaser.com/install/) - [Magellan (OpenCHAMI)](https://github.com/OpenCHAMI/magellan) - diff --git a/examples/coredhcp/README.md b/examples/coredhcp/README.md index f8a61c1..67a90cd 100644 --- a/examples/coredhcp/README.md +++ b/examples/coredhcp/README.md @@ -105,6 +105,50 @@ plugins: See [coredhcp.yaml](coredhcp.yaml) for a complete example showing both DHCPv4 and DHCPv6 configurations. +## TokenSmith Auth for SMD Requests + +The CoreDHCP `coresmd` plugin can authenticate outbound SMD API requests using +TokenSmith service tokens. + +Configure auth in the `coresmd` key-value block: + +- `auth_mode={disabled|optional|required}` + - `disabled` (default): no auth + - `optional`: try auth, continue unauthenticated if bootstrap exchange fails + - `required`: fail startup if bootstrap exchange fails +- `tokensmith_url=https://tokensmith.cluster.local` + - required for `optional` and `required` +- `refresh_before=2m` + - optional lead time before token expiry for proactive refresh + +Set the bootstrap token in the environment for the CoreDHCP process: + +```bash +export TOKENSMITH_BOOTSTRAP_TOKEN="" +``` + +Example: + +```yaml +plugins: + - coresmd: | + svc_base_uri=https://smd.openchami.cluster + ipxe_base_uri=http://172.16.0.253:8081 + ca_cert=/root_ca/root_ca.crt + cache_valid=30s + + auth_mode=required + tokensmith_url=https://tokensmith.cluster.local + refresh_before=90s + + rule=type:Node,hostname:nid{04d} + rule=type:NodeBMC,hostname:bmc{04d} + domain=openchami.cluster +``` + +`target_service` and `scopes` are intentionally omitted from plugin config. +TokenSmith derives both from bootstrap token claims. + ## Custom Hostnames Hostname patterns can be used to specify custom hostnames for nodes and BMCs. See [**hostnames.md**](hostnames.md) for more details. diff --git a/examples/coredhcp/rules.md b/examples/coredhcp/rules.md index 55f2fc0..2cc5c3a 100644 --- a/examples/coredhcp/rules.md +++ b/examples/coredhcp/rules.md @@ -117,6 +117,37 @@ A rule may set: - `routers` (DHCPv4 option 3) - `netmask` (DHCPv4 option 1) +### `auth_mode={disabled|optional|required}` + +Controls outbound SMD authentication behavior using TokenSmith-issued service +tokens. + +- `disabled` (default): do not attempt auth +- `optional`: attempt auth at startup, continue unauthenticated if exchange fails +- `required`: fail startup if exchange fails + +### `tokensmith_url=URL` + +TokenSmith base URL used for bootstrap token exchange. + +Required when `auth_mode` is `optional` or `required`. + +### `refresh_before=DURATION` + +Optional lead time before token expiration to refresh proactively. + +**Default:** `2m` + +Uses Go duration syntax (for example `30s`, `90s`, `2m`, `5m`). + +### Token Bootstrap Environment + +Set `TOKENSMITH_BOOTSTRAP_TOKEN` in the CoreDHCP process environment. The +plugin reads this value at startup. + +`target_service` and `scopes` are intentionally not configured in CoreDHCP. +TokenSmith reads them from bootstrap token claims. + ## Migrating from `*_pattern` Older CoreSMD configurations used legacy pattern directives (for example diff --git a/examples/coredns/README.md b/examples/coredns/README.md index 18c1721..58b0805 100644 --- a/examples/coredns/README.md +++ b/examples/coredns/README.md @@ -96,6 +96,41 @@ coresmd { } ``` +### Enabling TokenSmith Auth for SMD Requests + +The CoreDNS `coresmd` plugin can authenticate outbound SMD API requests using +TokenSmith-issued service tokens. + +Auth directives in the `coresmd` block: + +- `auth_mode`: `disabled` (default), `optional`, or `required` +- `tokensmith_url`: required when `auth_mode` is `optional` or `required` +- `refresh_before`: optional token refresh lead time (default `2m`) + +Set the bootstrap token in the environment before starting CoreDNS: + +```bash +export TOKENSMITH_BOOTSTRAP_TOKEN="" +``` + +Example Corefile snippet: + +```corefile +coresmd { + smd_url https://smd.cluster.local + auth_mode optional + tokensmith_url https://tokensmith.cluster.local + refresh_before 2m + + zone openchami.cluster { + nodes nid{04d} + } +} +``` + +`target_service` and `scopes` are not configured in the plugin. TokenSmith +reads those constraints from the bootstrap token claims. + ## Testing ### Test DNS Resolution diff --git a/examples/coredns/advanced/Corefile b/examples/coredns/advanced/Corefile index a566327..3914ca3 100644 --- a/examples/coredns/advanced/Corefile +++ b/examples/coredns/advanced/Corefile @@ -5,7 +5,7 @@ # Advanced CoreSMD CoreDNS Configuration # This configuration provides DNS resolution with custom zones and TLS support -# +# # This example demonstrates advanced features including: # - TLS certificate validation for secure SMD communication # - Multiple zone configurations for different domains @@ -18,31 +18,31 @@ coresmd { # URL of the SMD server that provides component information smd_url https://smd.cluster.local - + # CA certificate for validating SMD server TLS certificate # Required for secure communication in production environments ca_cert /etc/ssl/certs/smd-ca.crt - + # Extended cache duration for production (60 seconds) # Longer cache reduces SMD server load but may delay updates cache_duration 60s - + # Primary zone configuration for cluster.local domain zone cluster.local { # Pattern for compute node hostnames: nid0001, nid0002, etc. nodes nid{04d} } - + # Secondary zone configuration for management network zone mgmt.local { # Pattern for management node hostnames: mgmt0001, mgmt0002, etc. nodes mgmt{04d} } } - + # Prometheus metrics endpoint for monitoring and alerting prometheus 0.0.0.0:9153 - + # Forward all other queries to Google's DNS servers forward . 8.8.8.8 -} \ No newline at end of file +} diff --git a/examples/coredns/basic/Corefile b/examples/coredns/basic/Corefile index 7ddc253..88cce08 100644 --- a/examples/coredns/basic/Corefile +++ b/examples/coredns/basic/Corefile @@ -5,7 +5,7 @@ # Basic CoreSMD CoreDNS Configuration # This configuration provides DNS resolution for OpenCHAMI cluster components -# +# # The coresmd plugin integrates with the OpenCHAMI SMD (State Management Database) # to provide dynamic DNS resolution for compute nodes and BMCs (Baseboard Management Controllers) # based on their hardware addresses and component information. @@ -16,20 +16,20 @@ coresmd { # URL of the SMD server that provides component information smd_url https://demo.openchami.cluster:8443 - + # How long to cache SMD data before refreshing (30 seconds) cache_duration 30s - + # Zone configuration for openchami.cluster domain zone openchami.cluster { # Pattern for node hostnames: nid0001, nid0002, etc. nodes nid{04d} } } - + # Prometheus metrics endpoint for monitoring prometheus 0.0.0.0:9153 - + # Forward all other queries to Google's DNS servers forward . 8.8.8.8 -} \ No newline at end of file +} diff --git a/examples/coredns/kubernetes/coredns-configmap.yaml b/examples/coredns/kubernetes/coredns-configmap.yaml index 7bb8c23..a8d06d3 100644 --- a/examples/coredns/kubernetes/coredns-configmap.yaml +++ b/examples/coredns/kubernetes/coredns-configmap.yaml @@ -13,14 +13,14 @@ data: # CoreSMD CoreDNS Configuration for Kubernetes # This configuration provides DNS resolution for OpenCHAMI cluster components # within a Kubernetes environment - # + # # The coresmd plugin integrates with the OpenCHAMI SMD (State Management Database) # to provide dynamic DNS resolution for compute nodes and BMCs based on their # hardware addresses and component information stored in SMD. - # + # # In Kubernetes, this ConfigMap is mounted into the CoreDNS container and # provides the DNS configuration for the cluster's internal DNS service. - + # Main DNS server block - handles all queries . { # CoreSMD plugin configuration @@ -28,26 +28,26 @@ data: # URL of the SMD server that provides component information # In Kubernetes, this should be accessible from within the cluster smd_url https://smd.cluster.local - + # CA certificate for validating SMD server TLS certificate # This certificate should be mounted as a volume in the CoreDNS deployment ca_cert /etc/ssl/certs/smd-ca.crt - + # Cache duration for SMD data (60 seconds) # Longer cache reduces SMD server load in production environments cache_duration 60s - + # Zone configuration for cluster.local domain zone cluster.local { # Pattern for compute node hostnames: nid0001, nid0002, etc. nodes nid{04d} } } - + # Prometheus metrics endpoint for monitoring # Accessible at :9153/metrics for monitoring and alerting prometheus 0.0.0.0:9153 - + # Forward all other queries to upstream DNS servers forward . 8.8.8.8 - } \ No newline at end of file + } diff --git a/examples/coredns/kubernetes/coredns-deployment.yaml b/examples/coredns/kubernetes/coredns-deployment.yaml index 6fe94d2..0383da9 100644 --- a/examples/coredns/kubernetes/coredns-deployment.yaml +++ b/examples/coredns/kubernetes/coredns-deployment.yaml @@ -67,6 +67,7 @@ spec: - name: certs-volume secret: secretName: smd-certs + --- apiVersion: v1 kind: Service @@ -88,4 +89,4 @@ spec: - name: metrics port: 9153 protocol: TCP - type: ClusterIP \ No newline at end of file + type: ClusterIP diff --git a/generator/coredhcp/plugins.txt b/generator/coredhcp/plugins.txt index 1f29098..f96e6bb 100644 --- a/generator/coredhcp/plugins.txt +++ b/generator/coredhcp/plugins.txt @@ -12,4 +12,4 @@ github.com/coredhcp/coredhcp/plugins/router github.com/coredhcp/coredhcp/plugins/serverid github.com/coredhcp/coredhcp/plugins/searchdomains github.com/coredhcp/coredhcp/plugins/sleep -github.com/coredhcp/coredhcp/plugins/staticroute \ No newline at end of file +github.com/coredhcp/coredhcp/plugins/staticroute diff --git a/go.mod b/go.mod index 4209760..d0cc129 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,11 @@ require ( github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 github.com/mattn/go-sqlite3 v1.14.32 github.com/miekg/dns v1.1.72 + github.com/openchami/tokensmith v0.3.1-0.20260408211730-d305fa0bedb3 github.com/ori-edge/k8s_gateway v0.4.0 github.com/pin/tftp/v3 v3.1.0 github.com/prometheus/client_golang v1.23.2 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/pflag v1.0.10 ) require ( @@ -35,21 +35,22 @@ require ( github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect @@ -58,6 +59,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nginxinc/kubernetes-ingress v1.12.5 // indirect github.com/nxadm/tail v1.4.11 // indirect + github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect @@ -69,10 +71,12 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.20.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect diff --git a/go.sum b/go.sum index 46b15d2..6154d33 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,7 @@ github.com/coredns/coredns v1.14.2 h1:L6ECLwm4Fjg7NJamtGBFDBjk/uqvCJjfgAEhrxh5zJ github.com/coredns/coredns v1.14.2/go.mod h1:nuO2VVHVluZ6xzPv0dhcNc2h9roH5WxXhtuQH2TkQBc= github.com/coredns/rrl v0.0.0-20250915113509-ac1135e077ba h1:wZbxUTEiqBJLTFrGoF+0iQyv5eiERaTiHg2OUHre3jI= github.com/coredns/rrl v0.0.0-20250915113509-ac1135e077ba/go.mod h1:UDRRaESggHxuJkZjDGfXXTZEAwmocAoWuwXlkE7PWRw= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,6 +39,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -55,6 +58,9 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -71,8 +77,6 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= -github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -84,8 +88,6 @@ github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167 h1:MEufgJohwIjFi github.com/insomniacslk/dhcp v0.0.0-20251020182700-175e84fbb167/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= -github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -101,19 +103,17 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= -github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= -github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= -github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= @@ -137,6 +137,10 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 h1:Gzt5f6RK39CHvY3SJudzBb/RK4tVh/S3CpJ0eQlbNdg= +github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700/go.mod h1:UuXvr2loD4MtvZeKr57W0WpBs+gm0KM1kdtcXrE8M6s= +github.com/openchami/tokensmith v0.3.1-0.20260408211730-d305fa0bedb3 h1:nJaBWyKECFs6ZEfEsXOszc9M2PITq+n35vAjJcMYY5U= +github.com/openchami/tokensmith v0.3.1-0.20260408211730-d305fa0bedb3/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/ori-edge/k8s_gateway v0.4.0 h1:kINsea+AcuTB4Jw74SaHHYBKZ35N2ENCUF1gFItJvTc= @@ -149,6 +153,7 @@ github.com/pin/tftp/v3 v3.1.0 h1:rQaxd4pGwcAJnpId8zC+O2NX3B2/NscjDZQaqEjuE7c= github.com/pin/tftp/v3 v3.1.0/go.mod h1:xwQaN4viYL019tM4i8iecm++5cGxSqen6AJEOEyEI0w= github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -170,6 +175,9 @@ github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 h1:mZHayPoR0lNmnH github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5HgEKCvEIIrSpFI3ozzG5xOKA2DVlEX/gGnewM= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -232,7 +240,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= @@ -255,6 +262,7 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= @@ -265,7 +273,6 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..3bc0a32 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +// Package auth provides optional TokenSmith-backed service authentication for +// outbound SMD requests made by the CoreDNS and CoreDHCP coresmd plugins. +// +// Three modes control behaviour: +// +// - disabled (default) – no authentication is attempted; existing behaviour +// is fully preserved. +// - optional – a bootstrap token exchange is attempted at startup; if it +// fails the plugin continues unauthenticated and logs the failure. +// - required – startup fails if the bootstrap token exchange fails. +// +// The bootstrap token is never stored in plugin config files; it must be +// supplied via the TOKENSMITH_BOOTSTRAP_TOKEN environment variable. +package auth + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/openchami/tokensmith/pkg/tokenservice" + "github.com/sirupsen/logrus" +) + +// BootstrapTokenEnvVar is the environment variable read at startup for the +// one-time bootstrap token. +const BootstrapTokenEnvVar = tokenservice.BootstrapTokenEnvVar + +// Mode controls how the SMD client authenticates outbound requests. +type Mode string + +const ( + // ModeDisabled disables authentication. No TokenSmith dependency at runtime. + ModeDisabled Mode = "disabled" + // ModeOptional attempts authentication; startup continues unauthenticated on failure. + ModeOptional Mode = "optional" + // ModeRequired fails startup if the bootstrap token exchange fails. + ModeRequired Mode = "required" +) + +// ParseMode converts a string to a Mode. An empty string maps to ModeDisabled. +func ParseMode(s string) (Mode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case string(ModeDisabled), "": + return ModeDisabled, nil + case string(ModeOptional): + return ModeOptional, nil + case string(ModeRequired): + return ModeRequired, nil + default: + return "", fmt.Errorf("unknown auth_mode %q: must be disabled, optional, or required", s) + } +} + +// Config holds plugin-supplied auth configuration. No secrets belong here; +// the bootstrap token is read from the environment by the caller. +// target_service and scopes are intentionally absent: they are encoded in the +// bootstrap token at mint time and TokenSmith uses those values when the +// fields are omitted from the exchange request. +type Config struct { + Mode Mode + TokensmithURL string + RefreshBefore time.Duration // defaults to 2 minutes +} + +// Provider wraps a tokenservice.ServiceClient and manages its lifecycle for +// the duration of the plugin process. +type Provider struct { + client *tokenservice.ServiceClient + mode Mode + log logrus.FieldLogger + cancel context.CancelFunc +} + +// New creates a Provider using cfg and the supplied bootstrapToken. +// bootstrapToken should be the value of $TOKENSMITH_BOOTSTRAP_TOKEN. +// target_service and scopes are read from the bootstrap token by TokenSmith; +// the plugin does not need to supply them. +func New(cfg Config, bootstrapToken string, log logrus.FieldLogger) *Provider { + if cfg.RefreshBefore <= 0 { + cfg.RefreshBefore = 2 * time.Minute + } + if log == nil { + log = logrus.NewEntry(logrus.New()) + } + + sc := tokenservice.NewServiceClientWithOptions( + cfg.TokensmithURL, + "coresmd", + "coresmd", + "coresmd", + "", + tokenservice.WithBootstrapToken(bootstrapToken), + tokenservice.WithRefreshBefore(cfg.RefreshBefore), + ) + + return &Provider{ + client: sc, + mode: cfg.Mode, + log: log, + } +} + +// Initialize performs the bootstrap token exchange according to the configured Mode. +// +// For optional mode a 15-second timeout is applied; a failed exchange is +// logged but Initialize returns nil so the plugin can start unauthenticated. +// +// For required mode the ServiceClient's built-in retry policy applies (5 +// attempts, 1–15 s exponential backoff); a failure returns a non-nil error. +func (p *Provider) Initialize() error { + if p.mode == ModeDisabled { + return nil + } + + if p.mode == ModeOptional { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := p.client.Initialize(ctx); err != nil { + p.log.Warnf("coresmd auth: optional token exchange failed, continuing unauthenticated: %v", err) + return nil + } + p.log.Info("coresmd auth: service token obtained (optional mode)") + return nil + } + + // required: fail closed + if err := p.client.Initialize(context.Background()); err != nil { + return fmt.Errorf("coresmd auth: required token exchange failed: %w", err) + } + p.log.Info("coresmd auth: service token obtained (required mode)") + return nil +} + +// StartAutoRefresh spawns a background goroutine that proactively renews the +// service token until the refresh token expires or Stop is called. It is a +// no-op when mode is disabled. +func (p *Provider) StartAutoRefresh() { + if p.mode == ModeDisabled { + return + } + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + go func() { + p.client.StartAutoRefresh(ctx) + if p.mode == ModeRequired { + p.log.Error("coresmd auth: auto-refresh stopped (refresh token expired); SMD requests will be unauthenticated") + } else { + p.log.Warn("coresmd auth: auto-refresh stopped (refresh token expired); SMD requests will be unauthenticated") + } + }() +} + +// Stop cancels the auto-refresh goroutine. Safe to call on a nil Provider. +func (p *Provider) Stop() { + if p == nil { + return + } + if p.cancel != nil { + p.cancel() + p.cancel = nil + } +} + +// GetBearerToken returns the current access token string, or "" when no token +// is available (mode disabled, exchange not yet completed, or exchange failed). +// This method satisfies the func() string signature expected by +// smdclient.SmdClient.TokenProvider. +func (p *Provider) GetBearerToken() string { + if p == nil || p.mode == ModeDisabled || p.client == nil { + return "" + } + st := p.client.GetServiceToken() + if st == nil { + return "" + } + return st.Token +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..1951ac5 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" +) + +func TestParseMode(t *testing.T) { + tests := []struct { + name string + in string + want Mode + wantErr bool + }{ + {name: "empty maps to disabled", in: "", want: ModeDisabled}, + {name: "explicit disabled", in: "disabled", want: ModeDisabled}, + {name: "optional", in: "optional", want: ModeOptional}, + {name: "required", in: "required", want: ModeRequired}, + {name: "trim and case normalize", in: " OpTional ", want: ModeOptional}, + {name: "invalid", in: "shadow", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseMode(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseMode(%q): expected error, got nil", tt.in) + } + return + } + if err != nil { + t.Fatalf("ParseMode(%q): unexpected error: %v", tt.in, err) + } + if got != tt.want { + t.Fatalf("ParseMode(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNewSetsFields(t *testing.T) { + p := New(Config{Mode: ModeOptional, TokensmithURL: "https://tokensmith.example"}, "bootstrap", nil) + if p == nil { + t.Fatal("New() returned nil provider") + } + if p.client == nil { + t.Fatal("New() returned provider with nil client") + } + if p.mode != ModeOptional { + t.Fatalf("provider mode = %q, want %q", p.mode, ModeOptional) + } + if p.log == nil { + t.Fatal("New() should set a default logger when nil is provided") + } +} + +func TestStopSafeAndIdempotent(t *testing.T) { + var p *Provider + p.Stop() // nil receiver should be safe + + p = &Provider{} + p.Stop() // cancel is nil + p.Stop() // idempotent +} + +func TestStartAutoRefreshDisabledNoop(t *testing.T) { + p := &Provider{mode: ModeDisabled} + p.StartAutoRefresh() + if p.cancel != nil { + t.Fatal("StartAutoRefresh() in disabled mode should not set cancel") + } +} + +func TestGetBearerTokenEmptyStates(t *testing.T) { + var nilProvider *Provider + if got := nilProvider.GetBearerToken(); got != "" { + t.Fatalf("nil provider token = %q, want empty", got) + } + + p := &Provider{mode: ModeDisabled} + if got := p.GetBearerToken(); got != "" { + t.Fatalf("disabled provider token = %q, want empty", got) + } + + p = &Provider{mode: ModeRequired, client: nil} + if got := p.GetBearerToken(); got != "" { + t.Fatalf("nil client token = %q, want empty", got) + } +} diff --git a/internal/smdclient/smdclient.go b/internal/smdclient/smdclient.go index c23c9d6..f72a1b2 100644 --- a/internal/smdclient/smdclient.go +++ b/internal/smdclient/smdclient.go @@ -24,6 +24,11 @@ var ( type SmdClient struct { *http.Client BaseURL *url.URL + // TokenProvider, when non-nil, is called before each request. If it returns + // a non-empty string that value is set as a Bearer token in the Authorization + // header. Set this field to auth.Provider.GetBearerToken to enable optional + // or required TokenSmith authentication. + TokenProvider func() string } type EthernetInterface struct { @@ -90,6 +95,12 @@ func (sc *SmdClient) APIGet(path string) ([]byte, error) { return nil, fmt.Errorf("failed to create request: %w", err) } + if sc.TokenProvider != nil { + if token := sc.TokenProvider(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + } + if sc == nil { return nil, fmt.Errorf("SmdClient is nil") } diff --git a/internal/smdclient/smdclient_test.go b/internal/smdclient/smdclient_test.go index e259e75..91ca08e 100644 --- a/internal/smdclient/smdclient_test.go +++ b/internal/smdclient/smdclient_test.go @@ -339,6 +339,113 @@ func TestSmdClientAPIGet_ReadBodyError(t *testing.T) { } } +//============================================================================== +// SmdClient.TokenProvider bearer token injection +//============================================================================== + +func TestSmdClientAPIGet_TokenProvider(t *testing.T) { + tests := []struct { + name string + tokenProvider func() string + wantHeader string // expected Authorization header value, "" means absent + }{ + { + name: "nil_provider_sends_no_auth_header", + tokenProvider: nil, + wantHeader: "", + }, + { + name: "provider_returns_empty_sends_no_auth_header", + tokenProvider: func() string { return "" }, + wantHeader: "", + }, + { + name: "provider_returns_token_sets_bearer_header", + tokenProvider: func() string { return "test-service-token" }, + wantHeader: "Bearer test-service-token", + }, + { + name: "provider_return_value_used_verbatim", + tokenProvider: func() string { return "eyJhbGciOiJSUzI1NiJ9.payload.sig" }, + wantHeader: "Bearer eyJhbGciOiJSUzI1NiJ9.payload.sig", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedAuthHeader string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuthHeader = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + baseURL, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + client := NewSmdClient(baseURL) + client.Client = srv.Client() + client.TokenProvider = tt.tokenProvider + + _, err = client.APIGet("/test") + if err != nil { + t.Fatalf("APIGet() unexpected error: %v", err) + } + + if capturedAuthHeader != tt.wantHeader { + t.Errorf("Authorization header = %q, want %q", capturedAuthHeader, tt.wantHeader) + } + }) + } +} + +func TestSmdClientAPIGet_TokenProviderCalledPerRequest(t *testing.T) { + callCount := 0 + tokens := []string{"first-token", "second-token", "third-token"} + tokenProvider := func() string { + t := tokens[callCount%len(tokens)] + callCount++ + return t + } + + var receivedHeaders []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = append(receivedHeaders, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + baseURL, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + client := NewSmdClient(baseURL) + client.Client = srv.Client() + client.TokenProvider = tokenProvider + + for i := 0; i < 3; i++ { + if _, err := client.APIGet("/test"); err != nil { + t.Fatalf("APIGet() call %d unexpected error: %v", i, err) + } + } + + if callCount != 3 { + t.Errorf("TokenProvider call count = %d, want 3", callCount) + } + for i, hdr := range receivedHeaders { + want := "Bearer " + tokens[i] + if hdr != want { + t.Errorf("request %d Authorization = %q, want %q", i, hdr, want) + } + } +} + //============================================================================== // Struct JSON behavior //============================================================================== diff --git a/plugin/coredhcp/coresmd/main.go b/plugin/coredhcp/coresmd/main.go index 08b10b5..d53458c 100644 --- a/plugin/coredhcp/coresmd/main.go +++ b/plugin/coredhcp/coresmd/main.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "net/url" + "os" "strconv" "strings" "time" @@ -20,6 +21,7 @@ import ( "github.com/insomniacslk/dhcp/dhcpv6" "github.com/sirupsen/logrus" + "github.com/openchami/coresmd/internal/auth" "github.com/openchami/coresmd/internal/cache" "github.com/openchami/coresmd/internal/debug" "github.com/openchami/coresmd/internal/iface" @@ -43,6 +45,11 @@ type Config struct { domain string // domain ruleLog string // rule_log rules []rule.Rule // rule + + // Auth (optional TokenSmith service-to-service authentication) + authMode auth.Mode // auth_mode + tokensmithURL string // tokensmith_url + refreshBefore *time.Duration // refresh_before } func (c Config) String() string { @@ -118,6 +125,26 @@ func setup6(args ...string) (handler.Handler6, error) { return nil, fmt.Errorf("failed to set CA certificate: %w", err) } + // Wire optional TokenSmith authentication + if cfg.authMode == auth.ModeOptional || cfg.authMode == auth.ModeRequired { + bootstrapToken := os.Getenv(auth.BootstrapTokenEnvVar) + var refreshBefore time.Duration + if cfg.refreshBefore != nil { + refreshBefore = *cfg.refreshBefore + } + authProvider := auth.New(auth.Config{ + Mode: cfg.authMode, + TokensmithURL: cfg.tokensmithURL, + RefreshBefore: refreshBefore, + }, bootstrapToken, log) + if err := authProvider.Initialize(); err != nil { + return nil, err + } + authProvider.StartAutoRefresh() + smdClient.TokenProvider = authProvider.GetBearerToken + log.Infof("coresmd auth enabled (mode: %s, tokensmith: %s)", cfg.authMode, cfg.tokensmithURL) + } + // Create cache and start fetching var err error if smdCache, err = cache.NewCache(log, cfg.cacheValid.String(), smdClient); err != nil { @@ -171,6 +198,26 @@ func setup4(args ...string) (handler.Handler4, error) { return nil, fmt.Errorf("failed to set CA certificate: %w", err) } + // Wire optional TokenSmith authentication + if cfg.authMode == auth.ModeOptional || cfg.authMode == auth.ModeRequired { + bootstrapToken := os.Getenv(auth.BootstrapTokenEnvVar) + var refreshBefore time.Duration + if cfg.refreshBefore != nil { + refreshBefore = *cfg.refreshBefore + } + authProvider := auth.New(auth.Config{ + Mode: cfg.authMode, + TokensmithURL: cfg.tokensmithURL, + RefreshBefore: refreshBefore, + }, bootstrapToken, log) + if err := authProvider.Initialize(); err != nil { + return nil, err + } + authProvider.StartAutoRefresh() + smdClient.TokenProvider = authProvider.GetBearerToken + log.Infof("coresmd auth enabled (mode: %s, tokensmith: %s)", cfg.authMode, cfg.tokensmithURL) + } + // Create cache and start fetching var err error if smdCache, err = cache.NewCache(log, cfg.cacheValid.String(), smdClient); err != nil { @@ -341,6 +388,22 @@ func parseConfig(argv ...string) (cfg Config, errs []error) { continue } cfg.rules = append(cfg.rules, rule) + case "auth_mode": + mode, err := auth.ParseMode(opt[1]) + if err != nil { + errs = append(errs, fmt.Errorf("non-comment arg %d: %w", idx, err)) + continue + } + cfg.authMode = mode + case "tokensmith_url": + cfg.tokensmithURL = strings.TrimSpace(opt[1]) + case "refresh_before": + d, err := time.ParseDuration(opt[1]) + if err != nil { + errs = append(errs, fmt.Errorf("non-comment arg %d: %s: invalid duration %q: %w", idx, opt[0], opt[1], err)) + continue + } + cfg.refreshBefore = &d default: errs = append(errs, fmt.Errorf("non-comment arg %d: unknown config key '%s' (skipping)", idx, opt[0])) continue @@ -409,6 +472,9 @@ func (c *Config) validate() (warns []string, errs []error) { c.rules[i].Log = c.ruleLog } } + if c.authMode != auth.ModeDisabled && c.authMode != "" && c.tokensmithURL == "" { + errs = append(errs, fmt.Errorf("tokensmith_url is required when auth_mode is %s", c.authMode)) + } return } diff --git a/plugin/coredhcp/coresmd/main_test.go b/plugin/coredhcp/coresmd/main_test.go index ac37ec4..907374e 100644 --- a/plugin/coredhcp/coresmd/main_test.go +++ b/plugin/coredhcp/coresmd/main_test.go @@ -180,3 +180,89 @@ func TestSetup6_InvalidConfigFails(t *testing.T) { t.Fatalf("setup6() with invalid config: expected nil handler") } } + +func TestParseConfig_AuthSettings(t *testing.T) { + cfg, errs := parseConfig( + "svc_base_uri=https://svc.example.test", + "ipxe_base_uri=https://ipxe.example.test", + "auth_mode=optional", + "tokensmith_url=https://tokensmith.example.test", + "refresh_before=75s", + ) + if len(errs) != 0 { + t.Fatalf("parseConfig() unexpected errors: %v", errs) + } + if cfg.authMode != "optional" { + t.Fatalf("authMode=%q, want %q", cfg.authMode, "optional") + } + if cfg.tokensmithURL != "https://tokensmith.example.test" { + t.Fatalf("tokensmithURL=%q", cfg.tokensmithURL) + } + if cfg.refreshBefore == nil || cfg.refreshBefore.String() != "1m15s" { + t.Fatalf("refreshBefore=%v, want 1m15s", cfg.refreshBefore) + } +} + +func TestParseConfig_InvalidAuthInputs(t *testing.T) { + _, errs := parseConfig( + "svc_base_uri=https://svc.example.test", + "ipxe_base_uri=https://ipxe.example.test", + "auth_mode=shadow", + "refresh_before=not-a-duration", + ) + if len(errs) < 2 { + t.Fatalf("expected at least 2 errors, got %d: %v", len(errs), errs) + } + + hasModeErr := false + hasRefreshErr := false + for _, err := range errs { + if strings.Contains(err.Error(), "unknown auth_mode") { + hasModeErr = true + } + if strings.Contains(err.Error(), "invalid duration") { + hasRefreshErr = true + } + } + if !hasModeErr { + t.Fatalf("expected unknown auth_mode error, got: %v", errs) + } + if !hasRefreshErr { + t.Fatalf("expected invalid refresh_before duration error, got: %v", errs) + } +} + +func TestConfigValidate_AuthRequiresTokensmithURL(t *testing.T) { + svc, _ := url.Parse("https://svc.example.test") + ipxe, _ := url.Parse("https://ipxe.example.test") + + cfg := Config{svcBaseURI: svc, ipxeBaseURI: ipxe, authMode: "required"} + _, errs := cfg.validate() + if len(errs) == 0 { + t.Fatal("expected validation error when auth_mode is required and tokensmith_url is missing") + } + + found := false + for _, err := range errs { + if strings.Contains(err.Error(), "tokensmith_url is required when auth_mode") { + found = true + break + } + } + if !found { + t.Fatalf("expected missing tokensmith_url validation error, got: %v", errs) + } +} + +func TestConfigValidate_DisabledAuthDoesNotRequireTokensmithURL(t *testing.T) { + svc, _ := url.Parse("https://svc.example.test") + ipxe, _ := url.Parse("https://ipxe.example.test") + + cfg := Config{svcBaseURI: svc, ipxeBaseURI: ipxe, authMode: ""} + _, errs := cfg.validate() + for _, err := range errs { + if strings.Contains(err.Error(), "tokensmith_url is required when auth_mode") { + t.Fatalf("did not expect tokensmith_url error for disabled/zero auth mode: %v", errs) + } + } +} diff --git a/plugin/coredns/README.md b/plugin/coredns/README.md index b9529df..6b6a5b7 100644 --- a/plugin/coredns/README.md +++ b/plugin/coredns/README.md @@ -65,8 +65,50 @@ The CoreSMD CoreDNS plugin is included in the CoreSMD binary. No additional inst | `smd_url` | string | required | SMD API endpoint URL | | `ca_cert` | string | "" | Path to CA certificate for SMD TLS | | `cache_duration` | duration | "30s" | Cache refresh interval | +| `auth_mode` | enum | `disabled` | Outbound SMD auth mode: `disabled`, `optional`, or `required` | +| `tokensmith_url` | string | required when auth enabled | TokenSmith base URL used for bootstrap exchange | +| `refresh_before` | duration | `2m` | How early to refresh expiring service tokens | | `zone` | block | auto | Zone configuration block | +### TokenSmith Authentication + +When enabled, the plugin obtains a service token from TokenSmith and includes +`Authorization: Bearer ` on outbound SMD API calls. + +Auth settings live in the `coresmd` Corefile block: + +- `auth_mode` + - `disabled` (default): never attempts auth + - `optional`: attempts auth, continues unauthenticated if token bootstrap fails + - `required`: startup fails if token bootstrap fails +- `tokensmith_url`: required for `optional` and `required` +- `refresh_before`: optional duration for proactive token renewal (default `2m`) + +The bootstrap token is read from the environment variable +`TOKENSMITH_BOOTSTRAP_TOKEN`. + +`target_service` and `scopes` are intentionally not configured in CoreDNS. +TokenSmith derives those claims from the bootstrap token itself. + +Example: + +```corefile +. { + coresmd { + smd_url https://smd.cluster.local + auth_mode required + tokensmith_url https://tokensmith.cluster.local + refresh_before 90s + + zone cluster.local { + nodes nid{04d} + } + } + prometheus 0.0.0.0:9153 + forward . 8.8.8.8 +} +``` + ### Zone Configuration Each zone block supports the following options: diff --git a/plugin/coredns/ready.go b/plugin/coredns/ready.go index 25d54bc..76c5fa3 100644 --- a/plugin/coredns/ready.go +++ b/plugin/coredns/ready.go @@ -40,6 +40,6 @@ func (p Plugin) OnStartupComplete() error { } func (p Plugin) OnShutdown() error { - // Plugin shutdown is complete + p.authProvider.Stop() return nil } diff --git a/plugin/coredns/setup.go b/plugin/coredns/setup.go index cec95a5..02621f5 100644 --- a/plugin/coredns/setup.go +++ b/plugin/coredns/setup.go @@ -8,6 +8,7 @@ package plugin import ( "fmt" "net/url" + "os" "time" "github.com/coredns/caddy" @@ -15,6 +16,7 @@ import ( "github.com/coredns/coredns/plugin" "github.com/sirupsen/logrus" + "github.com/openchami/coresmd/internal/auth" "github.com/openchami/coresmd/internal/cache" "github.com/openchami/coresmd/internal/smdclient" "github.com/openchami/coresmd/internal/version" @@ -35,6 +37,12 @@ type Plugin struct { // Shared infrastructure cache *cache.Cache smdClient *smdclient.SmdClient + + // Auth (optional TokenSmith service-to-service authentication) + authMode auth.Mode + tokensmithURL string + refreshBefore time.Duration + authProvider *auth.Provider } // Global variables @@ -129,6 +137,35 @@ func parse(c *caddy.Controller) (*Plugin, error) { p.cacheDuration = c.Val() log.Debugf("Set cache_duration to: %s", p.cacheDuration) + case "auth_mode": + if !c.NextArg() { + return nil, c.ArgErr() + } + mode, err := auth.ParseMode(c.Val()) + if err != nil { + return nil, c.Errf("%v", err) + } + p.authMode = mode + log.Debugf("Set auth_mode to: %s", p.authMode) + + case "tokensmith_url": + if !c.NextArg() { + return nil, c.ArgErr() + } + p.tokensmithURL = c.Val() + log.Debugf("Set tokensmith_url to: %s", p.tokensmithURL) + + case "refresh_before": + if !c.NextArg() { + return nil, c.ArgErr() + } + d, err := time.ParseDuration(c.Val()) + if err != nil { + return nil, c.Errf("invalid refresh_before duration %q: %v", c.Val(), err) + } + p.refreshBefore = d + log.Debugf("Set refresh_before to: %s", p.refreshBefore) + case "zone": // Example usage in Corefile: // zone cluster.local { @@ -160,6 +197,9 @@ func parse(c *caddy.Controller) (*Plugin, error) { if p.cacheDuration == "" { p.cacheDuration = "30s" } + if p.authMode != auth.ModeDisabled && p.authMode != "" && p.tokensmithURL == "" { + return nil, fmt.Errorf("tokensmith_url is required when auth_mode is %s", p.authMode) + } return p, nil } @@ -230,6 +270,22 @@ func (p *Plugin) OnStartup() error { log.Infof("CA certificate path was empty, not setting") } + // Wire optional TokenSmith authentication + if p.authMode == auth.ModeOptional || p.authMode == auth.ModeRequired { + bootstrapToken := os.Getenv(auth.BootstrapTokenEnvVar) + p.authProvider = auth.New(auth.Config{ + Mode: p.authMode, + TokensmithURL: p.tokensmithURL, + RefreshBefore: p.refreshBefore, + }, bootstrapToken, log) + if err := p.authProvider.Initialize(); err != nil { + return err + } + p.authProvider.StartAutoRefresh() + p.smdClient.TokenProvider = p.authProvider.GetBearerToken + log.Infof("coresmd auth enabled (mode: %s, tokensmith: %s)", p.authMode, p.tokensmithURL) + } + // Create cache p.cache, err = cache.NewCache(log, p.cacheDuration, p.smdClient) if err != nil { diff --git a/plugin/coredns/setup_test.go b/plugin/coredns/setup_test.go index 893c0b6..3ee0e50 100644 --- a/plugin/coredns/setup_test.go +++ b/plugin/coredns/setup_test.go @@ -231,6 +231,81 @@ func TestParseConfigurationMissingArgument(t *testing.T) { } } +func TestParseConfigurationWithAuthDirectives(t *testing.T) { + corefile := `coresmd { + smd_url https://smd.cluster.local + auth_mode optional + tokensmith_url https://tokensmith.cluster.local + refresh_before 90s + }` + + c := caddy.NewTestController("dns", corefile) + plugin, err := parse(c) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if plugin.authMode != "optional" { + t.Fatalf("Expected auth_mode optional, got %q", plugin.authMode) + } + if plugin.tokensmithURL != "https://tokensmith.cluster.local" { + t.Fatalf("Expected tokensmith_url to be set, got %q", plugin.tokensmithURL) + } + if plugin.refreshBefore.String() != "1m30s" { + t.Fatalf("Expected refresh_before 1m30s, got %s", plugin.refreshBefore) + } +} + +func TestParseConfigurationAuthModeRequiresTokensmithURL(t *testing.T) { + corefile := `coresmd { + smd_url https://smd.cluster.local + auth_mode required + }` + + c := caddy.NewTestController("dns", corefile) + _, err := parse(c) + if err == nil { + t.Fatal("Expected error for missing tokensmith_url with required mode") + } + if !strings.Contains(err.Error(), "tokensmith_url is required when auth_mode") { + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestParseConfigurationInvalidAuthMode(t *testing.T) { + corefile := `coresmd { + smd_url https://smd.cluster.local + auth_mode shadow + }` + + c := caddy.NewTestController("dns", corefile) + _, err := parse(c) + if err == nil { + t.Fatal("Expected error for invalid auth_mode") + } + if !strings.Contains(err.Error(), "unknown auth_mode") { + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestParseConfigurationInvalidRefreshBefore(t *testing.T) { + corefile := `coresmd { + smd_url https://smd.cluster.local + auth_mode optional + tokensmith_url https://tokensmith.cluster.local + refresh_before nope + }` + + c := caddy.NewTestController("dns", corefile) + _, err := parse(c) + if err == nil { + t.Fatal("Expected error for invalid refresh_before") + } + if !strings.Contains(err.Error(), "invalid refresh_before duration") { + t.Fatalf("Unexpected error: %v", err) + } +} + func TestPluginOnStartup(t *testing.T) { plugin := &Plugin{ smdURL: "https://smd.cluster.local",