Repository: tinkerbell/boots Branch: main Commit: 5f31a4ab8025 Files: 104 Total size: 497.8 KB Directory structure: gitextract_aau8ld8h/ ├── .dockerignore ├── .github/ │ ├── CODEOWNERS │ ├── codecov.yml │ ├── dependabot.yml │ ├── mergify.yml │ ├── settings.yml │ └── workflows/ │ ├── ci-checks.sh │ ├── ci.yaml │ └── tags.yaml ├── .gitignore ├── .golangci.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── RELEASING.md ├── Tiltfile ├── cmd/ │ └── smee/ │ ├── backend.go │ ├── flag.go │ ├── flag_test.go │ └── main.go ├── contrib/ │ └── tag-release.sh ├── docker-compose.yml ├── docs/ │ ├── Backend-File.md │ ├── Code-Structure.md │ ├── DCO.md │ ├── DESIGN.md │ ├── DESIGNPHILOSOPHY.md │ ├── DHCP.md │ ├── Design-Philosophy.md │ ├── ISO-Static-IPAM.md │ ├── images/ │ │ └── BYO_DHCP.uml │ └── manifests/ │ ├── README.md │ ├── k3d.md │ ├── kind.md │ ├── kubernetes.md │ └── tilt.md ├── go.mod ├── go.sum ├── internal/ │ ├── backend/ │ │ ├── file/ │ │ │ ├── file.go │ │ │ ├── file_test.go │ │ │ └── testdata/ │ │ │ └── example.yaml │ │ ├── kube/ │ │ │ ├── error.go │ │ │ ├── index.go │ │ │ ├── index_test.go │ │ │ ├── kube.go │ │ │ └── kube_test.go │ │ └── noop/ │ │ ├── noop.go │ │ └── noop_test.go │ ├── dhcp/ │ │ ├── data/ │ │ │ ├── data.go │ │ │ └── data_test.go │ │ ├── dhcp.go │ │ ├── dhcp_test.go │ │ ├── handler/ │ │ │ ├── handler.go │ │ │ ├── proxy/ │ │ │ │ └── proxy.go │ │ │ └── reservation/ │ │ │ ├── handler.go │ │ │ ├── handler_test.go │ │ │ ├── noop.go │ │ │ ├── noop_test.go │ │ │ ├── option.go │ │ │ ├── option_test.go │ │ │ └── reservation.go │ │ ├── otel/ │ │ │ ├── otel.go │ │ │ └── otel_test.go │ │ └── server/ │ │ ├── dhcp.go │ │ └── dhcp_test.go │ ├── ipxe/ │ │ ├── http/ │ │ │ ├── http.go │ │ │ ├── middleware.go │ │ │ ├── xff.go │ │ │ └── xff_test.go │ │ └── script/ │ │ ├── auto.go │ │ ├── auto_test.go │ │ ├── custom.go │ │ ├── hook.go │ │ ├── ipxe.go │ │ ├── ipxe_test.go │ │ └── static.go │ ├── iso/ │ │ ├── internal/ │ │ │ ├── LICENSE │ │ │ ├── acsii.go │ │ │ ├── acsii_test.go │ │ │ ├── context.go │ │ │ ├── reverseproxy.go │ │ │ └── reverseproxy_test.go │ │ ├── ipam.go │ │ ├── ipam_test.go │ │ ├── iso.go │ │ ├── iso_test.go │ │ └── testdata/ │ │ └── output.iso │ ├── metric/ │ │ └── metric.go │ ├── otel/ │ │ └── otel.go │ └── syslog/ │ ├── facility_string.go │ ├── message.go │ ├── receiver.go │ └── severity_string.go ├── lint.mk ├── rules.mk └── test/ ├── Dockerfile ├── busybox-udhcpc-script.sh ├── extract-traceparent-from-opt43.sh ├── hardware.yaml ├── otel-collector.yaml ├── start-smee.sh └── test-smee.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ * !cmd/smee/smee-*-* !cmd/smee/smee !test/ ================================================ FILE: .github/CODEOWNERS ================================================ /.github/settings.yml @chrisdoherty4 @jacobweinstock /.github/CODEOWNERS @chrisdoherty4 @jacobweinstock ================================================ FILE: .github/codecov.yml ================================================ --- coverage: precision: 0 # xx% round: down # round down range: 30..40 # red < yellow (this range) < green status: project: default: target: auto # automatically calculate coverage target - should increase threshold: 2% # allow for 2% reduction without failing patch: default: target: auto changes: false ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" day: "monday" time: "04:39" timezone: "America/New_York" reviewers: - chrisdoherty4 - jacobweinstock open-pull-requests-limit: 10 - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "monday" time: "03:52" timezone: "America/New_York" reviewers: - chrisdoherty4 - jacobweinstock open-pull-requests-limit: 10 - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "thursday" time: "03:52" timezone: "America/New_York" reviewers: - chrisdoherty4 - jacobweinstock open-pull-requests-limit: 10 - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" day: "monday" time: "04:22" timezone: "America/New_York" reviewers: - chrisdoherty4 - jacobweinstock open-pull-requests-limit: 10 ================================================ FILE: .github/mergify.yml ================================================ queue_rules: - name: default queue_conditions: - base=main - "#approved-reviews-by>=1" - "#changes-requested-reviews-by=0" - "#review-requested=0" - check-success=DCO - check-success=validation - label!=do-not-merge - label=ready-to-merge merge_conditions: # Conditions to get out of the queue (= merged) - check-success=DCO - check-success=validation merge_method: merge commit_message_template: | {{ title }} (#{{ number }}) {{ body }} pull_request_rules: - name: refactored queue action rule conditions: [] actions: queue: ================================================ FILE: .github/settings.yml ================================================ # Collaborators: give specific users access to this repository. # See https://docs.github.com/en/rest/reference/repos#add-a-repository-collaborator for available options collaborators: # Maintainers, should also be added to the .github/CODEOWNERS file as owners of this settings.yml file. - username: jacobweinstock permission: maintain - username: chrisdoherty4 permission: maintain # Approvers # Reviewers # Note: `permission` is only valid on organization-owned repositories. # The permission to grant the collaborator. Can be one of: # * `pull` - can pull, but not push to or administer this repository. # * `push` - can pull and push, but not administer this repository. # * `admin` - can pull, push and administer this repository. # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. ================================================ FILE: .github/workflows/ci-checks.sh ================================================ #!/usr/bin/env bash set -eux failed=0 if [[ -n $(go run golang.org/x/tools/cmd/goimports@latest -d -e -l .) ]]; then go run golang.org/x/tools/cmd/goimports@latest -w . failed=1 fi if ! go mod tidy; then failed=true fi if ! git diff | (! grep .); then failed=1 fi exit "$failed" ================================================ FILE: .github/workflows/ci.yaml ================================================ name: For each commit and PR on: push: branches: - "*" tags-ignore: - "v*" pull_request: env: REGISTRY: quay.io IMAGE: quay.io/${{ github.repository }} CGO_ENABLED: 0 GO_VERSION: "1.24" jobs: validation: runs-on: ubuntu-latest env: CGO_ENABLED: 0 steps: - name: Setup Dynamic Env run: | echo "MAKEFLAGS=-j$(nproc)" | tee $GITHUB_ENV - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 5 - name: Setup Go uses: actions/setup-go@v5 with: go-version: "${{ env.GO_VERSION }}" cache: true - name: Fetch Deps run: | # fixes "write /run/user/1001/355792648: no space left on device" error sudo mount -o remount,size=3G /run/user/1001 || true go get -t ./... && go mod tidy - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Generate all files run: make -j1 gen - name: Run all the tests run: make ci - name: upload codecov uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: compile binaries run: make crosscompile - name: Figure out Docker Tags id: docker-image-tag run: | echo ::set-output name=tags::${{ env.IMAGE }}:latest,${{ env.IMAGE }}:sha-${GITHUB_SHA::8} - name: Login to quay.io uses: docker/login-action@v3 if: ${{ startsWith(github.ref, 'refs/heads/main') }} with: registry: ${{ env.REGISTRY }} username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - name: Build Docker Images uses: docker/build-push-action@v6 with: context: ./ file: ./Dockerfile cache-from: type=registry,ref=${{ env.IMAGE }}:latest platforms: linux/amd64,linux/arm64 tags: ${{ steps.docker-image-tag.outputs.tags }} # looks just like Build Docker Images except with push:true and this will only run for builds for main - name: Push Docker Images uses: docker/build-push-action@v6 if: ${{ startsWith(github.ref, 'refs/heads/main') }} with: context: ./ file: ./Dockerfile cache-from: type=registry,ref=${{ env.IMAGE }}:latest platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.docker-image-tag.outputs.tags }} ================================================ FILE: .github/workflows/tags.yaml ================================================ on: push: tags: - "v*" name: Create release env: REGISTRY: quay.io IMAGE_NAME: ${{ github.repository }} jobs: release: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Generate Release Notes run: | release_notes=$(gh api repos/{owner}/{repo}/releases/generate-notes -F tag_name=${{ github.ref }} --jq .body) echo 'RELEASE_NOTES<> $GITHUB_ENV echo "${release_notes}" >> $GITHUB_ENV echo 'EOF' >> $GITHUB_ENV env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OWNER: ${{ github.repository_owner }} REPO: ${{ github.event.repository.name }} - name: Docker manager metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} flavor: latest=false tags: type=ref,event=tag - name: Set the from image tag run: echo "FROM_TAG=sha-${GITHUB_SHA::8}" >> $GITHUB_ENV - name: Copy the image using skopeo run: skopeo copy --all --dest-creds="${DST_REG_USER}":"${DST_REG_PASS}" docker://"${SRC_IMAGE}" docker://"${DST_IMAGE}" env: SRC_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.FROM_TAG }} DST_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} DST_REG_USER: ${{ secrets.QUAY_USERNAME }} DST_REG_PASS: ${{ secrets.QUAY_PASSWORD }} - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} body: ${{ env.RELEASE_NOTES }} draft: false prerelease: true ================================================ FILE: .gitignore ================================================ *.iml *.orig *.test .idea*/** /bin/ /cmd/smee/smee /cmd/smee/smee-*-* coverage.txt .vscode # added by lint-install out/ ================================================ FILE: .golangci.yml ================================================ version: "2" run: # The default runtime timeout is 1m, which doesn't work well on Github Actions. timeout: 4m linters: default: none enable: - asciicheck - bodyclose - copyloopvar - cyclop - dogsled - dupl - durationcheck - errcheck - errname - errorlint - exhaustive - forcetypeassert - gocognit - goconst - gocritic - godot - goheader - goprintffuncname - gosec - govet - importas - ineffassign - makezero - misspell - nakedret - nestif - nilerr - noctx - nolintlint - predeclared - revive - rowserrcheck - sqlclosecheck - staticcheck - thelper - tparallel - unconvert - unparam - unused - wastedassign - whitespace settings: cyclop: max-complexity: 37 package-average: 34 dupl: threshold: 200 errorlint: # Forcing %w in error wrapping forces authors to make errors part of their package APIs. The decision to make # an error part of a package API should be a conscious decision by the author. # Also see Hyrums Law. errorf: false asserts: false exhaustive: default-signifies-exhaustive: true gocognit: min-complexity: 98 goconst: min-len: 4 min-occurrences: 5 gosec: excludes: - G107 # Potential HTTP request made with variable url - G204 # Subprocess launched with function call as argument or cmd arguments - G404 # Use of weak random number generator (math/rand instead of crypto/rand nestif: min-complexity: 8 nolintlint: require-explanation: true require-specific: true allow-unused: false revive: severity: warning rules: - name: atomic - name: blank-imports - name: bool-literal-in-expr - name: confusing-naming - name: constant-logical-expr - name: context-as-argument - name: context-keys-type - name: deep-exit - name: defer - name: range-val-in-closure - name: range-val-address - name: dot-imports - name: error-naming - name: error-return - name: error-strings - name: errorf - name: exported - name: identical-branches - name: if-return - name: import-shadowing - name: increment-decrement - name: indent-error-flow - name: indent-error-flow - name: package-comments - name: range - name: receiver-naming - name: redefines-builtin-id - name: superfluous-else - name: struct-tag - name: time-naming - name: unexported-naming - name: unexported-return - name: unnecessary-stmt - name: unreachable-code - name: unused-parameter - name: var-declaration - name: var-naming - name: unconditional-recursion - name: waitgroup-by-value # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#struct-tag - name: struct-tag arguments: - json,inline - yaml,omitzero - protobuf,casttype exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - dupl - errcheck - forcetypeassert - gocyclo - gosec - noctx path: _test\.go - linters: # This check is of questionable value - tparallel text: call t.Parallel on the top level as well as its subtests - linters: - cyclop - goconst path: (.+)_test\.go paths: - third_party$ - builtin$ - examples$ - internal/iso/internal/reverseproxy.go - internal/iso/internal/reverseproxy_test.go - internal/iso/internal/acsii.go - internal/iso/internal/acsii_test.go issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gofmt - gofumpt - goimports exclusions: generated: lax paths: - internal/iso/internal/reverseproxy.go - internal/iso/internal/reverseproxy_test.go - internal/iso/internal/acsii.go - internal/iso/internal/acsii_test.go - third_party$ - builtin$ - examples$ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributor Guide Welcome to Smee! We are really excited to have you. Please use the following guide on your contributing journey. Thanks for contributing! ## Table of Contents - [Context](#Context) - [Architecture](#Architecture) - [Design Docs](#Design-Docs) - [Code Structure](#Code-Structure) - [Prerequisites](#Prerequisites) - [DCO Sign Off](#DCO-Sign-Off) - [Code of Conduct](#Code-of-Conduct) - [Setting up your development environment](#Setting-up-your-development-environment) - [Development](#Development) - [Building](#Building) - [Unit testing](#Unit-testing) - [Linting](#Linting) - [Functional testing](#Functional-testing) - [Running Smee locally](#Running-Smee-locally) - [Pull Requests](#Pull-Requests) - [Branching strategy](#Branching-strategy) - [Quality](#Quality) - [CI](#CI) - [Code coverage](#Code-coverage) - [Pre PR Checklist](#Pre-PR-Checklist) --- ## Context Smee is a DHCP and PXE (TFTP & HTTP) service. It is part of the [Tinkerbell stack](https://tinkerbell.org) and provides the first interaction for any machines being provisioned through Tinkerbell. ## Architecture ### Design Docs Details and diagrams for Smee are found [here](docs/DESIGN.md). ### Code Structure Details on Smee's code structure is found [here](docs/CODE_STRUCTURE.md) (WIP) ## Prerequisites ### DCO Sign Off Please read and understand the DCO found [here](docs/DCO.md). ### Code of Conduct Please read and understand the code of conduct found [here](https://github.com/tinkerbell/.github/blob/main/CODE_OF_CONDUCT.md). ### Setting up your development environment --- ### Dependencies #### Build time dependencies #### Runtime dependencies At runtime Smee needs to communicate with a Tink server. Follow this [guide](https://tinkerbell.org/docs/setup/getting_started/) for running Tink server. ## Development ### Building > At the moment, these instructions are only stable on Linux environments To build Smee, run: ```bash # build all ipxe files, embed them, and build the Go binary # Built binary can be found in the top level directory. make build ``` To build the amd64 Smee container image, run: ```bash # make the amd64 container image # Built image will be named smee:latest make image ``` To build the IPXE binaries and embed them into Go, run: ```bash # Note, this will not build the Smee binary make bindata ``` To build Smee binaries for all distro ### Unit testing To execute the unit tests, run: ```bash make test # to get code coverage numbers, run: make coverage ``` ### Linting To execute linting, run: ```bash # runs golangci-lint make lint # runs goimports make goimports # runs go vet make vet ``` ## Linting of Non Go files ```bash # lints non Go files like shell scripts, markdown files, etc # this script is used in CI run, so be sure it passes before submitting a PR ./.github/workflows/ci-non-go.sh ``` ### Functional testing 1. Create a hardware record in Tink server - follow the guide [here](https://tinkerbell.org/docs/concepts/hardware/) 2. boot the machine ### Running Smee 1. Be sure all documented runtime dependencies are satisfied. 2. Define all environment variables. ```bash # MIRROR_HOST is for downloading kernel, initrd export MIRROR_HOST=192.168.2.3 # PUBLIC_FQDN is for phone home endpoint export PUBLIC_FQDN=192.168.2.4 # DOCKER_REGISTRY, REGISTRY_USERNAME, REGISTRY_PASSWORD, TINKERBELL_GRPC_AUTHORITY, TINKERBELL_CERT_URL are needed for auto.ipxe file generation # TINKERBELL_GRPC_AUTHORITY, TINKERBELL_CERT_URL are needed for getting hardware data export DOCKER_REGISTRY=192.168.2.1:5000 export REGISTRY_USERNAME=admin export REGISTRY_PASSWORD=secret export TINKERBELL_GRPC_AUTHORITY=tinkerbell.tinkerbell:42113 export TINKERBELL_CERT_URL=http://tinkerbell.tinkerbell:42114/cert # FACILITY_CODE is needed for ? export FACILITY_CODE=onprem export DATA_MODEL_VERSION=1 # API_AUTH_TOKEN, API_CONSUMER_TOKEN are needed to by pass panicking in main.go main func export API_AUTH_TOKEN=none export API_CONSUMER_TOKEN=none ``` 3. Run Smee ```bash # Run the compiled smee sudo ./smee -http-addr 192.168.2.225:80 -tftp-addr 192.168.2.225:69 -dhcp-addr 192.168.2.225:67 ``` 4. Faster iterating via `go run` ```bash # after the ipxe binaries have been compiled you can use `go run` to iterate a little more quickly than building the binary every time sudo go run ./smee -http-addr 192.168.2.225:80 -tftp-addr 192.168.2.225:69 -dhcp-addr 192.168.2.225:67 ``` ## Pull Requests ### Branching strategy Smee uses a fork and pull request model. See this [doc](https://guides.github.com/activities/forking/) for more details. ### Quality #### CI Smee uses GitHub Actions for CI. The workflow is found in [.github/workflows/ci.yaml](.github/workflows/ci.yaml). It is run for each commit and PR. #### Code coverage Smee does run code coverage with each PR. Coverage thresholds are not currently enforced. It is always nice and very welcomed to add tests and keep or increase the code coverage percentage. ### Pre PR Checklist This checklist is a helper to make sure there's no gotchas that come up when you submit a PR. - [ ] You've reviewed the [code of conduct](#Code-of-Conduct) - [ ] All commits are DCO signed off - [ ] Code is [formatted and linted](#Linting) - [ ] Code [builds](#Building) successfully - [ ] All tests are [passing](#Unit-testing) - [ ] Code coverage [percentage](#Code-coverage). (main line is the base with which to compare) ================================================ FILE: Dockerfile ================================================ # run `make image` to build the binary + container # if you're using `make build` this Dockerfile will not find the binary # and you probably want `make smee-linux-amd64` FROM alpine:3.22 ARG TARGETARCH ARG TARGETVARIANT ENTRYPOINT ["/usr/bin/smee"] RUN apk add --update --upgrade --no-cache ca-certificates COPY cmd/smee/smee-linux-${TARGETARCH:-amd64}${TARGETVARIANT} /usr/bin/smee ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2020 Packet Host, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ all: help -include lint.mk -include rules.mk build: cmd/smee/smee ## Compile smee for host OS and Architecture crosscompile: $(crossbinaries) ## Compile smee for all architectures gen: $(generated_go_files) ## Generate go generate'd files IMAGE_TAG ?= smee:latest image: cmd/smee/smee-linux-amd64 ## Build docker image docker build -t $(IMAGE_TAG) . test: gen ## Run go test CGO_ENABLED=1 go test -race -coverprofile=coverage.txt -covermode=atomic -v ${TEST_ARGS} ./... coverage: test ## Show test coverage go tool cover -func=coverage.txt vet: ## Run go vet go vet ./... goimports: gen ## Run goimports $(GOIMPORTS) -w . ci-checks: .github/workflows/ci-checks.sh gen ./.github/workflows/ci-checks.sh ci: ci-checks coverage goimports lint vet ## Runs all the same validations and tests that run in CI help: ## Print this help @grep --no-filename -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sed 's/:.*##/·/' | sort | column -ts '·' -c 120 ================================================ FILE: README.md ================================================ > [!IMPORTANT] > The Smee repo has been deprecated. All functionality has been moved to https://github.com/tinkerbell/tinkerbell. > For more details, see the roadmap issue [#41](https://github.com/tinkerbell/roadmap/issues/41). > This repository is scheduled for archive by the end of 2025. # Smee [![Build Status](https://github.com/tinkerbell/smee/workflows/For%20each%20commit%20and%20PR/badge.svg)](https://github.com/tinkerbell/smee/actions?query=workflow%3A%22For+each+commit+and+PR%22+branch%3Amain) Smee is the network boot service in the [Tinkerbell stack](https://tinkerbell.org), formerly known as `Boots`. It is comprised of the following services. - DHCP server - host reservations only - mac address based lookups - netboot options support - backend support - Kubernetes - file based - ProxyDHCP support - TFTP server - serving iPXE binaries - HTTP server - serving iPXE binaries and iPXE scripts - iPXE script serving uses IP authentication - backend support - Kubernetes - file based - Syslog server - receives syslog messages and logs them ## Definitions **DHCP Reservation:** A fixed IP address that is reserved for a specific client. **DHCP Lease:** An IP address, that can potentially change, that is assigned to a client by the DHCP server. The IP is typically pulled from a pool or subnet of available IP addresses. **ProxyDHCP:** "[A] Proxy DHCP server behaves much like a DHCP server by listening for ordinary DHCP client traffic and responding to certain client requests. However, unlike the DHCP server, the PXE Proxy DHCP server does not administer network addresses, and it only responds to clients that identify themselves as PXE clients. The responses given by the PXE Proxy DHCP server contain the mechanism by which the client locates the boot servers or the network addresses and descriptions of the supported, compatible boot servers." -- [IBM](https://www.ibm.com/docs/en/aix/7.1?topic=protocol-preboot-execution-environment-proxy-dhcp-daemon) ## Running Smee ### DHCP Modes Smee's DHCP functionality can operate in one of the following modes: 1. **DHCP Reservation** To enable this mode set `-dhcp-mode=reservation`. Smee will respond to DHCP requests from clients and provide them with IP and next boot info when netbooting. This is the default mode. IP info is all reservation based. There must be a corresponding Hardware record for the requesting client's MAC address. 1. **Proxy DHCP** To enable this mode set `-dhcp-mode=proxy`. Smee will respond to PXE enabled DHCP requests from clients and provide them with next boot info when netbooting. In this mode an existing DHCP server that does not serve network boot information is required. Smee will respond to PXE enabled DHCP requests and provide the client with the next boot info. There must be a corresponding Hardware record for the requesting client's MAC address. The `auto.ipxe` script will be served with the MAC address in the URL and the MAC address will be used to lookup the corresponding Hardware record. Layer 2 access to machines or a DHCP relay agent that will forward the DHCP requests to Smee is required. 1. **Auto Proxy DHCP** To enable this mode set `-dhcp-mode=auto-proxy`. Smee will respond to PXE enabled DHCP requests from clients and provide them with next boot info when netbooting. In this mode an existing DHCP server that does not serve network boot information is required. In this mode, if no corresponding Hardware record is found for the requesting client's MAC address, Smee will provide the client with a statically defined iPXE script. If a Hardware record is found, then the normal `auto.ipxe` script will be served. Use `-backend-noop-enabled` to disable all backend look ups. Layer 2 access to machines or a DHCP relay agent that will forward the DHCP requests to Smee is required. - When using Smee's auto.ipxe, you'll generally want to set the following flags: - `-dhcp-mode=auto-proxy` - `-osie-url ` - `-tink-server ` - `-extra-kernel-args="tink_worker_image=quay.io/tinkerbell/tink-worker:"` - When not using Smee's auto.ipxe, you'll generally want to set the following flags: - `-dhcp-mode=auto-proxy` - `-dhcp-http-ipxe-script-url=https://boot.netboot.xyz` - `-dhcp-http-ipxe-script-prepend-mac=false` 1. **DHCP disabled** To enable this mode set `-dhcp-enabled=false`. Smee will not respond to DHCP requests from clients. This is useful when the network has an existing DHCP server that will provide both IP and next boot info and Smee's TFTP and HTTP functionality will be used. The IP address in the Hardware record must be the same as the IP address of the client requesting the `auto.ipxe` script. See this [doc](docs/DHCP.md) for more details. In most situations`--dhcp-http-ipxe-script-prepend-mac=false` should also be set when in this mode. ### Interoperability with other DHCP servers When a DHCP server exists on the network, Smee should be set to run `proxy` or `auto-proxy` mode. This will allow Smee to provide the next boot information to clients that request it. The existing DHCP server will provide the IP address and other network boot details. Layer 2 access to machines or a DHCP relay agent that will forward the DHCP requests to Smee is required. It is not recommended, but it is possible for Smee to be run in `reservation` mode in networks with another DHCP server(s). To get the intended behavior from Smee one of the following must be true. 1. All DHCP servers besides Smee are configured to ignore the MAC addresses that Smee is configured to serve. 1. All DHCP servers are configured to serve the same IP address and network boot details as Smee. In this scenario the DHCP functionality of Smee is redundant. It would be recommended to run Smee with the DHCP server functionality disabled (`-dhcp=false`). See the [doc](./docs/DHCP.md) on using your existing DHCP service for more details. ### Environment Variables and CLI Flags It's important to note that CLI flags take precedence over environment variables. All CLI flags can be set as environment variables. Environment variable names are the same as the flag names with some modifications. For example, the flag `-dhcp-addr` has the environment variable of `SMEE_DHCP_ADDR`. The modifications of CLI flags to environment variables are as follows: - prefixed with `SMEE_` - all uppercase - hyphens (`-`) are replaced with underscores (`_`) There is one environment variable that does not have a corresponding CLI flag. The environment variable is `SMEE_PUBLIC_IP_INTERFACE`. This environment variable takes a local network interface name and uses it to auto detect the IP address to use as the default in all other CLI flags that require an IP address. This is useful when the machine running Smee has multiple network interfaces and you want the default detected IP to be from this specified interface. ### Local Setup Running the Tests ```bash # run the tests make test ``` Build/Run Smee ```bash # make the binary make build # run Smee ./smee -h Smee is the DHCP and Network boot service for use in the Tinkerbell stack. USAGE smee [flags] FLAGS -log-level log level (debug, info) (default "info") -backend-file-enabled [backend] enable the file backend for DHCP and the HTTP iPXE script (default "false") -backend-file-path [backend] the hardware yaml file path for the file backend -backend-kube-api [backend] the Kubernetes API URL, used for in-cluster client construction, kube backend only -backend-kube-config [backend] the Kubernetes config file location, kube backend only -backend-kube-enabled [backend] enable the kubernetes backend for DHCP and the HTTP iPXE script (default "true") -backend-kube-namespace [backend] an optional Kubernetes namespace override to query hardware data from, kube backend only -backend-noop-enabled [backend] enable the noop backend for DHCP and the HTTP iPXE script (default "false") -dhcp-addr [dhcp] local IP:Port to listen on for DHCP requests (default "0.0.0.0:67") -dhcp-enabled [dhcp] enable DHCP server (default "true") -dhcp-http-ipxe-binary-host [dhcp] HTTP iPXE binaries host or IP to use in DHCP packets (default "172.17.0.3") -dhcp-http-ipxe-binary-path [dhcp] HTTP iPXE binaries path to use in DHCP packets (default "/ipxe/") -dhcp-http-ipxe-binary-port [dhcp] HTTP iPXE binaries port to use in DHCP packets (default "8080") -dhcp-http-ipxe-binary-scheme [dhcp] HTTP iPXE binaries scheme to use in DHCP packets (default "http") -dhcp-http-ipxe-script-host [dhcp] HTTP iPXE script host or IP to use in DHCP packets (default "172.17.0.3") -dhcp-http-ipxe-script-path [dhcp] HTTP iPXE script path to use in DHCP packets (default "/auto.ipxe") -dhcp-http-ipxe-script-port [dhcp] HTTP iPXE script port to use in DHCP packets (default "8080") -dhcp-http-ipxe-script-prepend-mac [dhcp] prepend the hardware MAC address to iPXE script URL base, http://1.2.3.4/auto.ipxe -> http://1.2.3.4/40:15:ff:89:cc:0e/auto.ipxe (default "true") -dhcp-http-ipxe-script-scheme [dhcp] HTTP iPXE script scheme to use in DHCP packets (default "http") -dhcp-http-ipxe-script-url [dhcp] HTTP iPXE script URL to use in DHCP packets, this overrides the flags for dhcp-http-ipxe-script-{scheme, host, port, path} -dhcp-iface [dhcp] interface to bind to for DHCP requests -dhcp-ip-for-packet [dhcp] IP address to use in DHCP packets (opt 54, etc) (default "172.17.0.3") -dhcp-mode [dhcp] DHCP mode (reservation, proxy, auto-proxy) (default "reservation") -dhcp-syslog-ip [dhcp] Syslog server IP address to use in DHCP packets (opt 7) (default "172.17.0.3") -dhcp-tftp-ip [dhcp] TFTP server IP address to use in DHCP packets (opt 66, etc) (default "172.17.0.3") -dhcp-tftp-port [dhcp] TFTP server port to use in DHCP packets (opt 66, etc) (default "69") -extra-kernel-args [http] extra set of kernel args (k=v k=v) that are appended to the kernel cmdline iPXE script -http-addr [http] local IP to listen on for iPXE HTTP script requests (default "172.17.0.3") -http-ipxe-binary-enabled [http] enable iPXE HTTP binary server (default "true") -http-ipxe-script-enabled [http] enable iPXE HTTP script server (default "true") -http-port [http] local port to listen on for iPXE HTTP script requests (default "8080") -ipxe-script-retries [http] number of retries to attempt when fetching kernel and initrd files in the iPXE script (default "0") -ipxe-script-retry-delay [http] delay (in seconds) between retries when fetching kernel and initrd files in the iPXE script (default "2") -osie-url [http] URL where OSIE (HookOS) images are located -tink-server [http] IP:Port for the Tink server -tink-server-tls [http] use TLS for Tink server (default "false") -trusted-proxies [http] comma separated list of trusted proxies in CIDR notation -iso-enabled [iso] enable patching an OSIE ISO (default "false") -iso-magic-string [iso] the string pattern to match for in the source ISO, defaults to the one defined in HookOS -iso-static-ipam-enabled [iso] enable static IPAM for HookOS (default "false") -iso-url [iso] an ISO source URL target for patching -otel-endpoint [otel] OpenTelemetry collector endpoint -otel-insecure [otel] OpenTelemetry collector insecure (default "true") -syslog-addr [syslog] local IP to listen on for Syslog messages (default "172.17.0.3") -syslog-enabled [syslog] enable Syslog server(receiver) (default "true") -syslog-port [syslog] local port to listen on for Syslog messages (default "514") -ipxe-script-patch [tftp/http] iPXE script fragment to patch into served iPXE binaries served via TFTP or HTTP -tftp-addr [tftp] local IP to listen on for iPXE TFTP binary requests (default "172.17.0.3") -tftp-block-size [tftp] TFTP block size a value between 512 (the default block size for TFTP) and 65456 (the max size a UDP packet payload can be) (default "512") -tftp-enabled [tftp] enable iPXE TFTP binary server) (default "true") -tftp-port [tftp] local port to listen on for iPXE TFTP binary requests (default "69") -tftp-timeout [tftp] iPXE TFTP binary server requests timeout (default "5s") ``` ### Developing using the file backend The quickest way to get started is `docker-compose up`. This will start Smee using the file backend. This uses the example Yaml file (hardware.yaml) in the `test/` directory. It also starts a client container that runs some tests. ```sh docker compose up --build # build images and start the network & services # it's fine to hit control-C twice for fast shutdown docker compose down # stop the network & containers ``` Alternatively Smee can be run by itself. It requires a few flags or environment variables for configuration. `test/hardware.yaml` should be safe enough for most developers to use on the command line locally without getting a call from your network administrator. That said, you might want to contact them before running a DHCP server on their network. Best to isolate it in Docker or a VM if you're not sure. ```sh # build the binary make build export SMEE_OSIE_URL= # For more info on the default OSIE (Hook) artifacts, please see https://github.com/tinkerbell/hook export SMEE_BACKEND_KUBE_ENABLED=false export SMEE_BACKEND_FILE_ENABLED=true export SMEE_BACKEND_FILE_PATH=./test/hardware.yaml export SMEE_EXTRA_KERNEL_ARGS="tink_worker_image=quay.io/tinkerbell/tink-worker:latest" # By default, Smee needs to bind to low ports (67, 69, 514) so it needs root. sudo -E ./cmd/smee/smee # clean up the environment variables unset SMEE_OSIE_URL unset SMEE_BACKEND_KUBE_ENABLED unset SMEE_BACKEND_FILE_ENABLED unset SMEE_BACKEND_FILE_PATH unset SMEE_EXTRA_KERNEL_ARGS ``` ================================================ FILE: RELEASING.md ================================================ # Releasing ## Process For version v0.x.y: 1. Create the annotated tag > NOTE: To use your GPG signature when pushing the tag, use `SIGN_TAG=1 ./contrib/tag-release.sh v0.x.y` instead) - `./contrib/tag-release.sh v0.x.y` 1. Push the tag to the GitHub repository. This will automatically trigger a [Github Action](https://github.com/tinkerbell/smee/actions) to create a release. > NOTE: `origin` should be the name of the remote pointing to `github.com/tinkerbell/smee` - `git push origin v0.x.y` 1. Review the release on GitHub. ### Permissions Releasing requires a particular set of permissions. - Tag push access to the GitHub repository ================================================ FILE: Tiltfile ================================================ load('ext://restart_process', 'docker_build_with_restart') load('ext://local_output', 'local_output') load('ext://helm_resource', 'helm_resource') local_resource('compile smee', cmd='make cmd/smee/smee-linux-amd64', deps=["go.mod", "go.sum", "internal", "Dockerfile", "cmd/smee/main.go", "cmd/smee/flag.go", "cmd/smee/backend.go"], ) docker_build_with_restart( 'quay.io/tinkerbell/smee', '.', dockerfile='Dockerfile', entrypoint=['/usr/bin/smee'], live_update=[ sync('cmd/smee/smee-linux-amd64', '/usr/bin/smee'), ], ) default_registry('ttl.sh/meohmy-dghentld') default_trusted_proxies = local_output("kubectl get nodes -o jsonpath='{.items[*].spec.podCIDR}' | tr ' ' ','") trusted_proxies = os.getenv('TRUSTED_PROXIES', default_trusted_proxies) lb_ip = os.getenv('LB_IP', '') stack_version = os.getenv('STACK_CHART_VERSION', '0.5.0') stack_location = os.getenv('STACK_LOCATION', 'oci://ghcr.io/tinkerbell/charts/stack') # or a local path like '/home/tink/repos/tinkerbell/charts/tinkerbell/stack' namespace = 'tink' if lb_ip == '': fail('Please set the LB_IP environment variable. This is required to deploy the stack.') # to use a KinD cluster, add a macvlan interface into the KinD docker container. for example: `docker network connect macvlan kind-control-plane` # Then uncomment the 2 interface lines below. helm_resource('stack', chart=stack_location, namespace=namespace, image_deps=['quay.io/tinkerbell/smee'], image_keys=[('smee.image')], flags=[ '--create-namespace', '--version=%s' % stack_version, '--set=global.trustedProxies={%s}' % trusted_proxies, '--set=global.publicIP=%s' % lb_ip, #'--set=stack.kubevip.interface=eth1', #'--set=stack.relay.sourceInterface=eth1', ], release_name='stack' ) ================================================ FILE: cmd/smee/backend.go ================================================ package main import ( "context" "github.com/go-logr/logr" "github.com/tinkerbell/smee/internal/backend/file" "github.com/tinkerbell/smee/internal/backend/kube" "github.com/tinkerbell/smee/internal/backend/noop" "github.com/tinkerbell/smee/internal/dhcp/handler" "github.com/tinkerbell/tink/api/v1alpha1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "k8s.io/client-go/scale/scheme" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/cluster" ) type Kube struct { // ConfigFilePath is the path to a kubernetes config file (kubeconfig). ConfigFilePath string // APIURL is the Kubernetes API URL. APIURL string // Namespace is an override for the Namespace the kubernetes client will watch. // The default is the Namespace the pod is running in. Namespace string Enabled bool } type File struct { // FilePath is the path to a JSON FilePath containing hardware data. FilePath string Enabled bool } type Noop struct { Enabled bool } func (n *Noop) backend() handler.BackendReader { return &noop.Backend{} } func (k *Kube) getClient() (*rest.Config, error) { loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() loadingRules.ExplicitPath = k.ConfigFilePath overrides := &clientcmd.ConfigOverrides{ ClusterInfo: clientcmdapi.Cluster{ Server: k.APIURL, }, Context: clientcmdapi.Context{ Namespace: k.Namespace, }, } loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) return loader.ClientConfig() } func (k *Kube) backend(ctx context.Context) (handler.BackendReader, error) { config, err := k.getClient() if err != nil { return nil, err } rs := runtime.NewScheme() if err := scheme.AddToScheme(rs); err != nil { return nil, err } if err := v1alpha1.AddToScheme(rs); err != nil { return nil, err } conf := func(opts *cluster.Options) { opts.Scheme = rs if k.Namespace != "" { opts.Cache.DefaultNamespaces = map[string]cache.Config{k.Namespace: {}} } } kb, err := kube.NewBackend(config, conf) if err != nil { return nil, err } go func() { err = kb.Start(ctx) if err != nil { panic(err) } }() return kb, nil } func (s *File) backend(ctx context.Context, logger logr.Logger) (handler.BackendReader, error) { f, err := file.NewWatcher(logger, s.FilePath) if err != nil { return nil, err } go f.Start(ctx) return f, nil } ================================================ FILE: cmd/smee/flag.go ================================================ package main import ( "errors" "flag" "fmt" "net" "os" "regexp" "sort" "strings" "text/tabwriter" "time" "golang.org/x/sys/unix" "github.com/peterbourgon/ff/v3" "github.com/peterbourgon/ff/v3/ffcli" "github.com/vishvananda/netlink" ) // customUsageFunc is a custom UsageFunc used for all commands. func customUsageFunc(c *ffcli.Command) string { var b strings.Builder if c.LongHelp != "" { fmt.Fprintf(&b, "%s\n\n", c.LongHelp) } fmt.Fprintf(&b, "USAGE\n") if c.ShortUsage != "" { fmt.Fprintf(&b, " %s\n", c.ShortUsage) } else { fmt.Fprintf(&b, " %s\n", c.Name) } fmt.Fprintf(&b, "\n") if len(c.Subcommands) > 0 { fmt.Fprintf(&b, "SUBCOMMANDS\n") tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) for _, subcommand := range c.Subcommands { fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp) } tw.Flush() fmt.Fprintf(&b, "\n") } if countFlags(c.FlagSet) > 0 { fmt.Fprintf(&b, "FLAGS\n") tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0) type flagUsage struct { name string usage string defaultValue string } flags := []flagUsage{} c.FlagSet.VisitAll(func(f *flag.Flag) { f1 := flagUsage{name: f.Name, usage: f.Usage, defaultValue: f.DefValue} flags = append(flags, f1) }) sort.SliceStable(flags, func(i, j int) bool { // sort by the service name between the brackets "[]" found in the usage string. r := regexp.MustCompile(`^\[(.*?)\]`) return r.FindString(flags[i].usage) < r.FindString(flags[j].usage) }) for _, elem := range flags { if elem.defaultValue != "" { fmt.Fprintf(tw, " -%s\t%s (default %q)\n", elem.name, elem.usage, elem.defaultValue) } else { fmt.Fprintf(tw, " -%s\t%s\n", elem.name, elem.usage) } } tw.Flush() fmt.Fprintf(&b, "\n") } return strings.TrimSpace(b.String()) + "\n" } func countFlags(fs *flag.FlagSet) (n int) { fs.VisitAll(func(*flag.Flag) { n++ }) return n } func syslogFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.syslog.enabled, "syslog-enabled", true, "[syslog] enable Syslog server(receiver)") fs.StringVar(&c.syslog.bindAddr, "syslog-addr", detectPublicIPv4(), "[syslog] local IP to listen on for Syslog messages") fs.IntVar(&c.syslog.bindPort, "syslog-port", 514, "[syslog] local port to listen on for Syslog messages") } func tftpFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.tftp.enabled, "tftp-enabled", true, "[tftp] enable iPXE TFTP binary server)") fs.StringVar(&c.tftp.bindAddr, "tftp-addr", detectPublicIPv4(), "[tftp] local IP to listen on for iPXE TFTP binary requests") fs.IntVar(&c.tftp.bindPort, "tftp-port", 69, "[tftp] local port to listen on for iPXE TFTP binary requests") fs.DurationVar(&c.tftp.timeout, "tftp-timeout", time.Second*5, "[tftp] iPXE TFTP binary server requests timeout") fs.StringVar(&c.tftp.ipxeScriptPatch, "ipxe-script-patch", "", "[tftp/http] iPXE script fragment to patch into served iPXE binaries served via TFTP or HTTP") fs.IntVar(&c.tftp.blockSize, "tftp-block-size", 512, "[tftp] TFTP block size a value between 512 (the default block size for TFTP) and 65456 (the max size a UDP packet payload can be)") } func ipxeHTTPBinaryFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.ipxeHTTPBinary.enabled, "http-ipxe-binary-enabled", true, "[http] enable iPXE HTTP binary server") } func ipxeHTTPScriptFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.ipxeHTTPScript.enabled, "http-ipxe-script-enabled", true, "[http] enable iPXE HTTP script server") fs.StringVar(&c.ipxeHTTPScript.bindAddr, "http-addr", detectPublicIPv4(), "[http] local IP to listen on for iPXE HTTP script requests") fs.IntVar(&c.ipxeHTTPScript.bindPort, "http-port", 8080, "[http] local port to listen on for iPXE HTTP script requests") fs.StringVar(&c.ipxeHTTPScript.extraKernelArgs, "extra-kernel-args", "", "[http] extra set of kernel args (k=v k=v) that are appended to the kernel cmdline iPXE script") fs.StringVar(&c.ipxeHTTPScript.trustedProxies, "trusted-proxies", "", "[http] comma separated list of trusted proxies in CIDR notation") fs.StringVar(&c.ipxeHTTPScript.hookURL, "osie-url", "", "[http] URL where OSIE (HookOS) images are located") fs.StringVar(&c.ipxeHTTPScript.tinkServer, "tink-server", "", "[http] IP:Port for the Tink server") fs.BoolVar(&c.ipxeHTTPScript.tinkServerUseTLS, "tink-server-tls", false, "[http] use TLS for Tink server") fs.BoolVar(&c.ipxeHTTPScript.tinkServerInsecureTLS, "tink-server-insecure-tls", false, "[http] use insecure TLS for Tink server") fs.IntVar(&c.ipxeHTTPScript.retries, "ipxe-script-retries", 0, "[http] number of retries to attempt when fetching kernel and initrd files in the iPXE script") fs.IntVar(&c.ipxeHTTPScript.retryDelay, "ipxe-script-retry-delay", 2, "[http] delay (in seconds) between retries when fetching kernel and initrd files in the iPXE script") } func dhcpFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.dhcp.enabled, "dhcp-enabled", true, "[dhcp] enable DHCP server") fs.StringVar(&c.dhcp.mode, "dhcp-mode", dhcpModeReservation.String(), fmt.Sprintf("[dhcp] DHCP mode (%s, %s, %s)", dhcpModeReservation, dhcpModeProxy, dhcpModeAutoProxy)) fs.StringVar(&c.dhcp.bindAddr, "dhcp-addr", "0.0.0.0:67", "[dhcp] local IP:Port to listen on for DHCP requests") fs.StringVar(&c.dhcp.bindInterface, "dhcp-iface", "", "[dhcp] interface to bind to for DHCP requests") fs.StringVar(&c.dhcp.ipForPacket, "dhcp-ip-for-packet", detectPublicIPv4(), "[dhcp] IP address to use in DHCP packets (opt 54, etc)") fs.StringVar(&c.dhcp.syslogIP, "dhcp-syslog-ip", detectPublicIPv4(), "[dhcp] Syslog server IP address to use in DHCP packets (opt 7)") fs.StringVar(&c.dhcp.tftpIP, "dhcp-tftp-ip", detectPublicIPv4(), "[dhcp] TFTP server IP address to use in DHCP packets (opt 66, etc)") fs.IntVar(&c.dhcp.tftpPort, "dhcp-tftp-port", 69, "[dhcp] TFTP server port to use in DHCP packets (opt 66, etc)") fs.StringVar(&c.dhcp.httpIpxeBinaryURL.Scheme, "dhcp-http-ipxe-binary-scheme", "http", "[dhcp] HTTP iPXE binaries scheme to use in DHCP packets") fs.StringVar(&c.dhcp.httpIpxeBinaryURL.Host, "dhcp-http-ipxe-binary-host", detectPublicIPv4(), "[dhcp] HTTP iPXE binaries host or IP to use in DHCP packets") fs.IntVar(&c.dhcp.httpIpxeBinaryURL.Port, "dhcp-http-ipxe-binary-port", 8080, "[dhcp] HTTP iPXE binaries port to use in DHCP packets") fs.StringVar(&c.dhcp.httpIpxeBinaryURL.Path, "dhcp-http-ipxe-binary-path", "/ipxe/", "[dhcp] HTTP iPXE binaries path to use in DHCP packets") fs.StringVar(&c.dhcp.httpIpxeScript.Scheme, "dhcp-http-ipxe-script-scheme", "http", "[dhcp] HTTP iPXE script scheme to use in DHCP packets") fs.StringVar(&c.dhcp.httpIpxeScript.Host, "dhcp-http-ipxe-script-host", detectPublicIPv4(), "[dhcp] HTTP iPXE script host or IP to use in DHCP packets") fs.IntVar(&c.dhcp.httpIpxeScript.Port, "dhcp-http-ipxe-script-port", 8080, "[dhcp] HTTP iPXE script port to use in DHCP packets") fs.StringVar(&c.dhcp.httpIpxeScript.Path, "dhcp-http-ipxe-script-path", "/auto.ipxe", "[dhcp] HTTP iPXE script path to use in DHCP packets") fs.StringVar(&c.dhcp.httpIpxeScriptURL, "dhcp-http-ipxe-script-url", "", "[dhcp] HTTP iPXE script URL to use in DHCP packets, this overrides the flags for dhcp-http-ipxe-script-{scheme, host, port, path}") fs.BoolVar(&c.dhcp.httpIpxeScript.injectMacAddress, "dhcp-http-ipxe-script-prepend-mac", true, "[dhcp] prepend the hardware MAC address to iPXE script URL base, http://1.2.3.4/auto.ipxe -> http://1.2.3.4/40:15:ff:89:cc:0e/auto.ipxe") } func backendFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.backends.file.Enabled, "backend-file-enabled", false, "[backend] enable the file backend for DHCP and the HTTP iPXE script") fs.StringVar(&c.backends.file.FilePath, "backend-file-path", "", "[backend] the hardware yaml file path for the file backend") fs.BoolVar(&c.backends.kubernetes.Enabled, "backend-kube-enabled", true, "[backend] enable the kubernetes backend for DHCP and the HTTP iPXE script") fs.StringVar(&c.backends.kubernetes.ConfigFilePath, "backend-kube-config", "", "[backend] the Kubernetes config file location, kube backend only") fs.StringVar(&c.backends.kubernetes.APIURL, "backend-kube-api", "", "[backend] the Kubernetes API URL, used for in-cluster client construction, kube backend only") fs.StringVar(&c.backends.kubernetes.Namespace, "backend-kube-namespace", "", "[backend] an optional Kubernetes namespace override to query hardware data from, kube backend only") fs.BoolVar(&c.backends.Noop.Enabled, "backend-noop-enabled", false, "[backend] enable the noop backend for DHCP and the HTTP iPXE script") } func otelFlags(c *config, fs *flag.FlagSet) { fs.StringVar(&c.otel.endpoint, "otel-endpoint", "", "[otel] OpenTelemetry collector endpoint") fs.BoolVar(&c.otel.insecure, "otel-insecure", true, "[otel] OpenTelemetry collector insecure") } func isoFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.iso.enabled, "iso-enabled", false, "[iso] enable patching an OSIE ISO") fs.StringVar(&c.iso.url, "iso-url", "", "[iso] an ISO source URL target for patching") fs.StringVar(&c.iso.magicString, "iso-magic-string", "", "[iso] the string pattern to match for in the source ISO, defaults to the one defined in HookOS") fs.BoolVar(&c.iso.staticIPAMEnabled, "iso-static-ipam-enabled", false, "[iso] enable static IPAM for HookOS") } func setFlags(c *config, fs *flag.FlagSet) { fs.StringVar(&c.logLevel, "log-level", "info", "log level (debug, info)") dhcpFlags(c, fs) tftpFlags(c, fs) ipxeHTTPBinaryFlags(c, fs) ipxeHTTPScriptFlags(c, fs) syslogFlags(c, fs) backendFlags(c, fs) otelFlags(c, fs) isoFlags(c, fs) } func newCLI(cfg *config, fs *flag.FlagSet) *ffcli.Command { setFlags(cfg, fs) return &ffcli.Command{ Name: name, ShortUsage: "smee [flags]", LongHelp: "Smee is the DHCP and Network boot service for use in the Tinkerbell stack.", FlagSet: fs, Options: []ff.Option{ff.WithEnvVarPrefix(name)}, UsageFunc: customUsageFunc, } } // ipByInterface returns the first IPv4 address on the named network interface. func ipByInterface(name string) string { iface, err := net.InterfaceByName(name) if err != nil { return "" } addrs, err := iface.Addrs() if err != nil { return "" } for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) if !ok { continue } if ipNet.IP.To4() != nil { return ipNet.IP.String() } } return "" } func detectPublicIPv4() string { if netint := os.Getenv("SMEE_PUBLIC_IP_INTERFACE"); netint != "" { if ip := ipByInterface(netint); ip != "" { return ip } } ipDgw, err := autoDetectPublicIpv4WithDefaultGateway() if err == nil { return ipDgw.String() } ip, err := autoDetectPublicIPv4() if err != nil { return "" } return ip.String() } func autoDetectPublicIPv4() (net.IP, error) { addrs, err := net.InterfaceAddrs() if err != nil { return nil, fmt.Errorf("unable to auto-detect public IPv4: %w", err) } for _, addr := range addrs { ip, ok := addr.(*net.IPNet) if !ok { continue } v4 := ip.IP.To4() if v4 == nil || !v4.IsGlobalUnicast() { continue } return v4, nil } return nil, errors.New("unable to auto-detect public IPv4") } // autoDetectPublicIpv4WithDefaultGateway finds the network interface with a default gateway // and returns the first net.IP address of the first interface that has a default gateway. func autoDetectPublicIpv4WithDefaultGateway() (net.IP, error) { // Get the list of routes from netlink routes, err := netlink.RouteList(nil, unix.AF_INET) if err != nil { return nil, fmt.Errorf("failed to list routes: %v", err) } // Find the route with a default gateway (Dst == nil) for _, route := range routes { if route.Dst == nil && route.Gw != nil { // Get the interface associated with this route iface, err := net.InterfaceByIndex(route.LinkIndex) if err != nil { return nil, fmt.Errorf("failed to get interface by index: %v", err) } // Get the addresses assigned to this interface addrs, err := iface.Addrs() if err != nil { return nil, fmt.Errorf("failed to get addresses for interface %v: %v", iface.Name, err) } // Return the first valid IP address found for _, addr := range addrs { if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { if ipNet.IP.To4() != nil { return ipNet.IP, nil } } } } } return nil, fmt.Errorf("no default gateway found") } ================================================ FILE: cmd/smee/flag_test.go ================================================ package main import ( "flag" "fmt" "testing" "time" "github.com/google/go-cmp/cmp" ) func TestParser(t *testing.T) { want := config{ syslog: syslogConfig{ enabled: true, bindAddr: "192.168.2.4", bindPort: 514, }, tftp: tftp{ blockSize: 512, enabled: true, timeout: 5 * time.Second, bindAddr: "192.168.2.4", bindPort: 69, }, ipxeHTTPBinary: ipxeHTTPBinary{ enabled: true, }, ipxeHTTPScript: ipxeHTTPScript{ enabled: true, bindAddr: "192.168.2.4", bindPort: 8080, retryDelay: 2, }, dhcp: dhcpConfig{ enabled: true, mode: "reservation", bindAddr: "0.0.0.0:67", ipForPacket: "192.168.2.4", syslogIP: "192.168.2.4", tftpIP: "192.168.2.4", tftpPort: 69, httpIpxeBinaryURL: urlBuilder{ Scheme: "http", Host: "192.168.2.4", Port: 8080, Path: "/ipxe/", }, httpIpxeScript: httpIpxeScript{ urlBuilder: urlBuilder{ Scheme: "http", Host: "192.168.2.4", Port: 8080, Path: "/auto.ipxe", }, injectMacAddress: true, }, }, iso: isoConfig{ enabled: true, url: "http://10.10.10.10:8787/hook.iso", magicString: magicString, }, logLevel: "info", backends: dhcpBackends{ file: File{}, kubernetes: Kube{Enabled: true}, }, otel: otelConfig{ insecure: true, }, } got := config{} fs := flag.NewFlagSet(name, flag.ContinueOnError) args := []string{ "-log-level", "info", "-syslog-addr", "192.168.2.4", "-tftp-addr", "192.168.2.4", "-http-addr", "192.168.2.4", "-dhcp-ip-for-packet", "192.168.2.4", "-dhcp-syslog-ip", "192.168.2.4", "-dhcp-tftp-ip", "192.168.2.4", "-dhcp-http-ipxe-binary-host", "192.168.2.4", "-dhcp-http-ipxe-script-host", "192.168.2.4", "-iso-enabled=true", "-iso-magic-string", magicString, "-iso-url", "http://10.10.10.10:8787/hook.iso", } cli := newCLI(&got, fs) cli.Parse(args) opts := cmp.Options{ cmp.AllowUnexported(config{}), cmp.AllowUnexported(syslogConfig{}), cmp.AllowUnexported(tftp{}), cmp.AllowUnexported(ipxeHTTPBinary{}), cmp.AllowUnexported(ipxeHTTPScript{}), cmp.AllowUnexported(dhcpConfig{}), cmp.AllowUnexported(dhcpBackends{}), cmp.AllowUnexported(httpIpxeScript{}), cmp.AllowUnexported(isoConfig{}), cmp.AllowUnexported(otelConfig{}), cmp.AllowUnexported(urlBuilder{}), } if diff := cmp.Diff(want, got, opts); diff != "" { t.Fatal(diff) } } func TestCustomUsageFunc(t *testing.T) { defaultIP := detectPublicIPv4() want := fmt.Sprintf(`Smee is the DHCP and Network boot service for use in the Tinkerbell stack. USAGE smee [flags] FLAGS -log-level log level (debug, info) (default "info") -backend-file-enabled [backend] enable the file backend for DHCP and the HTTP iPXE script (default "false") -backend-file-path [backend] the hardware yaml file path for the file backend -backend-kube-api [backend] the Kubernetes API URL, used for in-cluster client construction, kube backend only -backend-kube-config [backend] the Kubernetes config file location, kube backend only -backend-kube-enabled [backend] enable the kubernetes backend for DHCP and the HTTP iPXE script (default "true") -backend-kube-namespace [backend] an optional Kubernetes namespace override to query hardware data from, kube backend only -backend-noop-enabled [backend] enable the noop backend for DHCP and the HTTP iPXE script (default "false") -dhcp-addr [dhcp] local IP:Port to listen on for DHCP requests (default "0.0.0.0:67") -dhcp-enabled [dhcp] enable DHCP server (default "true") -dhcp-http-ipxe-binary-host [dhcp] HTTP iPXE binaries host or IP to use in DHCP packets (default "%[1]v") -dhcp-http-ipxe-binary-path [dhcp] HTTP iPXE binaries path to use in DHCP packets (default "/ipxe/") -dhcp-http-ipxe-binary-port [dhcp] HTTP iPXE binaries port to use in DHCP packets (default "8080") -dhcp-http-ipxe-binary-scheme [dhcp] HTTP iPXE binaries scheme to use in DHCP packets (default "http") -dhcp-http-ipxe-script-host [dhcp] HTTP iPXE script host or IP to use in DHCP packets (default "%[1]v") -dhcp-http-ipxe-script-path [dhcp] HTTP iPXE script path to use in DHCP packets (default "/auto.ipxe") -dhcp-http-ipxe-script-port [dhcp] HTTP iPXE script port to use in DHCP packets (default "8080") -dhcp-http-ipxe-script-prepend-mac [dhcp] prepend the hardware MAC address to iPXE script URL base, http://1.2.3.4/auto.ipxe -> http://1.2.3.4/40:15:ff:89:cc:0e/auto.ipxe (default "true") -dhcp-http-ipxe-script-scheme [dhcp] HTTP iPXE script scheme to use in DHCP packets (default "http") -dhcp-http-ipxe-script-url [dhcp] HTTP iPXE script URL to use in DHCP packets, this overrides the flags for dhcp-http-ipxe-script-{scheme, host, port, path} -dhcp-iface [dhcp] interface to bind to for DHCP requests -dhcp-ip-for-packet [dhcp] IP address to use in DHCP packets (opt 54, etc) (default "%[1]v") -dhcp-mode [dhcp] DHCP mode (reservation, proxy, auto-proxy) (default "reservation") -dhcp-syslog-ip [dhcp] Syslog server IP address to use in DHCP packets (opt 7) (default "%[1]v") -dhcp-tftp-ip [dhcp] TFTP server IP address to use in DHCP packets (opt 66, etc) (default "%[1]v") -dhcp-tftp-port [dhcp] TFTP server port to use in DHCP packets (opt 66, etc) (default "69") -extra-kernel-args [http] extra set of kernel args (k=v k=v) that are appended to the kernel cmdline iPXE script -http-addr [http] local IP to listen on for iPXE HTTP script requests (default "%[1]v") -http-ipxe-binary-enabled [http] enable iPXE HTTP binary server (default "true") -http-ipxe-script-enabled [http] enable iPXE HTTP script server (default "true") -http-port [http] local port to listen on for iPXE HTTP script requests (default "8080") -ipxe-script-retries [http] number of retries to attempt when fetching kernel and initrd files in the iPXE script (default "0") -ipxe-script-retry-delay [http] delay (in seconds) between retries when fetching kernel and initrd files in the iPXE script (default "2") -osie-url [http] URL where OSIE (HookOS) images are located -tink-server [http] IP:Port for the Tink server -tink-server-insecure-tls [http] use insecure TLS for Tink server (default "false") -tink-server-tls [http] use TLS for Tink server (default "false") -trusted-proxies [http] comma separated list of trusted proxies in CIDR notation -iso-enabled [iso] enable patching an OSIE ISO (default "false") -iso-magic-string [iso] the string pattern to match for in the source ISO, defaults to the one defined in HookOS -iso-static-ipam-enabled [iso] enable static IPAM for HookOS (default "false") -iso-url [iso] an ISO source URL target for patching -otel-endpoint [otel] OpenTelemetry collector endpoint -otel-insecure [otel] OpenTelemetry collector insecure (default "true") -syslog-addr [syslog] local IP to listen on for Syslog messages (default "%[1]v") -syslog-enabled [syslog] enable Syslog server(receiver) (default "true") -syslog-port [syslog] local port to listen on for Syslog messages (default "514") -ipxe-script-patch [tftp/http] iPXE script fragment to patch into served iPXE binaries served via TFTP or HTTP -tftp-addr [tftp] local IP to listen on for iPXE TFTP binary requests (default "%[1]v") -tftp-block-size [tftp] TFTP block size a value between 512 (the default block size for TFTP) and 65456 (the max size a UDP packet payload can be) (default "512") -tftp-enabled [tftp] enable iPXE TFTP binary server) (default "true") -tftp-port [tftp] local port to listen on for iPXE TFTP binary requests (default "69") -tftp-timeout [tftp] iPXE TFTP binary server requests timeout (default "5s") `, defaultIP) c := &config{} fs := flag.NewFlagSet(name, flag.ContinueOnError) cli := newCLI(c, fs) got := customUsageFunc(cli) if diff := cmp.Diff(want, got); diff != "" { t.Fatal(diff) } } ================================================ FILE: cmd/smee/main.go ================================================ package main import ( "context" "errors" "flag" "fmt" "log/slog" "net" "net/netip" "net/url" "os" "os/signal" "path" "path/filepath" "strings" "syscall" "time" "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4/server4" "github.com/tinkerbell/ipxedust" "github.com/tinkerbell/ipxedust/ihttp" "github.com/tinkerbell/smee/internal/dhcp/handler" "github.com/tinkerbell/smee/internal/dhcp/handler/proxy" "github.com/tinkerbell/smee/internal/dhcp/handler/reservation" "github.com/tinkerbell/smee/internal/dhcp/server" "github.com/tinkerbell/smee/internal/ipxe/http" "github.com/tinkerbell/smee/internal/ipxe/script" "github.com/tinkerbell/smee/internal/iso" "github.com/tinkerbell/smee/internal/metric" "github.com/tinkerbell/smee/internal/otel" "github.com/tinkerbell/smee/internal/syslog" "golang.org/x/sync/errgroup" ) var ( // GitRev is the git revision of the build. It is set by the Makefile. GitRev = "unknown (use make)" startTime = time.Now() ) const ( name = "smee" dhcpModeProxy dhcpMode = "proxy" dhcpModeReservation dhcpMode = "reservation" dhcpModeAutoProxy dhcpMode = "auto-proxy" // magicString comes from the HookOS repo // ref: https://github.com/tinkerbell/hook/blob/main/linuxkit-templates/hook.template.yaml magicString = `464vn90e7rbj08xbwdjejmdf4it17c5zfzjyfhthbh19eij201hjgit021bmpdb9ctrc87x2ymc8e7icu4ffi15x1hah9iyaiz38ckyap8hwx2vt5rm44ixv4hau8iw718q5yd019um5dt2xpqqa2rjtdypzr5v1gun8un110hhwp8cex7pqrh2ivh0ynpm4zkkwc8wcn367zyethzy7q8hzudyeyzx3cgmxqbkh825gcak7kxzjbgjajwizryv7ec1xm2h0hh7pz29qmvtgfjj1vphpgq1zcbiiehv52wrjy9yq473d9t1rvryy6929nk435hfx55du3ih05kn5tju3vijreru1p6knc988d4gfdz28eragvryq5x8aibe5trxd0t6t7jwxkde34v6pj1khmp50k6qqj3nzgcfzabtgqkmeqhdedbvwf3byfdma4nkv3rcxugaj2d0ru30pa2fqadjqrtjnv8bu52xzxv7irbhyvygygxu1nt5z4fh9w1vwbdcmagep26d298zknykf2e88kumt59ab7nq79d8amnhhvbexgh48e8qc61vq2e9qkihzt1twk1ijfgw70nwizai15iqyted2dt9gfmf2gg7amzufre79hwqkddc1cd935ywacnkrnak6r7xzcz7zbmq3kt04u2hg1iuupid8rt4nyrju51e6uejb2ruu36g9aibmz3hnmvazptu8x5tyxk820g2cdpxjdij766bt2n3djur7v623a2v44juyfgz80ekgfb9hkibpxh3zgknw8a34t4jifhf116x15cei9hwch0fye3xyq0acuym8uhitu5evc4rag3ui0fny3qg4kju7zkfyy8hwh537urd5uixkzwu5bdvafz4jmv7imypj543xg5em8jk8cgk7c4504xdd5e4e71ihaumt6u5u2t1w7um92fepzae8p0vq93wdrd1756npu1pziiur1payc7kmdwyxg3hj5n4phxbc29x0tcddamjrwt260b0w` ) type config struct { syslog syslogConfig tftp tftp ipxeHTTPBinary ipxeHTTPBinary ipxeHTTPScript ipxeHTTPScript dhcp dhcpConfig iso isoConfig // loglevel is the log level for smee. logLevel string backends dhcpBackends otel otelConfig } type syslogConfig struct { enabled bool bindAddr string bindPort int } type tftp struct { bindAddr string bindPort int blockSize int enabled bool ipxeScriptPatch string timeout time.Duration } type ipxeHTTPBinary struct { enabled bool } type ipxeHTTPScript struct { enabled bool bindAddr string bindPort int extraKernelArgs string hookURL string tinkServer string tinkServerUseTLS bool tinkServerInsecureTLS bool trustedProxies string retries int retryDelay int } type dhcpMode string type dhcpConfig struct { enabled bool mode string bindAddr string bindInterface string ipForPacket string syslogIP string tftpIP string tftpPort int httpIpxeBinaryURL urlBuilder httpIpxeScript httpIpxeScript httpIpxeScriptURL string } type urlBuilder struct { Scheme string Host string Port int Path string } type httpIpxeScript struct { urlBuilder // injectMacAddress will prepend the hardware mac address to the ipxe script URL file name. // For example: http://1.2.3.4/my/loc/auto.ipxe -> http://1.2.3.4/my/loc/40:15:ff:89:cc:0e/auto.ipxe // Setting this to false is useful when you are not using the auto.ipxe script in Smee. injectMacAddress bool } type dhcpBackends struct { file File kubernetes Kube Noop Noop } type otelConfig struct { endpoint string insecure bool } type isoConfig struct { enabled bool url string magicString string staticIPAMEnabled bool } func main() { cfg := &config{} cli := newCLI(cfg, flag.NewFlagSet(name, flag.ExitOnError)) _ = cli.Parse(os.Args[1:]) log := defaultLogger(cfg.logLevel) log.Info("starting", "version", GitRev) ctx, done := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGHUP, syscall.SIGTERM) defer done() oCfg := otel.Config{ Servicename: "smee", Endpoint: cfg.otel.endpoint, Insecure: cfg.otel.insecure, Logger: log, } ctx, otelShutdown, err := otel.Init(ctx, oCfg) if err != nil { log.Error(err, "failed to initialize OpenTelemetry") panic(err) } defer otelShutdown() metric.Init() g, ctx := errgroup.WithContext(ctx) // syslog if cfg.syslog.enabled { addr := fmt.Sprintf("%s:%d", cfg.syslog.bindAddr, cfg.syslog.bindPort) log.Info("starting syslog server", "bind_addr", addr) g.Go(func() error { if err := syslog.StartReceiver(ctx, log, addr, 1); err != nil { log.Error(err, "syslog server failure") return err } <-ctx.Done() log.Info("syslog server stopped") return nil }) } // tftp if cfg.tftp.enabled { tftpServer := &ipxedust.Server{ Log: log.WithValues("service", "github.com/tinkerbell/smee").WithName("github.com/tinkerbell/ipxedust"), HTTP: ipxedust.ServerSpec{Disabled: true}, // disabled because below we use the http handlerfunc instead. EnableTFTPSinglePort: true, } tftpServer.EnableTFTPSinglePort = true addr := fmt.Sprintf("%s:%d", cfg.tftp.bindAddr, cfg.tftp.bindPort) if ip, err := netip.ParseAddrPort(addr); err == nil { tftpServer.TFTP = ipxedust.ServerSpec{ Disabled: false, Addr: ip, Timeout: cfg.tftp.timeout, Patch: []byte(cfg.tftp.ipxeScriptPatch), BlockSize: cfg.tftp.blockSize, } // start the ipxe binary tftp server log.Info("starting tftp server", "bind_addr", addr) g.Go(func() error { return tftpServer.ListenAndServe(ctx) }) } else { log.Error(err, "invalid bind address") panic(fmt.Errorf("invalid bind address: %w", err)) } } handlers := http.HandlerMapping{} // http ipxe binaries if cfg.ipxeHTTPBinary.enabled { // serve ipxe binaries from the "/ipxe/" URI. handlers["/ipxe/"] = ihttp.Handler{ Log: log.WithValues("service", "github.com/tinkerbell/smee").WithName("github.com/tinkerbell/ipxedust"), Patch: []byte(cfg.tftp.ipxeScriptPatch), }.Handle } // http ipxe script if cfg.ipxeHTTPScript.enabled { br, err := cfg.backend(ctx, log) if err != nil { panic(fmt.Errorf("failed to create backend: %w", err)) } jh := script.Handler{ Logger: log, Backend: br, OSIEURL: cfg.ipxeHTTPScript.hookURL, ExtraKernelParams: strings.Split(cfg.ipxeHTTPScript.extraKernelArgs, " "), PublicSyslogFQDN: cfg.dhcp.syslogIP, TinkServerTLS: cfg.ipxeHTTPScript.tinkServerUseTLS, TinkServerInsecureTLS: cfg.ipxeHTTPScript.tinkServerInsecureTLS, TinkServerGRPCAddr: cfg.ipxeHTTPScript.tinkServer, IPXEScriptRetries: cfg.ipxeHTTPScript.retries, IPXEScriptRetryDelay: cfg.ipxeHTTPScript.retryDelay, StaticIPXEEnabled: (dhcpMode(cfg.dhcp.mode) == dhcpModeAutoProxy), } // serve ipxe script from the "/" URI. handlers["/"] = jh.HandlerFunc() } if cfg.iso.enabled { br, err := cfg.backend(ctx, log) if err != nil { panic(fmt.Errorf("failed to create backend: %w", err)) } ih := iso.Handler{ Logger: log, Backend: br, SourceISO: cfg.iso.url, ExtraKernelParams: strings.Split(cfg.ipxeHTTPScript.extraKernelArgs, " "), Syslog: cfg.dhcp.syslogIP, TinkServerTLS: cfg.ipxeHTTPScript.tinkServerUseTLS, TinkServerGRPCAddr: cfg.ipxeHTTPScript.tinkServer, StaticIPAMEnabled: cfg.iso.staticIPAMEnabled, MagicString: func() string { if cfg.iso.magicString == "" { return magicString } return cfg.iso.magicString }(), } isoHandler, err := ih.HandlerFunc() if err != nil { panic(fmt.Errorf("failed to create iso handler: %w", err)) } handlers["/iso/"] = isoHandler } if len(handlers) > 0 { // start the http server for ipxe binaries and scripts tp := parseTrustedProxies(cfg.ipxeHTTPScript.trustedProxies) httpServer := &http.Config{ GitRev: GitRev, StartTime: startTime, Logger: log, TrustedProxies: tp, } bindAddr := fmt.Sprintf("%s:%d", cfg.ipxeHTTPScript.bindAddr, cfg.ipxeHTTPScript.bindPort) log.Info("serving http", "addr", bindAddr, "trusted_proxies", tp) g.Go(func() error { return httpServer.ServeHTTP(ctx, bindAddr, handlers) }) } // dhcp serving if cfg.dhcp.enabled { dh, err := cfg.dhcpHandler(ctx, log) if err != nil { log.Error(err, "failed to create dhcp listener") panic(fmt.Errorf("failed to create dhcp listener: %w", err)) } log.Info("starting dhcp server", "bind_addr", cfg.dhcp.bindAddr) g.Go(func() error { bindAddr, err := netip.ParseAddrPort(cfg.dhcp.bindAddr) if err != nil { panic(fmt.Errorf("invalid tftp address for DHCP server: %w", err)) } conn, err := server4.NewIPv4UDPConn(cfg.dhcp.bindInterface, net.UDPAddrFromAddrPort(bindAddr)) if err != nil { panic(err) } defer conn.Close() ds := &server.DHCP{Logger: log, Conn: conn, Handlers: []server.Handler{dh}} return ds.Serve(ctx) }) } if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) { log.Error(err, "failed running all Smee services") panic(err) } log.Info("smee is shutting down") } func numTrue(b ...bool) int { n := 0 for _, v := range b { if v { n++ } } return n } func (c *config) backend(ctx context.Context, log logr.Logger) (handler.BackendReader, error) { if c.backends.file.Enabled || c.backends.Noop.Enabled { // the kubernetes backend is enabled by default so we disable it // if another backend is enabled so that users don't have to explicitly // set the CLI flag to disable it when using another backend. c.backends.kubernetes.Enabled = false } var be handler.BackendReader switch { case numTrue(c.backends.file.Enabled, c.backends.kubernetes.Enabled, c.backends.Noop.Enabled) > 1: return nil, errors.New("only one backend can be enabled at a time") case c.backends.Noop.Enabled: if c.dhcp.mode != string(dhcpModeAutoProxy) { return nil, errors.New("noop backend can only be used with --dhcp-mode=auto-proxy") } be = c.backends.Noop.backend() case c.backends.file.Enabled: b, err := c.backends.file.backend(ctx, log) if err != nil { return nil, fmt.Errorf("failed to create file backend: %w", err) } be = b default: // default backend is kubernetes b, err := c.backends.kubernetes.backend(ctx) if err != nil { return nil, fmt.Errorf("failed to create kubernetes backend: %w", err) } be = b } return be, nil } func (c *config) dhcpHandler(ctx context.Context, log logr.Logger) (server.Handler, error) { // 1. create the handler // 2. create the backend // 3. add the backend to the handler pktIP, err := netip.ParseAddr(c.dhcp.ipForPacket) if err != nil { return nil, fmt.Errorf("invalid bind address: %w", err) } tftpIP, err := netip.ParseAddrPort(fmt.Sprintf("%s:%d", c.dhcp.tftpIP, c.dhcp.tftpPort)) if err != nil { return nil, fmt.Errorf("invalid tftp address for DHCP server: %w", err) } httpBinaryURL := &url.URL{ Scheme: c.dhcp.httpIpxeBinaryURL.Scheme, Host: fmt.Sprintf("%s:%d", c.dhcp.httpIpxeBinaryURL.Host, c.dhcp.httpIpxeBinaryURL.Port), Path: c.dhcp.httpIpxeBinaryURL.Path, } if _, err := url.Parse(httpBinaryURL.String()); err != nil { return nil, fmt.Errorf("invalid http ipxe binary url: %w", err) } var httpScriptURL *url.URL if c.dhcp.httpIpxeScriptURL != "" { httpScriptURL, err = url.Parse(c.dhcp.httpIpxeScriptURL) if err != nil { return nil, fmt.Errorf("invalid http ipxe script url: %w", err) } } else { httpScriptURL = &url.URL{ Scheme: c.dhcp.httpIpxeScript.Scheme, Host: func() string { switch c.dhcp.httpIpxeScript.Scheme { case "http": if c.dhcp.httpIpxeScript.Port == 80 { return c.dhcp.httpIpxeScript.Host } case "https": if c.dhcp.httpIpxeScript.Port == 443 { return c.dhcp.httpIpxeScript.Host } } return fmt.Sprintf("%s:%d", c.dhcp.httpIpxeScript.Host, c.dhcp.httpIpxeScript.Port) }(), Path: c.dhcp.httpIpxeScript.Path, } } if _, err := url.Parse(httpScriptURL.String()); err != nil { return nil, fmt.Errorf("invalid http ipxe script url: %w", err) } ipxeScript := func(*dhcpv4.DHCPv4) *url.URL { return httpScriptURL } if c.dhcp.httpIpxeScript.injectMacAddress { ipxeScript = func(d *dhcpv4.DHCPv4) *url.URL { u := *httpScriptURL p := path.Base(u.Path) u.Path = path.Join(path.Dir(u.Path), d.ClientHWAddr.String(), p) return &u } } backend, err := c.backend(ctx, log) if err != nil { return nil, fmt.Errorf("failed to create backend: %w", err) } switch dhcpMode(c.dhcp.mode) { case dhcpModeReservation: syslogIP, err := netip.ParseAddr(c.dhcp.syslogIP) if err != nil { return nil, fmt.Errorf("invalid syslog address: %w", err) } dh := &reservation.Handler{ Backend: backend, IPAddr: pktIP, Log: log, Netboot: reservation.Netboot{ IPXEBinServerTFTP: tftpIP, IPXEBinServerHTTP: httpBinaryURL, IPXEScriptURL: ipxeScript, Enabled: true, }, OTELEnabled: true, SyslogAddr: syslogIP, } return dh, nil case dhcpModeProxy: dh := &proxy.Handler{ Backend: backend, IPAddr: pktIP, Log: log, Netboot: proxy.Netboot{ IPXEBinServerTFTP: tftpIP, IPXEBinServerHTTP: httpBinaryURL, IPXEScriptURL: ipxeScript, Enabled: true, }, OTELEnabled: true, AutoProxyEnabled: false, } return dh, nil case dhcpModeAutoProxy: dh := &proxy.Handler{ Backend: backend, IPAddr: pktIP, Log: log, Netboot: proxy.Netboot{ IPXEBinServerTFTP: tftpIP, IPXEBinServerHTTP: httpBinaryURL, IPXEScriptURL: ipxeScript, Enabled: true, }, OTELEnabled: true, AutoProxyEnabled: true, } return dh, nil } return nil, errors.New("invalid dhcp mode") } // defaultLogger uses the slog logr implementation. func defaultLogger(level string) logr.Logger { // source file and function can be long. This makes the logs less readable. // truncate source file and function to last 3 parts for improved readability. customAttr := func(_ []string, a slog.Attr) slog.Attr { if a.Key == slog.SourceKey { ss, ok := a.Value.Any().(*slog.Source) if !ok || ss == nil { return a } f := strings.Split(ss.Function, "/") if len(f) > 3 { ss.Function = filepath.Join(f[len(f)-3:]...) } p := strings.Split(ss.File, "/") if len(p) > 3 { ss.File = filepath.Join(p[len(p)-3:]...) } return a } return a } opts := &slog.HandlerOptions{AddSource: true, ReplaceAttr: customAttr} switch level { case "debug": opts.Level = slog.LevelDebug default: opts.Level = slog.LevelInfo } log := slog.New(slog.NewJSONHandler(os.Stdout, opts)) return logr.FromSlogHandler(log.Handler()) } func parseTrustedProxies(trustedProxies string) (result []string) { for _, cidr := range strings.Split(trustedProxies, ",") { cidr = strings.TrimSpace(cidr) if cidr == "" { continue } _, _, err := net.ParseCIDR(cidr) if err != nil { // Its not a cidr, but maybe its an IP if ip := net.ParseIP(cidr); ip != nil { if ip.To4() != nil { cidr += "/32" } else { cidr += "/128" } } else { // not an IP, panic panic("invalid ip cidr in TRUSTED_PROXIES cidr=" + cidr) } } result = append(result, cidr) } return result } func (d dhcpMode) String() string { return string(d) } ================================================ FILE: contrib/tag-release.sh ================================================ #!/usr/bin/env bash set -o errexit -o nounset -o pipefail if [ -z "${1-}" ]; then echo "Must specify new tag" exit 1 fi new_tag=${1-} [[ $new_tag =~ ^v[0-9]*\.[0-9]*\.[0-9]*$ ]] || ( echo "Tag must be in the form of vX.Y.Z" exit 1 ) if [[ $(git symbolic-ref HEAD) != refs/heads/main ]] && [[ -z ${ALLOW_NON_MAIN:-} ]]; then echo "Must be on main branch" >&2 exit 1 fi if [[ $(git describe --dirty) != $(git describe) ]]; then echo "Repo must be in a clean state" >&2 exit 1 fi git fetch --all last_tag=$(git describe --abbrev=0) last_tag_commit=$(git rev-list -n1 "$last_tag") last_specific_tag=$(git tag --contains="$last_tag_commit" | grep -E "^v[0-9]*\.[0-9]*\.[0-9]*$" | tail -n 1) last_specific_tag_commit=$(git rev-list -n1 "$last_specific_tag") if [[ $last_specific_tag_commit == $(git rev-list -n1 HEAD) ]]; then echo "No commits since last tag" >&2 exit 1 fi if [[ -n ${SIGN_TAG-} ]]; then git tag -s -m "${new_tag}" "${new_tag}" &>/dev/null && echo "created signed tag ${new_tag}" >&2 && exit else git tag -a -m "${new_tag}" "${new_tag}" &>/dev/null && echo "created annotated tag ${new_tag}" >&2 && exit fi ================================================ FILE: docker-compose.yml ================================================ --- # Provides a docker-compose configuration for local fast iteration when # hacking on smee alone. # TODO: figure out if NET_ADMIN capability is really necessary version: "3.8" # use a custom network configuration to enable macvlan mode and set explicit # IPs and MACs as well as support mainstream DHCP clients for easier testing # standalone-hardware.json references these IPs and MACs so we can write # (simpler) assertions against behavior on the client side. networks: smee-test: # enables a more realistic L2 network for the containers driver: macvlan ipam: driver: default config: - subnet: 192.168.99.0/24 gateway: 192.168.99.1 services: smee: build: . # entrypoint: ["/usr/bin/smee", "--dhcp-addr", "0.0.0.0:67"] entrypoint: ["/start-smee.sh"] networks: smee-test: ipv4_address: 192.168.99.42 mac_address: 02:00:00:00:00:01 environment: SMEE_TINK_SERVER: tink-server:42113 SMEE_BACKEND_KUBE_ENABLED: false SMEE_BACKEND_FILE_ENABLED: true SMEE_BACKEND_FILE_PATH: /hardware.yaml SMEE_OSIE_URL: "http://192.168.8.5/osie/artifacts/" OTEL_EXPORTER_OTLP_ENDPOINT: otel-collector:4317 OTEL_EXPORTER_OTLP_INSECURE: "true" volumes: - ./test/hardware.yaml:/hardware.yaml - ./test/start-smee.sh:/start-smee.sh cap_add: - NET_ADMIN # eventually want to add more client containers, including one that smee will # not recognize so we can validate it won't serve content to IPs it's not # managing client: depends_on: - smee build: test networks: smee-test: ipv4_address: 192.168.99.43 mac_address: 02:00:00:00:00:ff cap_add: - NET_ADMIN otel-collector: image: otel/opentelemetry-collector-contrib:0.38.0 networks: smee-test: ipv4_address: 192.168.99.44 volumes: - ./test/otel-collector.yaml:/etc/otel-collector.yaml command: --config /etc/otel-collector.yaml ports: - "4317:4317" ================================================ FILE: docs/Backend-File.md ================================================ # File Watcher Backend This document gives an overview of the file watcher backend. This backend will read in and watch a file on disk for changes. The data from this file will then be used for serving DHCP requests. ## Why This backend exists mainly for testing and development. It allows the DHCP server to be run without having to spin up any additional backend servers, like [Tink](https://github.com/tinkerbell/tink) or [Cacher](https://github.com/packethost/cacher). ## Usage ```bash # See the file example/main.go for details on how to select and use this backend in code. go run example/main.go ``` Below is an example of the format used for this file watcher backend. See this [example.yaml](../backend/file/testdata/example.yaml) for a full working example of the data model. ```yaml --- 08:00:27:29:4E:67: ipAddress: "192.168.2.153" subnetMask: "255.255.255.0" defaultGateway: "192.168.2.1" nameServers: - "8.8.8.8" - "1.1.1.1" hostname: "pxe-virtualbox" domainName: "example.com" broadcastAddress: "192.168.2.255" ntpServers: - "132.163.96.2" - "132.163.96.3" leaseTime: 86400 domainSearch: - "example.com" netboot: allowPxe: true ipxeScriptUrl: "https://boot.netboot.xyz" 52:54:00:aa:88:2a: ipAddress: "192.168.2.15" subnetMask: "255.255.255.0" defaultGateway: "192.168.2.1" nameServers: - "8.8.8.8" - "1.1.1.1" hostname: "sandbox" domainName: "example.com" broadcastAddress: "192.168.2.255" ntpServers: - "132.163.96.2" - "132.163.96.3" leaseTime: 86400 domainSearch: - "example.com" netboot: allowPxe: true ipxeScriptUrl: "https://boot.netboot.xyz" ``` ================================================ FILE: docs/Code-Structure.md ================================================ # Code Structure ## Backend Responsible for communicating with an external persistence source and returning data from said source. Backends live in the `backend/` directory. ## Handler Responsible for reading a DHCP packet from a source, calling a backend, and responding to the source. All business logic for responding or reacting to DHCP messages lives here. Handlers live in the `handler/` directory. ## Listener Responsible for listening for UDP packets on the specified address and port. A default listener can be used. ## Server Responsible for filtering for DHCP packets received by the listener and calling the specified handler. ## Functional description Server(listener, handler(backend)) ================================================ FILE: docs/DCO.md ================================================ # DCO Sign Off All authors to the project retain copyright to their work. However, to ensure that they are only submitting work that they have rights to, we are requiring everyone to acknowledge this by signing their work. Since this signature indicates your rights to the contribution and certifies the statements below, it must contain your real name and email address. Various forms of noreply email address must not be used. Any copyright notices in this repository should specify the authors as "The project authors". To sign your work, just add a line like this at the end of your commit message: ```bash Signed-off-by: Jess Owens ``` This can easily be done with the `--signoff` option to `git commit`. By doing this you state that you can certify the following (from [https://developercertificate.org/][1]): ```text Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 1 Letterman Drive Suite D4700 San Francisco, CA, 94129 Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` ================================================ FILE: docs/DESIGN.md ================================================ # Smee Design Details ## Table of Contents - [Smee Flow](#Smee-Flow) - [Smee Installers](#Smee-Installers) - [IPXE](#IPXE) --- ## Smee Flow High-level traffic flow for Smee. ![smee-flow](smee-flow.png)
Smee Flow Code Copy and paste the code below into [https://www.websequencediagrams.com](https://www.websequencediagrams.com) to modify ```flow title Smee Flow # DHCP note over Machine: DHCP start Machine->Smee: 1. DHCP Discover Smee->Tink: 2. Get Hardware data from MAC Tink->Smee: 3. Send Hardware data Smee->Machine: 4. DHCP Offer Machine->Smee: 5. DHCP Request Smee->Tink: 6. Get Hardware data from MAC Tink->Smee: 7. Send Hardware data Smee->Machine: 8. DHCP Ack note over Machine: DHCP end # TFTP note over Machine: TFTP start Machine->Smee: 9. TFTP Get ipxe binary Smee->Tink: 10. Get Hardware data from IP Tink->Smee: 11. Send Hardware data Smee->Machine: 12. Send ipxe binary note over Machine: TFTP end # DHCP note over Machine: DHCP start Machine->Smee: 13. DHCP Discover Smee->Tink: 14. Get Hardware data from MAC Tink->Smee: 15. Send Hardware data Smee->Machine: 16. DHCP Offer Machine->Smee: 17. DHCP Request Smee->Tink: 18. Get Hardware data from MAC Tink->Smee: 19. Send Hardware data Smee->Machine: 20. DHCP Ack note over Machine: DHCP end # HTTP note over Machine: HTTP start Machine->Smee: 21. HTTP Get ipxe script Smee->Tink: 22. Get Hardware data from IP Tink->Smee: 23. Send Hardware data Smee->Machine: 24. Send ipxe script note over Machine: HTTP start ```
## Smee Installers A Smee Installer is a custom iPXE script. The code for each Installer lives in `installers/` The idea of iPXE Installers that live in-tree here is an idea that doesn't follow the existing template/workflow paradigm. Installers should eventually be deprecated. The deprecation process is forthcoming. ### How an Installers is requested During a PXE boot request, an iPXE script is provided to a PXE-ing machine through a dynamically generated endpoint (http://smee.addr/auto.ipxe). The contents of the auto.ipxe script is determined through the following steps: 1. A hardware record is retrieved based on the PXE-ing machines mac address. 2. The following are tried, in order, to determine the content of the iPXE script ([code ref](https://github.com/tinkerbell/smee/blob/b2f4d15f9b55806f4636003948ed95975e1d475e/job/ipxe.go#L71)) 1. If the `metadata.instance.operating_system.slug` matches a registered Installer, the iPXE script from that Installer is returned 2. If the `metadata.instance.operating_system.distro` matches a registered Installer, the iPXE script from that Installer 3. If neither of the first 2 is matched, then the default (OSIE) iPXE script is used ### Registering an Installer To register an Installer, at a minimum, the following is required 1. A [blank import](https://github.com/golang/go/wiki/CodeReviewComments#import-blank) for your Installer should be added to `main.go` 2. Your Installer pkg needs an `func init()` that calls `job.RegisterSlug("InstallerName", funcThatReturnsAnIPXEScript)` ### Testing Installers Unit tests should be created to validate that your registered func returns the iPXE script you're expecting. Functional tests would be great but depending on what is in your iPXE script might be difficult because of external dependencies. At a minimum try to create documentation that details these dependencies so that others can make them available for testing changes. ## IPXE Smee serves the upstream IPXE binaries built from [https://github.com/ipxe/ipxe](https://github.com/ipxe/ipxe). The IPXE binaries are built from source and then embedded into the Smee Go binary to be served via TFTP. ### Building the IPXE binary The IPXE binaries from [https://github.com/ipxe/ipxe](https://github.com/ipxe/ipxe) are built via a Make target. ```make make bindata ``` ================================================ FILE: docs/DESIGNPHILOSOPHY.md ================================================ # Design Philosophy This living document describes some Go design philosophies we endeavor to incorporate when working, building, or writing in Go. ## General 1. Prefer easy to understand over easy to do 2. First do it, then do it right, then do it better, then make it testable [14] 3. When you spawn goroutines, make it clear when - or whether - they exit. [2] 4. Packages that are imported only for their side effects should be avoided [4] 5. Package level and global variables should be avoided 6. magic is bad; global state is magic → no package level vars; no func init [13] ## Dependencies 1. External dependencies should be tried and fail fast or just keep trying - For example, external connections, port binding, environment variables, secrets, etc - Examples of "failing fast" - Try external connections immediately - Binding to ports immediately - Examples of "keep trying" - Block ingress traffic or calls until external connections are successful - Should be accompanied by some way to check health status of external connections 2. Make all dependencies explicit [11] ## Naming 1. Naming general rules [12] - Structs are plain nouns: API, Replica, Object - Interfaces are active nouns: Reader, Writer, JobProcessor - Functions and methods are verbs: Read, Process, Sync 2. Package names [15] - Short: no more than one word - No plural - Lower case - Informative about the service it provides - Avoid packages named utility/utilities or model/models 3. Avoid renaming imports except to avoid a name collision; good package names should not require renaming [3] ## Interfaces 1. Accept interfaces, return structs [5] 2. Small interfaces are better [6] 3. Define an interface when you actually need it, not when you foresee needing it [7] 4. Interfaces [15] - Use interfaces as function/method arguments & as field types - Small interfaces are better ## Functions/Methods 1. All top-level, exported names should have doc comments, as should non-trivial unexported type or function declarations. [1] 2. Methods/functions [15] - One function has one goal - Simple names - Reduce the number of nesting levels 3. Only func main has the right to decide which flags, env variables, config files are available to the user [10a],[10b] 4. `context.Context` should, in most cases, be the first argument of all functions or methods 5. Prefer synchronous functions - functions which return their results directly or finish any callbacks or channel ops before returning - over asynchronous ones. [8] ## Errors 1. Error Handling [15] - Func `main` should normally be the only one calling fatal errors or `os.Exit` ## Source files 1. One file should be named like the package [9] 2. One file = One responsibility [9] 3. If you only have one command prefer a top level `main.go`, if you have more than one command put them in a `cmd/` package --- [1]: https://github.com/golang/go/wiki/CodeReviewComments#doc-comments [2]: https://github.com/golang/go/wiki/CodeReviewComments#goroutine-lifetimes [3]: https://github.com/golang/go/wiki/CodeReviewComments#imports [4]: https://github.com/golang/go/wiki/CodeReviewComments#import-blank [5]: https://medium.com/@cep21/what-accept-interfaces-return-structs-means-in-go-2fe879e25ee8 [6]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#use-interfaces [7]: http://c2.com/xp/YouArentGonnaNeedIt.html [8]: https://github.com/golang/go/wiki/CodeReviewComments#synchronous-functions [9]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#source-files [10a]: https://thoughtbot.com/blog/where-to-define-command-line-flags-in-go [10b]: https://peter.bourgon.org/go-best-practices-2016/#configuration [11]: https://peter.bourgon.org/go-best-practices-2016/#top-tip-9 [12]: https://twitter.com/peterbourgon/status/1121023995107782656 [13]: https://peter.bourgon.org/blog/2017/06/09/theory-of-modern-go.html [14]: https://code.tutsplus.com/articles/master-developers-addy-osmani--net-31661 [15]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#key-takeaways ================================================ FILE: docs/DHCP.md ================================================ # Use an existing DHCP service There can be numerous reasons why you may want to use an existing DHCP service instead of Smee: Security, compliance, access issues, existing layer 2 constraints, existing automation, and so on. In environments where there is an existing DHCP service, this DHCP service can be configured to interoperate with Smee. This document will cover how to make your existing DHCP service interoperate with Smee. In this scenario Smee will have no layer 2 DHCP responsibilities. > Note: Currently, Smee is responsible for more than just DHCP. So generally speaking, Smee can't be entirely avoided in the provisioning process. ## Additional Services in Smee - HTTP and TFTP servers for iPXE binaries - HTTP server for iPXE script - Syslog server (receiver) ## Process As a prerequisite, your existing DHCP must serve [host/address/static reservations](https://kb.isc.org/docs/what-are-host-reservations-how-to-use-them) for all machines. The IP address you select will need to be used in a corresponding Hardware object. Configure your existing DHCP service to provide the location of the iPXE binary and script. This is a two-step interaction between machines and the DHCP service and enables the network boot process to start. - **Step 1**: The machine broadcasts a request to network boot. Your existing DHCP service then provides the machine with all IPAM info as well as the location of the Tinkerbell iPXE binary (`ipxe.efi`). The machine configures its network interface with the IPAM info then downloads the Tinkerbell iPXE binary from the location provided by the DHCP service and runs it. - **Step 2**: Now with the Tinkerbell iPXE binary loaded and running, iPXE again broadcasts a request to network boot. The DHCP service again provides all IPAM info as well as the location of the Tinkerbell iPXE script (`auto.ipxe`). iPXE configures its network interface using the IPAM info and then downloads the Tinkerbell iPXE script from the location provided by the DHCP service and runs it. > Note The `auto.ipxe` is an [iPXE script](https://ipxe.org/scripting) that tells iPXE from where to download the [HookOS](https://github.com/tinkerbell/hook) kernel and initrd so that they can be loaded into memory. The following diagram illustrates the process described above. Note that the diagram only describes the network booting parts of the DHCP interaction, not the exchange of IPAM info. ![process](images/BYO_DHCP.png) ## Configuration Below you will find code snippets showing how to add the two-step process from above to an existing DHCP service. Each config checks if DHCP option 77 ([user class option](https://www.rfc-editor.org/rfc/rfc3004.html)) equals "`Tinkerbell`". If it does match, then the Tinkerbell iPXE script (`auto.ipxe`) will be served. If option 77 does not match, then the iPXE binary (`ipxe.efi`) will be served. ### DHCP option: `next server` Most DHCP services all customization of a `next server` option. This option generally corresponds to either DHCP option 66 or the DHCP header `sname`, [reference.](https://www.rfc-editor.org/rfc/rfc2132.html#section-9.4) This option is used to tell a machine where to download the initial bootloader, [reference.](https://networkboot.org/fundamentals/) ### Code snippets The following code snippets are generic examples of the config needed to enable the two-step process to an existing DHCP service. It does not cover the IPAM info that is also required. [dnsmasq](https://linux.die.net/man/8/dnsmasq) `dnsmasq.conf` ```text dhcp-match=tinkerbell, option:user-class, Tinkerbell dhcp-boot=tag:!tinkerbell,ipxe.efi,none,192.168.2.112 dhcp-boot=tag:tinkerbell,http://192.168.2.112/auto.ipxe ``` [Kea DHCP](https://www.isc.org/kea/) `kea.json` ```json { "Dhcp4": { "client-classes": [ { "name": "tinkerbell", "test": "substring(option[77].hex,0,10) == 'Tinkerbell'", "boot-file-name": "http://192.168.2.112/auto.ipxe" }, { "name": "default", "test": "not(substring(option[77].hex,0,10) == 'Tinkerbell')", "boot-file-name": "ipxe.efi" } ], "subnet4": [ { "next-server": "192.168.2.112" } ] } } ``` [ISC DHCP](https://ipxe.org/howto/dhcpd) `dhcpd.conf` ```text if exists user-class and option user-class = "Tinkerbell" { filename "http://192.168.2.112/auto.ipxe"; } else { filename "ipxe.efi"; } next-server "192.168.1.112"; ``` [Microsoft DHCP server](https://learn.microsoft.com/en-us/windows-server/networking/technologies/dhcp/dhcp-top) Please follow the ipxe.org [guide](https://ipxe.org/howto/msdhcp) on how to configure Microsoft DHCP server. ================================================ FILE: docs/Design-Philosophy.md ================================================ # Design Philosophy This living document describes some Go design philosophies we endeavor to incorporate when working, building, or writing in Go. ## General 1. Prefer easy to understand over easy to do 2. First do it, then do it right, then do it better, then make it testable [14] 3. When you spawn goroutines, make it clear when - or whether - they exit. [2] 4. Packages that are imported only for their side effects should be avoided [4] 5. Package level and global variables should be avoided 6. magic is bad; global state is magic → no package level vars; no func init [13] ## Dependencies 1. External dependencies should be tried and fail fast or just keep trying - For example, external connections, port binding, environment variables, secrets, etc - Examples of "failing fast" - Try external connections immediately - Binding to ports immediately - Examples of "keep trying" - Block ingress traffic or calls until external connections are successful - Should be accompanied by some way to check health status of external connections 2. Make all dependencies explicit [11] ## Naming 1. Naming general rules [12] - Structs are plain nouns: API, Replica, Object - Interfaces are active nouns: Reader, Writer, JobProcessor - Functions and methods are verbs: Read, Process, Sync 2. Package names [15] - Short: no more than one word - No plural - Lower case - Informative about the service it provides - Avoid packages named utility/utilities or model/models 3. Avoid renaming imports except to avoid a name collision; good package names should not require renaming [3] ## Interfaces 1. Accept interfaces, return structs [5] 2. Small interfaces are better [6] 3. Define an interface when you actually need it, not when you foresee needing it [7] 4. Interfaces [15] - Use interfaces as function/method arguments & as field types - Small interfaces are better ## Functions/Methods 1. All top-level, exported names should have doc comments, as should non-trivial unexported type or function declarations. [1] 2. Methods/functions [15] - One function has one goal - Simple names - Reduce the number of nesting levels 3. Only func main has the right to decide which flags, env variables, config files are available to the user [10a],[10b] 4. `context.Context` should, in most cases, be the first argument of all functions or methods 5. Prefer synchronous functions - functions which return their results directly or finish any callbacks or channel ops before returning - over asynchronous ones. [8] ## Errors 1. Error Handling [15] - Func `main` should normally be the only one calling fatal errors or `os.Exit` ## Source files 1. One file should be named like the package [9] 2. One file = One responsibility [9] 3. If you only have one command prefer a top level `main.go`, if you have more than one command put them in a `cmd/` package --- [1]: https://github.com/golang/go/wiki/CodeReviewComments#doc-comments [2]: https://github.com/golang/go/wiki/CodeReviewComments#goroutine-lifetimes [3]: https://github.com/golang/go/wiki/CodeReviewComments#imports [4]: https://github.com/golang/go/wiki/CodeReviewComments#import-blank [5]: https://medium.com/@cep21/what-accept-interfaces-return-structs-means-in-go-2fe879e25ee8 [6]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#use-interfaces [7]: http://c2.com/xp/YouArentGonnaNeedIt.html [8]: https://github.com/golang/go/wiki/CodeReviewComments#synchronous-functions [9]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#source-files [10a]: https://thoughtbot.com/blog/where-to-define-command-line-flags-in-go [10b]: https://peter.bourgon.org/go-best-practices-2016/#configuration [11]: https://peter.bourgon.org/go-best-practices-2016/#top-tip-9 [12]: https://twitter.com/peterbourgon/status/1121023995107782656 [13]: https://peter.bourgon.org/blog/2017/06/09/theory-of-modern-go.html [14]: https://code.tutsplus.com/articles/master-developers-addy-osmani--net-31661 [15]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#key-takeaways ================================================ FILE: docs/ISO-Static-IPAM.md ================================================ # Static IP Address Management in the OSIE ISO OSIE stands for operating system installation environment. In Tinkerbell we currently have just one, [HookOS](https://github.com/tinkerbell/hook). Smee has the capability to Patch the HookOS ISO at runtime to include information about the target machine's network configuration. This is enabled by setting the CLI flag `-iso-static-ipam-enabled=true` along with both `-iso-enabled` and `-iso-url`. This document defines the specification/data format for passing this info to the HookOS ISO. ## Specification/Data format This is the spec/ data format for passing the static IP address management information to the HookOS ISO. ```ipam=::::::::``` Example: ```ipam=de-ad-be-ef-fe-ed:30:192.168.2.193:255.255.255.0:192.168.2.1:server.example.com:1.1.1.1,8.8.8.8:example.com,team.example.com:132.163.97.1,132.163.96.1``` ### Fields Some fields are required so that basic network communication can function properly. | Field | Description | Required | Example | |-------|-------------|----------|---------| | mac-address | MAC address. Must be in dash notation. | Yes |`00-00-00-00-00-00` | | vlan-id | VLAN ID. Must be a string integer between 0 and 4096 or an empty string for no VLAN tagging. | No | `30` | | ip-address | IPv4 address. | Yes | `10.148.56.3` | | netmask | Netmask. | Yes | `255.255.240.0` | | gateway | IPv4 Gateway. | No | `10.148.56.1` | | hostname | Hostname for the system. Can be fully qualified or not. | No | `hookos` or `hookos.example.com` | | dns | Comma separated list of IPv4 DNS nameservers. Must be IPv4 addresses, not hostnames. | Yes | `1.1.1.1,8.8.8.8` | | search-domains | Comma separated list of search domains. | No | `example.com,example.org` | | ntp | Comma separated list of IPv4 NTP servers. Must be IPv4 addresses, not hostnames. | No | `132.163.97.1,132.163.96.1` | ## Implementation details Smee will set the kernel commandline parameter `ipam=` with the above format. In HookOS, there is a service that reads this cmdline parameter and writes the file(s) and runs the command(s) necessary to configure HookOS the use of all the values. See HookOS for more details on the service and how it works. ================================================ FILE: docs/images/BYO_DHCP.uml ================================================ title Bring your own DHCP service participant Machine participant DHCP participant Smee rbox over Machine,DHCP: 192.168.5.5 represents the IP from which the Smee service is available group #2f2e7b In firmware iPXE #white autonumber 1 Machine->DHCP: DHCP discover DHCP->Machine: DHCP OFFER\nnext server: 192.168.2.5.5\nboot file: ipxe.efi Machine->DHCP: DHCP REQUEST DHCP->Machine: DHCP ACK\nnext server: 192.168.5.5\nboot file: ipxe.efi Machine->Smee: Download and boot **ipxe.efi** (TFTP or HTTP) end group #2f2e7b In Tinkerbell iPXE #white Machine->DHCP: DHCP DISCOVER DHCP->Machine: DHCP OFFER\nnext server: 192.168.5.5\nboot file: http://192.168.5.5/auto.ipxe Machine->DHCP: DHCP REQUEST DHCP->Machine: DHCP ACK\nnext server: 192.168.5.5\nboot file: http://192.168.5.5/auto.ipxe Machine->Smee: Download and execute **auto.ipxe** iPXE script (HTTP) destroysilent Machine destroysilent DHCP destroysilent Smee end ================================================ FILE: docs/manifests/README.md ================================================ # Deploying Smee This directory contains the manifests for deploying Smee to various environments. This document will describe how to use the different Smee deployment options. ## Variables Regardless of the option you choose it is recommended you get started by updating the following environment variables in the [`manifests/kustomize/base/deployment.yaml`](./kustomize/base/deployment.yaml) file to match your setup. | Variable | Description | | --------------------------- | --------------------------------------------------------------------------------------------------- | | `TINKERBELL_GRPC_AUTHORITY` | This is the IP:Port that a Tink worker will use for communicated with the Tink server | | `MIRROR_BASE_URL` | The URL from where the "OSIE" or Hook kernel(s) and initrd(s) will be downloaded by netboot clients | | `PUBLIC_IP` | This is the IP that netboot clients and/or DHCP relay's will use to reach Smee | | `PUBLIC_SYSLOG_FQDN` | This is the IP that syslog clients will use to send messages | ## Deployment Options - [Kind](kind.md) - [Kubernetes](kubernetes.md) - [K3D](k3d.md) - [Tilt](tilt.md) ================================================ FILE: docs/manifests/k3d.md ================================================ # K3D (K3S in Docker) This describes deploying Smee into a K3S in Docker (K3D) cluster. ## Prerequisites - [K3D >= v5.4.1](https://k3d.io/v5.4.1/#installation) - [Kubectl >= v1.23.4](https://www.downloadkubernetes.com/) - Supported platforms: Linux ### Steps 1. Create K3D cluster ```bash # Create the K3D cluster k3d cluster create --network host --no-lb --k3s-arg "--disable=traefik" ``` 2. Deploy Smee Be sure you have updated `MIRROR_BASE_URL`, `PUBLIC_IP`, `PUBLIC_SYSLOG_FQDN`, and `TINKERBELL_GRPC_AUTHORITY` env variables in the [`manifests/kustomize/base/deployment.yaml`](../../manifests/kustomize/base/deployment.yaml) file. ```bash # Deploy Smee to K3D kubectl kustomize manifests/kustomize/overlays/k3d | kubectl apply -f - ``` 3. Watch the logs ```bash kubectl -n tinkerbell logs -f -l app=tinkerbell-smee ``` ================================================ FILE: docs/manifests/kind.md ================================================ # KinD (Kubernetes in Docker) This describes deploying Smee into a Kubernetes in Docker (KinD) cluster. ## Prerequisites - [KinD >= v0.12.0](https://kind.sigs.k8s.io/docs/user/quick-start#installation) - [Kubectl >= v1.23.4](https://www.downloadkubernetes.com/) ## Steps 1. Create KinD cluster ```bash # Create the KinD cluster kind create cluster --config ./manifests/kind/config.yaml ``` 2. Deploy Smee Be sure you have updated `MIRROR_BASE_URL`, `PUBLIC_IP`, `PUBLIC_SYSLOG_FQDN`, and `TINKERBELL_GRPC_AUTHORITY` env variables in the [`manifests/kustomize/base/deployment.yaml`](../../manifests/kustomize/base/deployment.yaml) file. ```bash # Deploy Smee to KinD kubectl kustomize manifests/kustomize/overlays/kind | kubectl apply -f - ``` 3. Watch the logs ```bash kubectl -n tinkerbell logs -f -l app=tinkerbell-smee ``` > **Note:** KinD will not be able to listen for DHCP broadcast traffic. Using a DHCP relay is recommended. > > ```bash > # Linux direct > ipaddr=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' kind-control-plane) > sudo -E dhcrelay -id -iu $(ip -o route get ${ipaddr} | cut -d" " -f3) -d ${ipaddr} > > # Linux Container > ipaddr=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' kind-control-plane) > docker run -d --network host --name dhcrelay modem7/dhcprelay:latest -id -iu $(ip -o route get ${ipaddr} | cut -d" " -f3) -d ${ipaddr} > > # MacOS TBD > ``` ================================================ FILE: docs/manifests/kubernetes.md ================================================ # Kubernetes This deployment requires a running Kubernetes cluster. It can be a single node cluster. It is required to be running directly on a Linux machine, not in a container. This deployment is under development and is not guaranteed to work at this time. ## Prerequisites TBD ## Steps 1. Deploy Smee Be sure you have updated `MIRROR_BASE_URL`, `PUBLIC_IP`, `PUBLIC_SYSLOG_FQDN`, and `TINKERBELL_GRPC_AUTHORITY` env variables in the [`manifests/kustomize/base/deployment.yaml`](../../manifests/kustomize/base/deployment.yaml) file. ```bash # Deploy Smee to Kubernetes kubectl kustomize manifests/kustomize/overlays/dev | kubectl apply -f - ``` 2. Watch the logs ```bash kubectl -n tinkerbell logs -f -l app=tinkerbell-smee ``` ================================================ FILE: docs/manifests/tilt.md ================================================ # Tilt This deployment method is for quick local development. Tilt will build and deploy Smee to the Kubernetes cluster pointed to in the current context of your Kubernetes config file. It will use the KinD manifest, documented [here](KIND.md), for deployment. ## Prerequisites - [Tilt >= v0.28.1](https://docs.tilt.dev/install.html) - Go >= 1.18 - [Kubectl >= v1.23.4](https://www.downloadkubernetes.com/) - KinD cluster ## Steps 1. Deploy Smee Be sure you have updated `MIRROR_BASE_URL`, `PUBLIC_IP`, `PUBLIC_SYSLOG_FQDN`, and `TINKERBELL_GRPC_AUTHORITY` env variables in the `manifests/kustomize/base/deployment.yaml` file. This deployment method uses the kustomize kind overlay (`manifests/kustomize/overlays/kind`). See the `Tiltfile` modify this. ```bash # Deploy Smee with Tilt tilt up --stream ``` 2. Watch the logs ```bash kubectl -n tinkerbell logs -f -l app=tinkerbell-smee ``` ================================================ FILE: go.mod ================================================ module github.com/tinkerbell/smee go 1.24.0 toolchain go1.24.1 require ( github.com/ccoveille/go-safecast v1.6.1 github.com/diskfs/go-diskfs v1.6.0 github.com/fsnotify/fsnotify v1.9.0 github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v1.4.3 github.com/go-logr/stdr v1.2.2 github.com/google/go-cmp v0.7.0 github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f github.com/peterbourgon/ff/v3 v3.4.0 github.com/prometheus/client_golang v1.22.0 github.com/stretchr/testify v1.10.0 github.com/tinkerbell/ipxedust v0.0.0-20250129162407-3c29a914f8be github.com/tinkerbell/tink v0.12.2 github.com/vishvananda/netlink v1.3.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 go.opentelemetry.io/otel v1.37.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 go.opentelemetry.io/otel/sdk v1.37.0 go.opentelemetry.io/otel/trace v1.37.0 golang.org/x/net v0.41.0 golang.org/x/sync v0.15.0 golang.org/x/sys v0.33.0 google.golang.org/grpc v1.73.0 k8s.io/apimachinery v0.33.2 k8s.io/client-go v0.33.2 sigs.k8s.io/controller-runtime v0.21.0 ) require ( dario.cat/mergo v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/djherbis/times v1.6.0 // indirect github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/go-logr/zerologr v1.2.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mdlayher/packet v1.1.2 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pierrec/lz4/v4 v4.1.19 // indirect github.com/pin/tftp/v3 v3.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/xattr v0.4.9 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rs/zerolog v1.33.0 // indirect github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.7.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/term v0.32.0 // indirect golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.33.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/ccoveille/go-safecast v1.6.1 h1:Nb9WMDR8PqhnKCVs2sCB+OqhohwO5qaXtCviZkIff5Q= github.com/ccoveille/go-safecast v1.6.1/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/diskfs/go-diskfs v1.6.0 h1:YmK5+vLSfkwC6kKKRTRPGaDGNF+Xh8FXeiNHwryDfu4= github.com/diskfs/go-diskfs v1.6.0/go.mod h1:bRFumZeGFCO8C2KNswrQeuj2m1WCVr4Ms5IjWMczMDk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 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= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU= github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM= 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.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= 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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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-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/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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.19 h1:tYLzDnjDXh9qIxSTKHwXwOYmm9d887Y7Y1ZkyXYHAN4= github.com/pierrec/lz4/v4 v4.1.19/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= 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= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tinkerbell/ipxedust v0.0.0-20250129162407-3c29a914f8be h1:PRUY/EEvGGjwohNGn1ncj5y8BlU5p42C/GUwYzmJI/4= github.com/tinkerbell/ipxedust v0.0.0-20250129162407-3c29a914f8be/go.mod h1:gO18k34se3edSoBttsayVjT9lPA7xTZ+yiXMU1oQAC8= github.com/tinkerbell/tink v0.12.2 h1:ROe5SAx5X8hHEROm9OJzc6XLhEzOhUcdGpY2bLVAOnk= github.com/tinkerbell/tink v0.12.2/go.mod h1:Cpv7pSazMhq6HYVAByHJu2tkLIsR9K/mBY1S87RQbC4= github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 h1:YcojQL98T/OO+rybuzn2+5KrD5dBwXIvYBvQ2cD3Avg= github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs= k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc= k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= ================================================ FILE: internal/backend/file/file.go ================================================ // Package file watches a file for changes and updates the in memory DHCP data. package file import ( "context" "fmt" "net" "net/netip" "net/url" "os" "path/filepath" "strings" "sync" "github.com/ccoveille/go-safecast" "github.com/fsnotify/fsnotify" "github.com/ghodss/yaml" "github.com/go-logr/logr" "github.com/tinkerbell/smee/internal/dhcp/data" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/codes" ) const tracerName = "github.com/tinkerbell/smee/dhcp" // Errors used by the file watcher. var ( // errFileFormat is returned when the file is not in the correct format, e.g. not valid YAML. errFileFormat = fmt.Errorf("invalid file format") errRecordNotFound = fmt.Errorf("record not found") errParseIP = fmt.Errorf("failed to parse IP from File") errParseSubnet = fmt.Errorf("failed to parse subnet mask from File") errParseURL = fmt.Errorf("failed to parse URL") ) // netboot is the structure for the data expected in a file. type netboot struct { AllowPXE bool `yaml:"allowPxe"` // If true, the client will be provided netboot options in the DHCP offer/ack. IPXEScriptURL string `yaml:"ipxeScriptUrl"` // Overrides default value of that is passed into DHCP on startup. IPXEScript string `yaml:"ipxeScript"` // Overrides a default value that is passed into DHCP on startup. Console string `yaml:"console"` Facility string `yaml:"facility"` } // dhcp is the structure for the data expected in a file. type dhcp struct { MACAddress net.HardwareAddr // The MAC address of the client. IPAddress string `yaml:"ipAddress"` // yiaddr DHCP header. SubnetMask string `yaml:"subnetMask"` // DHCP option 1. DefaultGateway string `yaml:"defaultGateway"` // DHCP option 3. NameServers []string `yaml:"nameServers"` // DHCP option 6. Hostname string `yaml:"hostname"` // DHCP option 12. DomainName string `yaml:"domainName"` // DHCP option 15. BroadcastAddress string `yaml:"broadcastAddress"` // DHCP option 28. NTPServers []string `yaml:"ntpServers"` // DHCP option 42. VLANID string `yaml:"vlanID"` // DHCP option 43.116. LeaseTime int `yaml:"leaseTime"` // DHCP option 51. Arch string `yaml:"arch"` // DHCP option 93. DomainSearch []string `yaml:"domainSearch"` // DHCP option 119. Disabled bool // If true, no DHCP response should be sent. Netboot netboot `yaml:"netboot"` } // Watcher represents the backend for watching a file for changes and updating the in memory DHCP data. type Watcher struct { fileMu sync.RWMutex // protects FilePath for reads // FilePath is the path to the file to watch. FilePath string // Log is the logger to be used in the File backend. Log logr.Logger dataMu sync.RWMutex // protects data data []byte // data from file watcher *fsnotify.Watcher } // NewWatcher creates a new file watcher. func NewWatcher(l logr.Logger, f string) (*Watcher, error) { watcher, err := fsnotify.NewWatcher() if err != nil { return nil, err } if err := watcher.Add(f); err != nil { return nil, err } w := &Watcher{ FilePath: f, watcher: watcher, Log: l, } w.fileMu.RLock() w.data, err = os.ReadFile(filepath.Clean(f)) w.fileMu.RUnlock() if err != nil { return nil, err } return w, nil } // GetByMac is the implementation of the Backend interface. // It reads a given file from the in memory data (w.data). func (w *Watcher) GetByMac(ctx context.Context, mac net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { tracer := otel.Tracer(tracerName) _, span := tracer.Start(ctx, "backend.file.GetByMac") defer span.End() // get data from file, translate it, then pass it into setDHCPOpts and setNetworkBootOpts w.dataMu.RLock() d := w.data w.dataMu.RUnlock() r := make(map[string]dhcp) if err := yaml.Unmarshal(d, &r); err != nil { err := fmt.Errorf("%w: %w", err, errFileFormat) w.Log.Error(err, "failed to unmarshal file data") span.SetStatus(codes.Error, err.Error()) return nil, nil, err } for k, v := range r { if strings.EqualFold(k, mac.String()) { // found a record for this mac address v.MACAddress = mac d, n, err := w.translate(v) if err != nil { span.SetStatus(codes.Error, err.Error()) return nil, nil, err } span.SetAttributes(d.EncodeToAttributes()...) span.SetAttributes(n.EncodeToAttributes()...) span.SetStatus(codes.Ok, "") return d, n, nil } } err := fmt.Errorf("%w: %s", errRecordNotFound, mac.String()) span.SetStatus(codes.Error, err.Error()) return nil, nil, err } // GetByIP is the implementation of the Backend interface. // It reads a given file from the in memory data (w.data). func (w *Watcher) GetByIP(ctx context.Context, ip net.IP) (*data.DHCP, *data.Netboot, error) { tracer := otel.Tracer(tracerName) _, span := tracer.Start(ctx, "backend.file.GetByIP") defer span.End() // get data from file, translate it, then pass it into setDHCPOpts and setNetworkBootOpts w.dataMu.RLock() d := w.data w.dataMu.RUnlock() r := make(map[string]dhcp) if err := yaml.Unmarshal(d, &r); err != nil { err := fmt.Errorf("%w: %w", err, errFileFormat) w.Log.Error(err, "failed to unmarshal file data") span.SetStatus(codes.Error, err.Error()) return nil, nil, err } for k, v := range r { if v.IPAddress == ip.String() { // found a record for this ip address v.IPAddress = ip.String() mac, err := net.ParseMAC(k) if err != nil { err := fmt.Errorf("%w: %w", err, errFileFormat) w.Log.Error(err, "failed to parse mac address") span.SetStatus(codes.Error, err.Error()) return nil, nil, err } v.MACAddress = mac d, n, err := w.translate(v) if err != nil { span.SetStatus(codes.Error, err.Error()) return nil, nil, err } span.SetAttributes(d.EncodeToAttributes()...) span.SetAttributes(n.EncodeToAttributes()...) span.SetStatus(codes.Ok, "") return d, n, nil } } err := fmt.Errorf("%w: %s", errRecordNotFound, ip.String()) span.SetStatus(codes.Error, err.Error()) return nil, nil, err } // Start starts watching a file for changes and updates the in memory data (w.data) on changes. // Start is a blocking method. Use a context cancellation to exit. func (w *Watcher) Start(ctx context.Context) { for { select { case <-ctx.Done(): w.Log.Info("stopping watcher") return case event, ok := <-w.watcher.Events: if !ok { continue } if event.Op&fsnotify.Write == fsnotify.Write { w.Log.Info("file changed, updating cache") w.fileMu.RLock() d, err := os.ReadFile(w.FilePath) w.fileMu.RUnlock() if err != nil { w.Log.Error(err, "failed to read file", "file", w.FilePath) break } w.dataMu.Lock() w.data = d w.dataMu.Unlock() } case err, ok := <-w.watcher.Errors: if !ok { continue } w.Log.Info("error watching file", "err", err) } } } // translate converts the data from the file into a data.DHCP and data.Netboot structs. func (w *Watcher) translate(r dhcp) (*data.DHCP, *data.Netboot, error) { d := new(data.DHCP) n := new(data.Netboot) d.MACAddress = r.MACAddress // ip address, required ip, err := netip.ParseAddr(r.IPAddress) if err != nil { return nil, nil, fmt.Errorf("%w: %w", err, errParseIP) } d.IPAddress = ip // subnet mask, required sm := net.ParseIP(r.SubnetMask) if sm == nil { return nil, nil, errParseSubnet } d.SubnetMask = net.IPMask(sm.To4()) // default gateway, optional if dg, err := netip.ParseAddr(r.DefaultGateway); err != nil { w.Log.Info("failed to parse default gateway", "defaultGateway", r.DefaultGateway, "err", err) } else { d.DefaultGateway = dg } // name servers, optional for _, s := range r.NameServers { ip := net.ParseIP(s) if ip == nil { w.Log.Info("failed to parse name server", "nameServer", s) break } d.NameServers = append(d.NameServers, ip) } // hostname, optional d.Hostname = r.Hostname // domain name, optional d.DomainName = r.DomainName // broadcast address, optional if ba, err := netip.ParseAddr(r.BroadcastAddress); err != nil { w.Log.Info("failed to parse broadcast address", "broadcastAddress", r.BroadcastAddress, "err", err) } else { d.BroadcastAddress = ba } // ntp servers, optional for _, s := range r.NTPServers { ip := net.ParseIP(s) if ip == nil { w.Log.Info("failed to parse ntp server", "ntpServer", s) break } d.NTPServers = append(d.NTPServers, ip) } // vlanid d.VLANID = r.VLANID // lease time // Default to one week d.LeaseTime = 604800 if v, err := safecast.ToUint32(r.LeaseTime); err == nil { d.LeaseTime = v } // arch d.Arch = r.Arch // domain search d.DomainSearch = r.DomainSearch // disabled d.Disabled = r.Disabled // allow machine to netboot n.AllowNetboot = r.Netboot.AllowPXE // ipxe script url is optional but if provided, it must be a valid url if r.Netboot.IPXEScriptURL != "" { u, err := url.Parse(r.Netboot.IPXEScriptURL) if err != nil { return nil, nil, fmt.Errorf("%w: %w", err, errParseURL) } n.IPXEScriptURL = u } // ipxe script if r.Netboot.IPXEScript != "" { n.IPXEScript = r.Netboot.IPXEScript } // console if r.Netboot.Console != "" { n.Console = r.Netboot.Console } // facility if r.Netboot.Facility != "" { n.Facility = r.Netboot.Facility } return d, n, nil } ================================================ FILE: internal/backend/file/file_test.go ================================================ package file import ( "bytes" "context" "errors" "fmt" "io/fs" "log" "net" "net/netip" "net/url" "os" "testing" "time" "github.com/fsnotify/fsnotify" "github.com/go-logr/logr" "github.com/go-logr/stdr" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/tinkerbell/smee/internal/dhcp/data" ) func TestNewWatcher(t *testing.T) { tests := map[string]struct { createFile bool want string wantErr error }{ "contents equal": {createFile: true, want: "test content here"}, "file not found": {createFile: false, wantErr: &fs.PathError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { var name string if tt.createFile { var err error name, err = createFile([]byte(tt.want)) if err != nil { t.Fatal(err) } defer os.Remove(name) } w, err := NewWatcher(logr.Discard(), name) if (err != nil) != (tt.wantErr != nil) { t.Fatalf("NewWatcher() error = %v; type = %[1]T, wantErr %v; type = %[2]T", err, tt.wantErr) } var got string if tt.wantErr != nil { got = "" } else { got = string(w.data) } if diff := cmp.Diff(got, tt.want); diff != "" { t.Fatal(diff) } }) } } func createFile(content []byte) (string, error) { file, err := os.CreateTemp("", "prefix") if err != nil { return "", err } defer file.Close() if _, err := file.Write(content); err != nil { return "", err } return file.Name(), nil } type testData struct { initial string after string action string expectedOut string } func TestStartAndStop(t *testing.T) { tt := &testData{action: "cancel", expectedOut: `"level"=0 "msg"="stopping watcher"` + "\n"} out := &bytes.Buffer{} l := stdr.New(log.New(out, "", 0)) ctx, cancel := context.WithCancel(context.Background()) cancel() watcher, err := fsnotify.NewWatcher() if err != nil { t.Fatal(err) } w := &Watcher{Log: l, watcher: watcher} w.Start(ctx) if diff := cmp.Diff(out.String(), tt.expectedOut); diff != "" { t.Fatal(diff) } } func TestStartFileUpdateError(t *testing.T) { tt := &testData{expectedOut: `"level"=0 "msg"="file changed, updating cache"` + "\n" + `"msg"="failed to read file" "error"="open not-found.txt: no such file or directory" "file"="not-found.txt"` + "\n" + `"level"=0 "msg"="stopping watcher"` + "\n"} out := &bytes.Buffer{} l := stdr.New(log.New(out, "", 0)) got, name := tt.helper(t, l) defer os.Remove(name) ctx, cancel := context.WithCancel(context.Background()) go func() { <-time.After(time.Millisecond) got.FilePath = "not-found.txt" got.watcher.Events <- fsnotify.Event{Op: fsnotify.Write} cancel() }() got.Start(ctx) time.Sleep(time.Second) if diff := cmp.Diff(out.String(), tt.expectedOut); diff != "" { t.Fatal(diff) } } func TestStartFileUpdate(t *testing.T) { tt := &testData{initial: "once upon a time", after: "\nhello world", expectedOut: "once upon a time\nhello world"} got, name := tt.helper(t, logr.Discard()) defer os.Remove(name) ctx, cancel := context.WithCancel(context.Background()) go func() { <-time.After(time.Millisecond) got.fileMu.Lock() f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Log(err) } f.Write([]byte(tt.after)) f.Close() got.fileMu.Unlock() time.Sleep(time.Millisecond) cancel() }() got.Start(ctx) got.dataMu.RLock() d := got.data got.dataMu.RUnlock() if diff := cmp.Diff(string(d), tt.expectedOut); diff != "" { t.Log(string(d)) t.Fatal(diff) } } func TestStartFileUpdateClosedChan(t *testing.T) { out := &bytes.Buffer{} l := stdr.New(log.New(out, "", 0)) ctx, cancel := context.WithCancel(context.Background()) defer cancel() watcher, err := fsnotify.NewWatcher() if err != nil { t.Fatal(err) } w := &Watcher{Log: l, watcher: watcher} go w.Start(ctx) close(w.watcher.Events) time.Sleep(time.Millisecond) if diff := cmp.Diff(out.String(), ""); diff != "" { t.Fatal(diff) } } func TestStartError(t *testing.T) { tt := &testData{expectedOut: `"level"=0 "msg"="error watching file" "err"="test error"` + "\n" + `"level"=0 "msg"="stopping watcher"` + "\n"} out := &bytes.Buffer{} l := stdr.New(log.New(out, "", 0)) ctx, cancel := context.WithCancel(context.Background()) watcher, err := fsnotify.NewWatcher() if err != nil { t.Fatal(err) } w := &Watcher{Log: l, watcher: watcher} go func() { time.Sleep(time.Millisecond) w.watcher.Errors <- fmt.Errorf("test error") cancel() }() w.Start(ctx) if diff := cmp.Diff(out.String(), tt.expectedOut); diff != "" { t.Fatal(diff) } } func TestStartErrorContinue(t *testing.T) { out := &bytes.Buffer{} l := stdr.New(log.New(out, "", 0)) ctx, cancel := context.WithCancel(context.Background()) defer cancel() watcher, err := fsnotify.NewWatcher() if err != nil { t.Fatal(err) } w := &Watcher{Log: l, watcher: watcher} go w.Start(ctx) close(w.watcher.Errors) time.Sleep(time.Millisecond) if diff := cmp.Diff(out.String(), ""); diff != "" { t.Fatal(diff) } } func (tt *testData) helper(t *testing.T, l logr.Logger) (*Watcher, string) { t.Helper() name, err := createFile([]byte(tt.initial)) if err != nil { t.Fatal(err) } w, err := NewWatcher(l, name) if err != nil { t.Fatal(err) } w.dataMu.RLock() before := string(w.data) w.dataMu.RUnlock() if diff := cmp.Diff(before, tt.initial); diff != "" { t.Fatal("before", diff) } return w, name } func TestTranslate(t *testing.T) { input := dhcp{ MACAddress: []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, IPAddress: "192.168.2.150", SubnetMask: "255.255.255.0", DefaultGateway: "192.168.2.1", NameServers: []string{"1.1.1.1", "8.8.8.8"}, Hostname: "test-server", DomainName: "example.com", BroadcastAddress: "192.168.2.255", NTPServers: []string{"132.163.96.2"}, VLANID: "100", LeaseTime: 86400, Arch: "x86_64", DomainSearch: []string{"example.com"}, Netboot: netboot{ AllowPXE: true, IPXEScriptURL: "http://boot.netboot.xyz", IPXEScript: "#!ipxe\nchain http://boot.netboot.xyz", Console: "ttyS0", Facility: "onprem", }, } wantDHCP := &data.DHCP{ MACAddress: []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, IPAddress: netip.MustParseAddr("192.168.2.150"), SubnetMask: net.IPv4Mask(255, 255, 255, 0), DefaultGateway: netip.MustParseAddr("192.168.2.1"), NameServers: []net.IP{{1, 1, 1, 1}, {8, 8, 8, 8}}, Hostname: "test-server", DomainName: "example.com", BroadcastAddress: netip.MustParseAddr("192.168.2.255"), NTPServers: []net.IP{{132, 163, 96, 2}}, VLANID: "100", LeaseTime: 86400, Arch: "x86_64", DomainSearch: []string{"example.com"}, } wantNetboot := &data.Netboot{ AllowNetboot: true, IPXEScriptURL: &url.URL{Scheme: "http", Host: "boot.netboot.xyz"}, IPXEScript: "#!ipxe\nchain http://boot.netboot.xyz", Console: "ttyS0", Facility: "onprem", } w := &Watcher{Log: logr.Discard()} gotDHCP, gotNetboot, err := w.translate(input) if err != nil { t.Fatal(err) } if diff := cmp.Diff(gotDHCP, wantDHCP, cmpopts.IgnoreUnexported(netip.Addr{})); diff != "" { t.Error(diff) } if diff := cmp.Diff(gotNetboot, wantNetboot); diff != "" { t.Error(diff) } } func TestTranslateErrors(t *testing.T) { tests := map[string]struct { input dhcp wantErr error }{ "invalid IP": {input: dhcp{IPAddress: "not an IP"}, wantErr: errParseIP}, "invalid subnet mask": {input: dhcp{IPAddress: "1.1.1.1", SubnetMask: "not a mask"}, wantErr: errParseSubnet}, "invalid gateway": {input: dhcp{IPAddress: "1.1.1.1", SubnetMask: "192.168.1.255", DefaultGateway: "not a gateway"}, wantErr: nil}, "invalid broadcast address": {input: dhcp{IPAddress: "1.1.1.1", SubnetMask: "192.168.1.255"}, wantErr: nil}, "invalid NameServers": {input: dhcp{IPAddress: "1.1.1.1", SubnetMask: "192.168.1.255", NameServers: []string{"no good"}}, wantErr: nil}, "invalid ntpservers": {input: dhcp{IPAddress: "1.1.1.1", SubnetMask: "192.168.1.255", NTPServers: []string{"no good"}}, wantErr: nil}, "invalid ipxe script url": {input: dhcp{IPAddress: "1.1.1.1", SubnetMask: "255.255.255.0", Netboot: netboot{IPXEScriptURL: ":not a url"}}, wantErr: errParseURL}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { w := &Watcher{Log: stdr.New(log.New(os.Stdout, "", log.Lshortfile))} if _, _, err := w.translate(tt.input); !errors.Is(err, tt.wantErr) { t.Errorf("translate() = %T, want %T", err, tt.wantErr) } }) } } func TestGetByMac(t *testing.T) { tests := map[string]struct { mac net.HardwareAddr badData bool wantErr error }{ "no record found": {mac: net.HardwareAddr{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, wantErr: errRecordNotFound}, "record found": {mac: net.HardwareAddr{0x08, 0x00, 0x27, 0x29, 0x4e, 0x67}, wantErr: nil}, "fail error translating": {mac: net.HardwareAddr{0x08, 0x00, 0x27, 0x29, 0x4e, 0x68}, wantErr: errParseIP}, "fail parsing file": {badData: true, wantErr: errFileFormat}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { data := "testdata/example.yaml" if tt.badData { var err error data, err = createFile([]byte("not a yaml file")) if err != nil { t.Fatal(err) } defer os.Remove(data) } w, err := NewWatcher(logr.Discard(), data) if err != nil { t.Fatal(err) } _, _, err = w.GetByMac(context.Background(), tt.mac) if !errors.Is(err, tt.wantErr) { t.Fatal(err) } }) } } func TestGetByIP(t *testing.T) { tests := map[string]struct { ip net.IP badData bool wantErr error }{ "no record found": {ip: net.IPv4(172, 168, 2, 1), wantErr: errRecordNotFound}, "record found": {ip: net.IPv4(192, 168, 2, 153), wantErr: nil}, "fail parsing file": {badData: true, wantErr: errFileFormat}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { data := "testdata/example.yaml" if tt.badData { var err error data, err = createFile([]byte("not a yaml file")) if err != nil { t.Fatal(err) } defer os.Remove(data) } w, err := NewWatcher(logr.Discard(), data) if err != nil { t.Fatal(err) } _, _, err = w.GetByIP(context.Background(), tt.ip) if !errors.Is(err, tt.wantErr) { t.Fatal(err) } }) } } ================================================ FILE: internal/backend/file/testdata/example.yaml ================================================ --- 08:00:27:29:4E:67: ipAddress: "192.168.2.153" subnetMask: "255.255.255.0" defaultGateway: "192.168.2.1" nameServers: - "8.8.8.8" - "1.1.1.1" hostname: "pxe-virtualbox" domainName: "example.com" broadcastAddress: "192.168.2.255" ntpServers: - "132.163.96.2" - "132.163.96.3" leaseTime: 86400 domainSearch: - "example.com" netboot: allowPxe: true ipxeScriptUrl: "https://boot.netboot.xyz" 52:54:00:aa:88:2a: ipAddress: "192.168.2.15" subnetMask: "255.255.255.0" defaultGateway: "192.168.2.1" nameServers: - "8.8.8.8" - "1.1.1.1" hostname: "sandbox" domainName: "example.com" broadcastAddress: "192.168.2.255" ntpServers: - "132.163.96.2" - "132.163.96.3" leaseTime: 86400 domainSearch: - "example.com" netboot: allowPxe: true ipxeScriptUrl: "https://boot.netboot.xyz" 86:96:b0:6e:ca:36: ipAddress: "192.168.2.158" subnetMask: "255.255.255.0" defaultGateway: "192.168.2.1" nameServers: - "8.8.8.8" - "1.1.1.1" hostname: "pxe-proxmox" domainName: "example.com" broadcastAddress: "192.168.2.255" ntpServers: - "132.163.96.2" - "132.163.96.3" leaseTime: 86400 domainSearch: - "example.com" netboot: allowPxe: true ipxeScriptUrl: "http://boot.netboot.xyz" b4:96:91:6f:33:d0: ipAddress: "192.168.56.15" subnetMask: "255.255.255.0" defaultGateway: "192.168.56.4" nameServers: - "8.8.8.8" - "1.1.1.1" hostname: "dhcp-testing" domainName: "example.com" broadcastAddress: "192.168.56.255" ntpServers: - "132.163.96.2" - "132.163.96.3" leaseTime: 86400 domainSearch: - "example.com" netboot: allowPxe: true ipxeScriptUrl: "https://boot.netboot.xyz" 08:00:27:29:4E:68: # bad data ipAddress: "3" subnetMask: "255.255.255.0" ================================================ FILE: internal/backend/kube/error.go ================================================ package kube import ( "net/http" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type hardwareNotFoundError struct{} func (hardwareNotFoundError) NotFound() bool { return true } func (hardwareNotFoundError) Error() string { return "hardware not found" } // Status() implements the APIStatus interface from apimachinery/pkg/api/errors // so that IsNotFound function could be used against this error type. func (hardwareNotFoundError) Status() metav1.Status { return metav1.Status{ Reason: metav1.StatusReasonNotFound, Code: http.StatusNotFound, } } ================================================ FILE: internal/backend/kube/index.go ================================================ package kube import ( "github.com/tinkerbell/tink/api/v1alpha1" "sigs.k8s.io/controller-runtime/pkg/client" ) // MACAddrIndex is an index used with a controller-runtime client to lookup hardware by MAC. const MACAddrIndex = ".Spec.Interfaces.MAC" // MACAddrs returns a list of MAC addresses for a Hardware object. func MACAddrs(obj client.Object) []string { hw, ok := obj.(*v1alpha1.Hardware) if !ok { return nil } return GetMACs(hw) } // GetMACs retrieves all MACs associated with h. func GetMACs(h *v1alpha1.Hardware) []string { var macs []string for _, i := range h.Spec.Interfaces { if i.DHCP != nil && i.DHCP.MAC != "" { macs = append(macs, i.DHCP.MAC) } } return macs } // IPAddrIndex is an index used with a controller-runtime client to lookup hardware by IP. const IPAddrIndex = ".Spec.Interfaces.DHCP.IP" // IPAddrs returns a list of IP addresses for a Hardware object. func IPAddrs(obj client.Object) []string { hw, ok := obj.(*v1alpha1.Hardware) if !ok { return nil } return GetIPs(hw) } // GetIPs retrieves all IP addresses. func GetIPs(h *v1alpha1.Hardware) []string { var ips []string for _, i := range h.Spec.Interfaces { if i.DHCP != nil && i.DHCP.IP != nil && i.DHCP.IP.Address != "" { ips = append(ips, i.DHCP.IP.Address) } } return ips } ================================================ FILE: internal/backend/kube/index_test.go ================================================ package kube import ( "testing" "github.com/google/go-cmp/cmp" "github.com/tinkerbell/tink/api/v1alpha1" "sigs.k8s.io/controller-runtime/pkg/client" ) func TestMACAddrs(t *testing.T) { tests := map[string]struct { hw client.Object want []string }{ "not a v1alpha1.Hardware object": {hw: &v1alpha1.Workflow{}, want: nil}, "2 MACs": {hw: &v1alpha1.Hardware{ Spec: v1alpha1.HardwareSpec{ Interfaces: []v1alpha1.Interface{ { DHCP: &v1alpha1.DHCP{ MAC: "00:00:00:00:00:00", }, }, { DHCP: &v1alpha1.DHCP{ MAC: "00:00:00:00:00:01", }, }, { DHCP: &v1alpha1.DHCP{}, }, }, }, }, want: []string{"00:00:00:00:00:00", "00:00:00:00:00:01"}}, "no interfaces": {hw: &v1alpha1.Hardware{}, want: nil}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { macs := MACAddrs(tc.hw) if diff := cmp.Diff(macs, tc.want); diff != "" { t.Errorf("unexpected MACs (+want -got):\n%s", diff) } }) } } func TestIPAddrs(t *testing.T) { tests := map[string]struct { hw client.Object want []string }{ "not a v1alpha1.Hardware object": {hw: &v1alpha1.Workflow{}, want: nil}, "2 IPs": {hw: &v1alpha1.Hardware{ Spec: v1alpha1.HardwareSpec{ Interfaces: []v1alpha1.Interface{ { DHCP: &v1alpha1.DHCP{ IP: &v1alpha1.IP{ Address: "192.168.2.1", }, }, }, { DHCP: &v1alpha1.DHCP{ IP: &v1alpha1.IP{ Address: "192.168.2.2", }, }, }, { DHCP: &v1alpha1.DHCP{}, }, { DHCP: &v1alpha1.DHCP{ IP: &v1alpha1.IP{}, }, }, }, }, }, want: []string{"192.168.2.1", "192.168.2.2"}}, "no interfaces": {hw: &v1alpha1.Hardware{}, want: nil}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { got := IPAddrs(tc.hw) if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("unexpected IPs (-want +got):\n%s", diff) } }) } } ================================================ FILE: internal/backend/kube/kube.go ================================================ // Package kube is a backend implementation that uses the Tinkerbell CRDs to get DHCP data. package kube import ( "context" "errors" "fmt" "net" "net/netip" "net/url" "github.com/ccoveille/go-safecast" "github.com/tinkerbell/smee/internal/dhcp/data" "github.com/tinkerbell/tink/api/v1alpha1" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/codes" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/cluster" ) const tracerName = "github.com/tinkerbell/smee/dhcp" // Backend is a backend implementation that uses the Tinkerbell CRDs to get DHCP data. type Backend struct { cluster cluster.Cluster } // NewBackend returns a controller-runtime cluster.Cluster with the Tinkerbell runtime // scheme registered, and indexers for: // * Hardware by MAC address // * Hardware by IP address // // Callers must instantiate the client-side cache by calling Start() before use. func NewBackend(conf *rest.Config, opts ...cluster.Option) (*Backend, error) { c, err := cluster.New(conf, opts...) if err != nil { return nil, fmt.Errorf("failed to create new cluster config: %w", err) } if err := c.GetFieldIndexer().IndexField(context.Background(), &v1alpha1.Hardware{}, MACAddrIndex, MACAddrs); err != nil { return nil, fmt.Errorf("failed to setup indexer: %w", err) } if err := c.GetFieldIndexer().IndexField(context.Background(), &v1alpha1.Hardware{}, IPAddrIndex, IPAddrs); err != nil { return nil, fmt.Errorf("failed to setup indexer(.spec.interfaces.dhcp.ip.address): %w", err) } return &Backend{cluster: c}, nil } // Start starts the client-side cache. func (b *Backend) Start(ctx context.Context) error { return b.cluster.Start(ctx) } // GetByMac implements the handler.BackendReader interface and returns DHCP and netboot data based on a mac address. func (b *Backend) GetByMac(ctx context.Context, mac net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { tracer := otel.Tracer(tracerName) ctx, span := tracer.Start(ctx, "backend.kube.GetByMac") defer span.End() hardwareList := &v1alpha1.HardwareList{} if err := b.cluster.GetClient().List(ctx, hardwareList, &client.MatchingFields{MACAddrIndex: mac.String()}); err != nil { span.SetStatus(codes.Error, err.Error()) return nil, nil, fmt.Errorf("failed listing hardware for (%v): %w", mac, err) } if len(hardwareList.Items) == 0 { err := hardwareNotFoundError{} span.SetStatus(codes.Error, err.Error()) return nil, nil, err } if len(hardwareList.Items) > 1 { err := fmt.Errorf("got %d hardware objects for mac %s, expected only 1", len(hardwareList.Items), mac) span.SetStatus(codes.Error, err.Error()) return nil, nil, err } i := v1alpha1.Interface{} for _, iface := range hardwareList.Items[0].Spec.Interfaces { if iface.DHCP.MAC == mac.String() { i = iface break } } d, n, err := transform(i, hardwareList.Items[0].Spec.Metadata) if err != nil { span.SetStatus(codes.Error, err.Error()) return nil, nil, err } span.SetAttributes(d.EncodeToAttributes()...) span.SetAttributes(n.EncodeToAttributes()...) span.SetStatus(codes.Ok, "") return d, n, nil } // GetByIP implements the handler.BackendReader interface and returns DHCP and netboot data based on an IP address. func (b *Backend) GetByIP(ctx context.Context, ip net.IP) (*data.DHCP, *data.Netboot, error) { tracer := otel.Tracer(tracerName) ctx, span := tracer.Start(ctx, "backend.kube.GetByIP") defer span.End() hardwareList := &v1alpha1.HardwareList{} if err := b.cluster.GetClient().List(ctx, hardwareList, &client.MatchingFields{IPAddrIndex: ip.String()}); err != nil { span.SetStatus(codes.Error, err.Error()) return nil, nil, fmt.Errorf("failed listing hardware for (%v): %w", ip, err) } if len(hardwareList.Items) == 0 { err := hardwareNotFoundError{} span.SetStatus(codes.Error, err.Error()) return nil, nil, err } if len(hardwareList.Items) > 1 { err := fmt.Errorf("got %d hardware objects for ip: %s, expected only 1", len(hardwareList.Items), ip) span.SetStatus(codes.Error, err.Error()) return nil, nil, err } i := v1alpha1.Interface{} for _, iface := range hardwareList.Items[0].Spec.Interfaces { if iface.DHCP.IP.Address == ip.String() { i = iface break } } d, n, err := transform(i, hardwareList.Items[0].Spec.Metadata) if err != nil { span.SetStatus(codes.Error, err.Error()) return nil, nil, err } span.SetAttributes(d.EncodeToAttributes()...) span.SetAttributes(n.EncodeToAttributes()...) span.SetStatus(codes.Ok, "") return d, n, nil } // toDHCPData converts a v1alpha1.DHCP to a data.DHCP data structure. // if required fields are missing, an error is returned. // Required fields: v1alpha1.Interface.DHCP.MAC, v1alpha1.Interface.DHCP.IP.Address, v1alpha1.Interface.DHCP.IP.Netmask. func toDHCPData(h *v1alpha1.DHCP) (*data.DHCP, error) { if h == nil { return nil, errors.New("no DHCP data") } d := new(data.DHCP) var err error // MACAddress is required if d.MACAddress, err = net.ParseMAC(h.MAC); err != nil { return nil, err } if h.IP != nil { // IPAddress is required if d.IPAddress, err = netip.ParseAddr(h.IP.Address); err != nil { return nil, err } // Netmask is required sm := net.ParseIP(h.IP.Netmask) if sm == nil { return nil, errors.New("no netmask") } d.SubnetMask = net.IPMask(sm.To4()) } else { return nil, errors.New("no IP data") } // Gateway is optional, but should be a valid IP address if present if h.IP.Gateway != "" { if d.DefaultGateway, err = netip.ParseAddr(h.IP.Gateway); err != nil { return nil, err } } // name servers, optional for _, s := range h.NameServers { ip := net.ParseIP(s) if ip == nil { break } d.NameServers = append(d.NameServers, ip) } // timeservers, optional for _, s := range h.TimeServers { ip := net.ParseIP(s) if ip == nil { break } d.NTPServers = append(d.NTPServers, ip) } // hostname, optional d.Hostname = h.Hostname // lease time required // Default to one week d.LeaseTime = 604800 if v, err := safecast.ToUint32(h.LeaseTime); err == nil { d.LeaseTime = v } // arch d.Arch = h.Arch // vlanid d.VLANID = h.VLANID return d, nil } // toNetbootData converts a hardware interface to a data.Netboot data structure. func toNetbootData(i *v1alpha1.Netboot, facility string) (*data.Netboot, error) { if i == nil { return nil, errors.New("no netboot data") } n := new(data.Netboot) // allow machine to netboot if i.AllowPXE != nil { n.AllowNetboot = *i.AllowPXE } // ipxe script url is optional but if provided, it must be a valid url if i.IPXE != nil { if i.IPXE.URL != "" { u, err := url.ParseRequestURI(i.IPXE.URL) if err != nil { return nil, err } n.IPXEScriptURL = u } } // ipxescript if i.IPXE != nil { n.IPXEScript = i.IPXE.Contents } // console n.Console = "" // facility n.Facility = facility // OSIE data n.OSIE = data.OSIE{} if i.OSIE != nil { if b, err := url.Parse(i.OSIE.BaseURL); err == nil { n.OSIE.BaseURL = b } n.OSIE.Kernel = i.OSIE.Kernel n.OSIE.Initrd = i.OSIE.Initrd } return n, nil } // transform returns data.DHCP and data.Netboot from part a v1alpha1.Interface and *v1alpha1.HardwareMetadata. func transform(i v1alpha1.Interface, m *v1alpha1.HardwareMetadata) (*data.DHCP, *data.Netboot, error) { d, err := toDHCPData(i.DHCP) if err != nil { return nil, nil, fmt.Errorf("failed to convert hardware to DHCP data: %w", err) } d.Disabled = i.DisableDHCP // Facility is used in the default HookOS iPXE script so we get it from the hardware metadata, if set. facility := "" if m != nil { if m.Facility != nil { facility = m.Facility.FacilityCode } } n, err := toNetbootData(i.Netboot, facility) if err != nil { return nil, nil, fmt.Errorf("failed to convert hardware to netboot data: %w", err) } return d, n, nil } ================================================ FILE: internal/backend/kube/kube_test.go ================================================ package kube import ( "context" "net" "net/http" "net/netip" "net/url" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/tinkerbell/smee/internal/dhcp/data" "github.com/tinkerbell/tink/api/v1alpha1" "k8s.io/apimachinery/pkg/api/meta" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/cache/informertest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/cluster" ) func TestNewBackend(t *testing.T) { tests := map[string]struct { conf *rest.Config opt cluster.Option shouldErr bool }{ "no config": {shouldErr: true}, "failed index field": {shouldErr: true, conf: new(rest.Config), opt: func(o *cluster.Options) { cl := fake.NewClientBuilder().Build() o.NewClient = func(*rest.Config, client.Options) (client.Client, error) { return cl, nil } o.MapperProvider = func(*rest.Config, *http.Client) (meta.RESTMapper, error) { return cl.RESTMapper(), nil } }}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { b, err := NewBackend(tt.conf, tt.opt) if tt.shouldErr && err == nil { t.Fatal("expected error") } if !tt.shouldErr && err != nil { t.Fatal(err) } if !tt.shouldErr && b == nil { t.Fatal("expected backend") } }) } } func TestToDHCPData(t *testing.T) { tests := map[string]struct { in *v1alpha1.DHCP want *data.DHCP shouldErr bool }{ "nil input": { in: nil, shouldErr: true, }, "no mac": { in: &v1alpha1.DHCP{}, shouldErr: true, }, "bad mac": { in: &v1alpha1.DHCP{MAC: "bad"}, shouldErr: true, }, "no ip": { in: &v1alpha1.DHCP{MAC: "aa:bb:cc:dd:ee:ff", IP: &v1alpha1.IP{}}, shouldErr: true, }, "no subnet": { in: &v1alpha1.DHCP{MAC: "aa:bb:cc:dd:ee:ff", IP: &v1alpha1.IP{Address: "192.168.2.4"}}, shouldErr: true, }, "v1alpha1.IP == nil": { in: &v1alpha1.DHCP{MAC: "aa:bb:cc:dd:ee:ff", IP: nil}, shouldErr: true, }, "bad gateway": { in: &v1alpha1.DHCP{MAC: "aa:bb:cc:dd:ee:ff", IP: &v1alpha1.IP{Address: "192.168.2.4", Netmask: "255.255.254.0", Gateway: "bad"}}, shouldErr: true, }, "one bad nameserver": { in: &v1alpha1.DHCP{ MAC: "00:00:00:00:00:04", NameServers: []string{"1.1.1.1", "bad"}, IP: &v1alpha1.IP{ Address: "192.168.2.4", Netmask: "255.255.0.0", Gateway: "192.168.2.1", }, }, want: &data.DHCP{ SubnetMask: net.IPv4Mask(255, 255, 0, 0), DefaultGateway: netip.MustParseAddr("192.168.2.1"), NameServers: []net.IP{net.IPv4(1, 1, 1, 1)}, IPAddress: netip.MustParseAddr("192.168.2.4"), MACAddress: net.HardwareAddr{0x00, 0x00, 0x00, 0x00, 0x00, 0x04}, }, }, "full": { in: &v1alpha1.DHCP{ MAC: "00:00:00:00:00:04", Hostname: "test", LeaseTime: 3600, NameServers: []string{"1.1.1.1"}, IP: &v1alpha1.IP{ Address: "192.168.1.4", Netmask: "255.255.255.0", Gateway: "192.168.1.1", }, }, want: &data.DHCP{ SubnetMask: net.IPv4Mask(255, 255, 255, 0), DefaultGateway: netip.MustParseAddr("192.168.1.1"), NameServers: []net.IP{net.IPv4(1, 1, 1, 1)}, Hostname: "test", LeaseTime: 3600, IPAddress: netip.MustParseAddr("192.168.1.4"), MACAddress: net.HardwareAddr{0x00, 0x00, 0x00, 0x00, 0x00, 0x04}, }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := toDHCPData(tt.in) if tt.shouldErr && err == nil { t.Fatal("expected error") } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(netip.Addr{})); diff != "" { t.Fatal(diff) } }) } } func TestToNetbootData(t *testing.T) { tests := map[string]struct { in *v1alpha1.Netboot want *data.Netboot shouldErr bool }{ "nil input": {in: nil, shouldErr: true}, "bad ipxe url": {in: &v1alpha1.Netboot{IPXE: &v1alpha1.IPXE{URL: "bad"}}, shouldErr: true}, "successful": {in: &v1alpha1.Netboot{IPXE: &v1alpha1.IPXE{URL: "http://example.com/ipxe.ipxe"}}, want: &data.Netboot{IPXEScriptURL: &url.URL{Scheme: "http", Host: "example.com", Path: "/ipxe.ipxe"}}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := toNetbootData(tt.in, "") if tt.shouldErr && err == nil { t.Fatal("expected error") } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(netip.Addr{})); diff != "" { t.Fatal(diff) } }) } } func TestGetByIP(t *testing.T) { tests := map[string]struct { hwObject []v1alpha1.Hardware wantDHCP *data.DHCP wantNetboot *data.Netboot shouldErr bool failToList bool }{ "empty hardware list": {shouldErr: true, hwObject: []v1alpha1.Hardware{}}, "more than one hardware": {shouldErr: true, hwObject: []v1alpha1.Hardware{hwObject1, hwObject2}}, "bad dhcp data": {shouldErr: true, hwObject: []v1alpha1.Hardware{badDHCPObject2}}, "bad netboot data": {shouldErr: true, hwObject: []v1alpha1.Hardware{badNetbootObject2}}, "fail to list hardware": {shouldErr: true, failToList: true}, "good data": {hwObject: []v1alpha1.Hardware{hwObject1}, wantDHCP: &data.DHCP{ MACAddress: net.HardwareAddr{0x3c, 0xec, 0xef, 0x4c, 0x4f, 0x54}, IPAddress: netip.MustParseAddr("172.16.10.100"), SubnetMask: []byte{0xff, 0xff, 0xff, 0x00}, DefaultGateway: netip.MustParseAddr("255.255.255.0"), NameServers: []net.IP{ {0x1, 0x1, 0x1, 0x1}, }, Hostname: "sm01", LeaseTime: 86400, Arch: "x86_64", }, wantNetboot: &data.Netboot{ AllowNetboot: true, IPXEScriptURL: &url.URL{ Scheme: "http", Host: "netboot.xyz", }, Facility: "onprem", }}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { rs := runtime.NewScheme() if err := scheme.AddToScheme(rs); err != nil { t.Fatal(err) } if err := v1alpha1.AddToScheme(rs); err != nil { t.Fatal(err) } ct := fake.NewClientBuilder() if !tc.failToList { ct = ct.WithScheme(rs) ct = ct.WithRuntimeObjects(&v1alpha1.HardwareList{}) ct = ct.WithIndex(&v1alpha1.Hardware{}, IPAddrIndex, func(client.Object) []string { var list []string for _, elem := range tc.hwObject { list = append(list, elem.Spec.Interfaces[0].DHCP.IP.Address) } return list }) } if len(tc.hwObject) > 0 { t.Logf("%+v", tc.hwObject[0].Spec.Interfaces[0].DHCP) t.Logf("%+v", tc.hwObject[0].Spec.Interfaces[0].DHCP.IP) ct = ct.WithLists(&v1alpha1.HardwareList{Items: tc.hwObject}) } cl := ct.Build() fn := func(o *cluster.Options) { o.NewClient = func(*rest.Config, client.Options) (client.Client, error) { return cl, nil } o.MapperProvider = func(*rest.Config, *http.Client) (meta.RESTMapper, error) { return cl.RESTMapper(), nil } o.NewCache = func(*rest.Config, cache.Options) (cache.Cache, error) { return &informertest.FakeInformers{Scheme: cl.Scheme()}, nil } } rc := new(rest.Config) b, err := NewBackend(rc, fn) if err != nil { t.Fatal(err) } go b.Start(context.Background()) gotDHCP, gotNetboot, err := b.GetByIP(context.Background(), net.IPv4(172, 16, 10, 100)) if tc.shouldErr && err == nil { t.Log(err) t.Fatal("expected error") } if diff := cmp.Diff(gotDHCP, tc.wantDHCP, cmpopts.IgnoreUnexported(netip.Addr{})); diff != "" { t.Fatal(diff) } if diff := cmp.Diff(gotNetboot, tc.wantNetboot); diff != "" { t.Fatal(diff) } }) } } func TestGetByMac(t *testing.T) { tests := map[string]struct { hwObject []v1alpha1.Hardware wantDHCP *data.DHCP wantNetboot *data.Netboot shouldErr bool failToList bool }{ "empty hardware list": {shouldErr: true}, "more than one hardware": {shouldErr: true, hwObject: []v1alpha1.Hardware{hwObject1, hwObject2}}, "bad dhcp data": {shouldErr: true, hwObject: []v1alpha1.Hardware{badDHCPObject}}, "bad netboot data": {shouldErr: true, hwObject: []v1alpha1.Hardware{badNetbootObject}}, "fail to list hardware": {shouldErr: true, failToList: true}, "good data": {hwObject: []v1alpha1.Hardware{hwObject1}, wantDHCP: &data.DHCP{ MACAddress: net.HardwareAddr{0x3c, 0xec, 0xef, 0x4c, 0x4f, 0x54}, IPAddress: netip.MustParseAddr("172.16.10.100"), SubnetMask: []byte{0xff, 0xff, 0xff, 0x00}, DefaultGateway: netip.MustParseAddr("255.255.255.0"), NameServers: []net.IP{ {0x1, 0x1, 0x1, 0x1}, }, Hostname: "sm01", LeaseTime: 86400, Arch: "x86_64", }, wantNetboot: &data.Netboot{ AllowNetboot: true, IPXEScriptURL: &url.URL{ Scheme: "http", Host: "netboot.xyz", }, Facility: "onprem", }}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { rs := runtime.NewScheme() if err := scheme.AddToScheme(rs); err != nil { t.Fatal(err) } if err := v1alpha1.AddToScheme(rs); err != nil { t.Fatal(err) } ct := fake.NewClientBuilder() if !tc.failToList { ct = ct.WithScheme(rs) ct = ct.WithRuntimeObjects(&v1alpha1.HardwareList{}) ct = ct.WithIndex(&v1alpha1.Hardware{}, MACAddrIndex, func(client.Object) []string { var list []string for _, elem := range tc.hwObject { list = append(list, elem.Spec.Interfaces[0].DHCP.MAC) } return list }) } if len(tc.hwObject) > 0 { t.Logf("%+v", tc.hwObject[0].Spec.Interfaces[0].DHCP) t.Logf("%+v", tc.hwObject[0].Spec.Interfaces[0].DHCP.MAC) ct = ct.WithLists(&v1alpha1.HardwareList{Items: tc.hwObject}) } cl := ct.Build() fn := func(o *cluster.Options) { o.NewClient = func(*rest.Config, client.Options) (client.Client, error) { return cl, nil } o.MapperProvider = func(*rest.Config, *http.Client) (meta.RESTMapper, error) { return cl.RESTMapper(), nil } o.NewCache = func(*rest.Config, cache.Options) (cache.Cache, error) { return &informertest.FakeInformers{Scheme: cl.Scheme()}, nil } } rc := new(rest.Config) b, err := NewBackend(rc, fn) if err != nil { t.Fatal(err) } go b.Start(context.Background()) gotDHCP, gotNetboot, err := b.GetByMac(context.Background(), net.HardwareAddr{0x3c, 0xec, 0xef, 0x4c, 0x4f, 0x54}) if tc.shouldErr && err == nil { t.Log(err) t.Fatal("expected error") } if diff := cmp.Diff(gotDHCP, tc.wantDHCP, cmpopts.IgnoreUnexported(netip.Addr{})); diff != "" { t.Fatal(diff) } if diff := cmp.Diff(gotNetboot, tc.wantNetboot); diff != "" { t.Fatal(diff) } }) } } var hwObject1 = v1alpha1.Hardware{ TypeMeta: v1.TypeMeta{ Kind: "Hardware", APIVersion: "tinkerbell.org/v1alpha1", }, ObjectMeta: v1.ObjectMeta{ Name: "machine1", Namespace: "default", }, Spec: v1alpha1.HardwareSpec{ Metadata: &v1alpha1.HardwareMetadata{ Facility: &v1alpha1.MetadataFacility{ FacilityCode: "onprem", }, }, Interfaces: []v1alpha1.Interface{ { Netboot: &v1alpha1.Netboot{ AllowPXE: &[]bool{true}[0], AllowWorkflow: &[]bool{true}[0], IPXE: &v1alpha1.IPXE{ URL: "http://netboot.xyz", }, }, DHCP: &v1alpha1.DHCP{ Arch: "x86_64", Hostname: "sm01", IP: &v1alpha1.IP{ Address: "172.16.10.100", Gateway: "172.16.10.1", Netmask: "255.255.255.0", }, LeaseTime: 86400, MAC: "3c:ec:ef:4c:4f:54", NameServers: []string{"1.1.1.1"}, UEFI: true, }, }, }, }, } var hwObject2 = v1alpha1.Hardware{ TypeMeta: v1.TypeMeta{ Kind: "Hardware", APIVersion: "tinkerbell.org/v1alpha1", }, ObjectMeta: v1.ObjectMeta{ Name: "machine2", Namespace: "default", }, Spec: v1alpha1.HardwareSpec{ Interfaces: []v1alpha1.Interface{ { Netboot: &v1alpha1.Netboot{ AllowPXE: &[]bool{true}[0], AllowWorkflow: &[]bool{true}[0], IPXE: &v1alpha1.IPXE{ URL: "http://netboot.xyz", }, }, DHCP: &v1alpha1.DHCP{ Arch: "x86_64", Hostname: "sm01", IP: &v1alpha1.IP{ Address: "172.16.10.101", Gateway: "172.16.10.1", Netmask: "255.255.255.0", }, LeaseTime: 86400, MAC: "3c:ec:ef:4c:4f:55", NameServers: []string{"1.1.1.1"}, UEFI: true, }, }, }, Metadata: &v1alpha1.HardwareMetadata{ Facility: &v1alpha1.MetadataFacility{ FacilityCode: "ewr2", }, }, }, } var badDHCPObject = v1alpha1.Hardware{ TypeMeta: v1.TypeMeta{ Kind: "Hardware", APIVersion: "tinkerbell.org/v1alpha1", }, ObjectMeta: v1.ObjectMeta{ Name: "machine2", Namespace: "default", }, Spec: v1alpha1.HardwareSpec{ Interfaces: []v1alpha1.Interface{ { Netboot: &v1alpha1.Netboot{ AllowPXE: &[]bool{true}[0], AllowWorkflow: &[]bool{true}[0], IPXE: &v1alpha1.IPXE{ URL: "http://netboot.xyz", }, }, DHCP: &v1alpha1.DHCP{ Arch: "x86_64", Hostname: "sm01", IP: &v1alpha1.IP{ Address: "172.16.10.100", Gateway: "bad-address", Netmask: "255.255.255.0", }, LeaseTime: 86400, MAC: "3c:ec:ef:4c:4f:54", NameServers: []string{"1.1.1.1"}, UEFI: true, }, }, }, }, } var badDHCPObject2 = v1alpha1.Hardware{ TypeMeta: v1.TypeMeta{ Kind: "Hardware", APIVersion: "tinkerbell.org/v1alpha1", }, ObjectMeta: v1.ObjectMeta{ Name: "machine2", Namespace: "default", }, Spec: v1alpha1.HardwareSpec{ Interfaces: []v1alpha1.Interface{ { Netboot: &v1alpha1.Netboot{ AllowPXE: &[]bool{true}[0], AllowWorkflow: &[]bool{true}[0], IPXE: &v1alpha1.IPXE{ URL: "http://netboot.xyz", }, }, DHCP: &v1alpha1.DHCP{ Arch: "x86_64", Hostname: "sm01", IP: &v1alpha1.IP{ Address: "172.16.10.100", Gateway: "bad-address", Netmask: "255.255.255.0", }, LeaseTime: 86400, MAC: "3c:ec:ef:4c:4f:55", NameServers: []string{"1.1.1.1"}, UEFI: true, }, }, }, }, } var badNetbootObject = v1alpha1.Hardware{ TypeMeta: v1.TypeMeta{ Kind: "Hardware", APIVersion: "tinkerbell.org/v1alpha1", }, ObjectMeta: v1.ObjectMeta{ Name: "machine2", Namespace: "default", }, Spec: v1alpha1.HardwareSpec{ Interfaces: []v1alpha1.Interface{ { Netboot: &v1alpha1.Netboot{ IPXE: &v1alpha1.IPXE{ URL: "bad-url", }, }, DHCP: &v1alpha1.DHCP{ Hostname: "sm01", IP: &v1alpha1.IP{ Address: "172.16.10.101", Gateway: "172.16.10.1", Netmask: "255.255.255.0", }, LeaseTime: 86400, MAC: "3c:ec:ef:4c:4f:54", NameServers: []string{"1.1.1.1"}, }, }, }, }, } var badNetbootObject2 = v1alpha1.Hardware{ TypeMeta: v1.TypeMeta{ Kind: "Hardware", APIVersion: "tinkerbell.org/v1alpha1", }, ObjectMeta: v1.ObjectMeta{ Name: "machine2", Namespace: "default", }, Spec: v1alpha1.HardwareSpec{ Interfaces: []v1alpha1.Interface{ { Netboot: &v1alpha1.Netboot{ IPXE: &v1alpha1.IPXE{ URL: "bad-url", }, }, DHCP: &v1alpha1.DHCP{ Hostname: "sm01", IP: &v1alpha1.IP{ Address: "172.16.10.100", Gateway: "172.16.10.1", Netmask: "255.255.255.0", }, LeaseTime: 86400, MAC: "3c:ec:ef:4c:4f:54", NameServers: []string{"1.1.1.1"}, }, }, }, }, } ================================================ FILE: internal/backend/noop/noop.go ================================================ package noop import ( "context" "errors" "net" "github.com/tinkerbell/smee/internal/dhcp/data" ) var errAlways = errors.New("noop backend always returns an error") type Backend struct{} func (n Backend) GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { return nil, nil, errAlways } func (n Backend) GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) { return nil, nil, errAlways } ================================================ FILE: internal/backend/noop/noop_test.go ================================================ package noop import ( "context" "errors" "testing" ) func TestBackend(t *testing.T) { b := Backend{} ctx := context.Background() _, _, err := b.GetByMac(ctx, nil) if err == nil { t.Error("expected error") } if !errors.Is(err, errAlways) { t.Error("expected errAlways") } _, _, err = b.GetByIP(ctx, nil) if err == nil { t.Error("expected error") } if !errors.Is(err, errAlways) { t.Error("expected errAlways") } } ================================================ FILE: internal/dhcp/data/data.go ================================================ // Package data is an interface between DHCP backend implementations and the DHCP server. package data import ( "net" "net/netip" "net/url" "strings" "github.com/insomniacslk/dhcp/dhcpv4" "go.opentelemetry.io/otel/attribute" ) // Packet holds the data that is passed to a DHCP handler. type Packet struct { // Peer is the address of the client that sent the DHCP message. Peer net.Addr // Pkt is the DHCP message. Pkt *dhcpv4.DHCPv4 // Md is the metadata that was passed to the DHCP server. Md *Metadata } // Metadata holds metadata about the DHCP packet that was received. type Metadata struct { // IfName is the name of the interface that the DHCP message was received on. IfName string // IfIndex is the index of the interface that the DHCP message was received on. IfIndex int } // DHCP holds the DHCP headers and options to be set in a DHCP handler response. // This is the API between a DHCP handler and a backend. type DHCP struct { MACAddress net.HardwareAddr // chaddr DHCP header. IPAddress netip.Addr // yiaddr DHCP header. SubnetMask net.IPMask // DHCP option 1. DefaultGateway netip.Addr // DHCP option 3. NameServers []net.IP // DHCP option 6. Hostname string // DHCP option 12. DomainName string // DHCP option 15. BroadcastAddress netip.Addr // DHCP option 28. NTPServers []net.IP // DHCP option 42. VLANID string // DHCP option 43.116. LeaseTime uint32 // DHCP option 51. Arch string // DHCP option 93. DomainSearch []string // DHCP option 119. Disabled bool // If true, no DHCP response should be sent. } // Netboot holds info used in netbooting a client. type Netboot struct { AllowNetboot bool // If true, the client will be provided netboot options in the DHCP offer/ack. IPXEScriptURL *url.URL // Overrides a default value that is passed into DHCP on startup. IPXEScript string // Overrides a default value that is passed into DHCP on startup. Console string Facility string OSIE OSIE } // OSIE or OS Installation Environment is the data about where the OSIE parts are located. type OSIE struct { // BaseURL is the URL where the OSIE parts are located. BaseURL *url.URL // Kernel is the name of the kernel file. Kernel string // Initrd is the name of the initrd file. Initrd string } // EncodeToAttributes returns a slice of opentelemetry attributes that can be used to set span.SetAttributes. func (d *DHCP) EncodeToAttributes() []attribute.KeyValue { var ns []string for _, e := range d.NameServers { ns = append(ns, e.String()) } var ntp []string for _, e := range d.NTPServers { ntp = append(ntp, e.String()) } var ip string if d.IPAddress.Compare(netip.Addr{}) != 0 { ip = d.IPAddress.String() } var sm string if d.SubnetMask != nil { sm = net.IP(d.SubnetMask).String() } var dfg string if d.DefaultGateway.Compare(netip.Addr{}) != 0 { dfg = d.DefaultGateway.String() } var ba string if d.BroadcastAddress.Compare(netip.Addr{}) != 0 { ba = d.BroadcastAddress.String() } return []attribute.KeyValue{ attribute.String("DHCP.MACAddress", d.MACAddress.String()), attribute.String("DHCP.IPAddress", ip), attribute.String("DHCP.SubnetMask", sm), attribute.String("DHCP.DefaultGateway", dfg), attribute.String("DHCP.NameServers", strings.Join(ns, ",")), attribute.String("DHCP.Hostname", d.Hostname), attribute.String("DHCP.DomainName", d.DomainName), attribute.String("DHCP.BroadcastAddress", ba), attribute.String("DHCP.NTPServers", strings.Join(ntp, ",")), attribute.Int64("DHCP.LeaseTime", int64(d.LeaseTime)), attribute.String("DHCP.DomainSearch", strings.Join(d.DomainSearch, ",")), } } // EncodeToAttributes returns a slice of opentelemetry attributes that can be used to set span.SetAttributes. func (n *Netboot) EncodeToAttributes() []attribute.KeyValue { var s string if n.IPXEScriptURL != nil { s = n.IPXEScriptURL.String() } return []attribute.KeyValue{ attribute.Bool("Netboot.AllowNetboot", n.AllowNetboot), attribute.String("Netboot.IPXEScriptURL", s), } } ================================================ FILE: internal/dhcp/data/data_test.go ================================================ package data import ( "net" "net/netip" "net/url" "testing" "github.com/google/go-cmp/cmp" "go.opentelemetry.io/otel/attribute" ) func TestDHCPEncodeToAttributes(t *testing.T) { tests := map[string]struct { dhcp *DHCP want []attribute.KeyValue }{ "successful encode of zero value DHCP struct": { dhcp: &DHCP{}, want: []attribute.KeyValue{ attribute.String("DHCP.MACAddress", ""), attribute.String("DHCP.IPAddress", ""), attribute.String("DHCP.Hostname", ""), attribute.String("DHCP.SubnetMask", ""), attribute.String("DHCP.DefaultGateway", ""), attribute.String("DHCP.NameServers", ""), attribute.String("DHCP.DomainName", ""), attribute.String("DHCP.BroadcastAddress", ""), attribute.String("DHCP.NTPServers", ""), attribute.Int64("DHCP.LeaseTime", 0), attribute.String("DHCP.DomainSearch", ""), }, }, "successful encode of populated DHCP struct": { dhcp: &DHCP{ MACAddress: []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, IPAddress: netip.MustParseAddr("192.168.2.150"), SubnetMask: []byte{255, 255, 255, 0}, DefaultGateway: netip.MustParseAddr("192.168.2.1"), NameServers: []net.IP{{1, 1, 1, 1}, {8, 8, 8, 8}}, Hostname: "test", DomainName: "example.com", BroadcastAddress: netip.MustParseAddr("192.168.2.255"), NTPServers: []net.IP{{132, 163, 96, 2}}, LeaseTime: 86400, DomainSearch: []string{"example.com", "example.org"}, }, want: []attribute.KeyValue{ attribute.String("DHCP.MACAddress", "00:01:02:03:04:05"), attribute.String("DHCP.IPAddress", "192.168.2.150"), attribute.String("DHCP.Hostname", "test"), attribute.String("DHCP.SubnetMask", "255.255.255.0"), attribute.String("DHCP.DefaultGateway", "192.168.2.1"), attribute.String("DHCP.NameServers", "1.1.1.1,8.8.8.8"), attribute.String("DHCP.DomainName", "example.com"), attribute.String("DHCP.BroadcastAddress", "192.168.2.255"), attribute.String("DHCP.NTPServers", "132.163.96.2"), attribute.Int64("DHCP.LeaseTime", 86400), attribute.String("DHCP.DomainSearch", "example.com,example.org"), }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { want := attribute.NewSet(tt.want...) got := attribute.NewSet(tt.dhcp.EncodeToAttributes()...) enc := attribute.DefaultEncoder() if diff := cmp.Diff(got.Encoded(enc), want.Encoded(enc)); diff != "" { t.Fatal(diff) } }) } } func TestNetbootEncodeToAttributes(t *testing.T) { tests := map[string]struct { netboot *Netboot want []attribute.KeyValue }{ "successful encode of zero value Netboot struct": { netboot: &Netboot{}, want: []attribute.KeyValue{ attribute.Bool("Netboot.AllowNetboot", false), attribute.String("Netboot.IPXEScriptURL", ""), }, }, "successful encode of populated Netboot struct": { netboot: &Netboot{ AllowNetboot: true, IPXEScriptURL: &url.URL{Scheme: "http", Host: "example.com"}, }, want: []attribute.KeyValue{ attribute.Bool("Netboot.AllowNetboot", true), attribute.String("Netboot.IPXEScriptURL", "http://example.com"), }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { want := attribute.NewSet(tt.want...) got := attribute.NewSet(tt.netboot.EncodeToAttributes()...) enc := attribute.DefaultEncoder() if diff := cmp.Diff(got.Encoded(enc), want.Encoded(enc)); diff != "" { t.Fatal(diff) } }) } } ================================================ FILE: internal/dhcp/dhcp.go ================================================ package dhcp import ( "bytes" "encoding/hex" "errors" "fmt" "net" "net/netip" "net/url" "strings" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/iana" ) const ( PXEClient ClientType = "PXEClient" HTTPClient ClientType = "HTTPClient" ) // known user-class types. must correspond to DHCP option 77 - User-Class // https://www.rfc-editor.org/rfc/rfc3004.html const ( // If the client has had iPXE burned into its ROM (or is a VM // that uses iPXE as the PXE "ROM"), special handling is // needed because in this mode the client is using iPXE native // drivers and chainloading to a UNDI stack won't work. IPXE UserClass = "iPXE" // If the client identifies as "Tinkerbell", we've already // chainloaded this client to the full-featured copy of iPXE // we supply. We have to distinguish this case so we don't // loop on the chainload step. Tinkerbell UserClass = "Tinkerbell" ) // UserClass is DHCP option 77 (https://www.rfc-editor.org/rfc/rfc3004.html). type UserClass string // ClientType is from DHCP option 60. Normally only PXEClient or HTTPClient. type ClientType string // ArchToBootFile maps supported hardware PXE architectures types to iPXE binary files. var ArchToBootFile = map[iana.Arch]string{ iana.INTEL_X86PC: "undionly.kpxe", iana.NEC_PC98: "undionly.kpxe", iana.EFI_ITANIUM: "undionly.kpxe", iana.DEC_ALPHA: "undionly.kpxe", iana.ARC_X86: "undionly.kpxe", iana.INTEL_LEAN_CLIENT: "undionly.kpxe", iana.EFI_IA32: "ipxe.efi", iana.EFI_X86_64: "ipxe.efi", iana.EFI_XSCALE: "ipxe.efi", iana.EFI_BC: "ipxe.efi", iana.EFI_ARM32: "snp.efi", iana.EFI_ARM64: "snp.efi", iana.EFI_X86_HTTP: "ipxe.efi", iana.EFI_X86_64_HTTP: "ipxe.efi", iana.EFI_ARM32_HTTP: "snp.efi", iana.EFI_ARM64_HTTP: "snp.efi", iana.Arch(41): "snp.efi", // arm rpiboot (0x29): https://www.iana.org/assignments/dhcpv6-parameters/dhcpv6-parameters.xhtml#processor-architecture } // ErrUnknownArch is used when the PXE client request is from an unknown architecture. var ErrUnknownArch = fmt.Errorf("could not determine client architecture from option 93") // Info holds details about the dhcp request. Use NewInfo to populate the struct fields from a dhcp packet. type Info struct { // Pkt is the dhcp packet that was received from the client. Pkt *dhcpv4.DHCPv4 // Arch is the architecture of the client. Use NewInfo to automatically populate this field. Arch iana.Arch // Mac is the mac address of the client. Use NewInfo to automatically populate this field. Mac net.HardwareAddr // UserClass is the user class of the client. Use NewInfo to automatically populate this field. UserClass UserClass // ClientType is the client type of the client. Use NewInfo to automatically populate this field. ClientType ClientType // IsNetbootClient returns nil if the client is a valid netboot client. Otherwise it returns an error. // Use NewInfo to automatically populate this field. IsNetbootClient error // IPXEBinary is the iPXE binary file to boot. Use NewInfo to automatically populate this field. IPXEBinary string } func NewInfo(pkt *dhcpv4.DHCPv4) Info { i := Info{Pkt: pkt} if pkt != nil { i.Arch = Arch(pkt) i.Mac = pkt.ClientHWAddr i.UserClass = i.UserClassFrom() i.ClientType = i.ClientTypeFrom() i.IsNetbootClient = IsNetbootClient(pkt) i.IPXEBinary = i.IPXEBinaryFrom() } return i } // isRaspberryPI checks if the mac address is from a Raspberry PI by matching prefixes against OUI registrations of the Raspberry Pi Trading Ltd. // https://www.netify.ai/resources/macs/brands/raspberry-pi // https://udger.com/resources/mac-address-vendor-detail?name=raspberry_pi_foundation // https://macaddress.io/statistics/company/27594 func isRaspberryPI(mac net.HardwareAddr) bool { prefixes := [][]byte{ {0xb8, 0x27, 0xeb}, // B8:27:EB {0xdc, 0xa6, 0x32}, // DC:A6:32 {0xe4, 0x5f, 0x01}, // E4:5F:01 {0x28, 0xcd, 0xc1}, // 28:CD:C1 {0xd8, 0x3a, 0xdd}, // D8:3A:DD } for _, prefix := range prefixes { if bytes.HasPrefix(mac, prefix) { return true } } return false } // Arch returns the Arch of the client pulled from DHCP option 93. func Arch(d *dhcpv4.DHCPv4) iana.Arch { // if the mac address is from a Raspberry PI, use the Raspberry PI architecture. // Some Raspberry PI's (Raspberry PI 5) report an option 93 of 0. // This translates to iana.INTEL_X86PC and causes us to map to undionly.kpxe. if isRaspberryPI(d.ClientHWAddr) { return iana.Arch(41) } // get option 93 ; arch fwt := d.ClientArch() if len(fwt) == 0 { return iana.Arch(255) // unknown arch } var archKnown bool var a iana.Arch for _, elem := range fwt { if !strings.Contains(elem.String(), "unknown") { archKnown = true // Basic architecture identification, based purely on // the PXE architecture option. // https://www.iana.org/assignments/dhcpv6-parameters/dhcpv6-parameters.xhtml#processor-architecture a = elem break } } if !archKnown { return iana.Arch(255) // unknown arch } return a } func (i Info) IPXEBinaryFrom() string { bin, found := ArchToBootFile[i.Arch] if !found { return "" } return bin } // String function for clientType. func (c ClientType) String() string { return string(c) } // String function for UserClass. func (u UserClass) String() string { return string(u) } func (i Info) UserClassFrom() UserClass { var u UserClass if i.Pkt != nil { if val := i.Pkt.Options.Get(dhcpv4.OptionUserClassInformation); val != nil { u = UserClass(string(val)) } } return u } func (i Info) ClientTypeFrom() ClientType { var c ClientType if i.Pkt != nil { if val := i.Pkt.Options.Get(dhcpv4.OptionClassIdentifier); val != nil { if strings.HasPrefix(string(val), HTTPClient.String()) { c = HTTPClient } else { c = PXEClient } } } return c } // IsNetbootClient returns nil if the client is a valid netboot client. Otherwise it returns an error. // // A valid netboot client will have the following in its DHCP request: // 1. is a DHCP discovery/request message type. // 2. option 93 is set. // 3. option 94 is set. // 4. option 97 is correct length. // 5. option 60 is set with this format: "PXEClient:Arch:xxxxx:UNDI:yyyzzz" or "HTTPClient:Arch:xxxxx:UNDI:yyyzzz". // // See: http://www.pix.net/software/pxeboot/archive/pxespec.pdf // // See: https://www.rfc-editor.org/rfc/rfc4578.html func IsNetbootClient(pkt *dhcpv4.DHCPv4) error { var err error // only response to DISCOVER and REQUEST packets if pkt.MessageType() != dhcpv4.MessageTypeDiscover && pkt.MessageType() != dhcpv4.MessageTypeRequest { err = wrapNonNil(err, "message type must be either Discover or Request") } // option 60 must be set if !pkt.Options.Has(dhcpv4.OptionClassIdentifier) { err = wrapNonNil(err, "option 60 not set") } // option 60 must start with PXEClient or HTTPClient opt60 := pkt.GetOneOption(dhcpv4.OptionClassIdentifier) if !strings.HasPrefix(string(opt60), string(PXEClient)) && !strings.HasPrefix(string(opt60), string(HTTPClient)) { err = wrapNonNil(err, "option 60 not PXEClient or HTTPClient") } // option 93 must be set if !pkt.Options.Has(dhcpv4.OptionClientSystemArchitectureType) { err = wrapNonNil(err, "option 93 not set") } // option 94 must be set if !pkt.Options.Has(dhcpv4.OptionClientNetworkInterfaceIdentifier) { err = wrapNonNil(err, "option 94 not set") } // option 97 must be have correct length or not be set guid := pkt.GetOneOption(dhcpv4.OptionClientMachineIdentifier) switch len(guid) { case 0: // A missing GUID is invalid according to the spec, however // there are PXE ROMs in the wild that omit the GUID and still // expect to boot. The only thing we do with the GUID is // mirror it back to the client if it's there, so we might as // well accept these buggy ROMs. case 17: if guid[0] != 0 { err = wrapNonNil(err, "option 97 does not start with 0") } default: err = wrapNonNil(err, "option 97 has invalid length (must be 0 or 17)") } return err } func wrapNonNil(err error, format string) error { if err == nil { return errors.New(format) } return fmt.Errorf("%w: %v", err, format) } // Bootfile returns the calculated dhcp header: "file" value. see https://datatracker.ietf.org/doc/html/rfc2131#section-2 . func (i Info) Bootfile(customUC UserClass, ipxeScript, ipxeHTTPBinServer *url.URL, ipxeTFTPBinServer netip.AddrPort) string { bootfile := "/no-ipxe-script-defined" // If a machine is in an ipxe boot loop, it is likely to be that we aren't matching on IPXE or Tinkerbell userclass (option 77). switch { // order matters here. case i.UserClass == Tinkerbell, (customUC != "" && i.UserClass == customUC): // this case gets us out of an ipxe boot loop. if ipxeScript != nil { bootfile = ipxeScript.String() } case i.ClientType == HTTPClient: // Check the client type from option 60. if ipxeHTTPBinServer != nil { paths := []string{i.IPXEBinary} if i.Mac != nil { paths = append([]string{i.Mac.String()}, paths...) } bootfile = ipxeHTTPBinServer.JoinPath(paths...).String() } case i.UserClass == IPXE: // if the "iPXE" user class is found it means we aren't in our custom version of ipxe, but because of the option 43 we're setting we need to give a full tftp url from which to boot. t := url.URL{ Scheme: "tftp", Host: ipxeTFTPBinServer.String(), } paths := []string{i.IPXEBinary} if i.Mac != nil { paths = append([]string{i.Mac.String()}, paths...) } bootfile = t.JoinPath(paths...).String() default: if i.IPXEBinary != "" { bootfile = i.IPXEBinary } } return bootfile } // NextServer returns the calculated dhcp header (ServerIPAddr): "siaddr" value. see https://datatracker.ietf.org/doc/html/rfc2131#section-2 . func (i Info) NextServer(ipxeHTTPBinServer *url.URL, ipxeTFTPBinServer netip.AddrPort) net.IP { var nextServer net.IP // If a machine is in an ipxe boot loop, it is likely to be that we aren't matching on IPXE or Tinkerbell userclass (option 77). switch { // order matters here. case i.ClientType == HTTPClient: // Check the client type from option 60. if ipxeHTTPBinServer != nil { nextServer = net.ParseIP(ipxeHTTPBinServer.Hostname()) } case i.UserClass == IPXE: // if the "iPXE" user class is found it means we aren't in our custom version of ipxe, but because of the option 43 we're setting we need to give a full tftp url from which to boot. nextServer = net.IP(ipxeTFTPBinServer.Addr().AsSlice()) default: nextServer = net.IP(ipxeTFTPBinServer.Addr().AsSlice()) } return nextServer } // AddRPIOpt43 adds the Raspberry PI required option43 sub options to an existing opt 43. func (i Info) AddRPIOpt43(opts dhcpv4.Options) []byte { // these are suboptions of option43. ref: https://datatracker.ietf.org/doc/html/rfc2132#section-8.4 if isRaspberryPI(i.Mac) { // TODO document what these hex strings are and why they are needed. // https://www.raspberrypi.org/documentation/computers/raspberry-pi.html#PXE_OPTION43 // tested with Raspberry Pi 4 using UEFI from here: https://github.com/pftf/RPi4/releases/tag/v1.31 // all files were served via a tftp server and lived at the top level dir of the tftp server (i.e tftp://server/) // "\x00\x00\x11" is equal to NUL(Null), NUL(Null), DC1(Device Control 1) opt9, _ := hex.DecodeString("00001152617370626572727920506920426f6f74") // "\x00\x00\x11Raspberry Pi Boot" opts[9] = opt9 // "\x0a\x04\x00" is equal to LF(Line Feed), EOT(End of Transmission), NUL(Null) opt10, _ := hex.DecodeString("00505845") // "\x0a\x04\x00PXE" opts[10] = opt10 } return opts.ToBytes() } ================================================ FILE: internal/dhcp/dhcp_test.go ================================================ package dhcp import ( "encoding/hex" "errors" "net" "net/netip" "net/url" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/iana" ) const ( examplePXEClient = "PXEClient:Arch:00007:UNDI:003001" exampleHTTPClient = "HTTPClient:Arch:00016:UNDI:003001" ) func TestNewInfo(t *testing.T) { tests := map[string]struct { pkt *dhcpv4.DHCPv4 want Info }{ "valid http client": { pkt: &dhcpv4.DHCPv4{ ClientIPAddr: []byte{0x00, 0x00, 0x00, 0x00}, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClientArch(iana.EFI_X86_64_HTTP), dhcpv4.OptUserClass(Tinkerbell.String()), dhcpv4.OptClassIdentifier(exampleHTTPClient), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x05, 0x06, 0x07}), ), }, want: Info{ Arch: iana.EFI_X86_64_HTTP, Mac: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, UserClass: Tinkerbell, ClientType: HTTPClient, IsNetbootClient: nil, IPXEBinary: "ipxe.efi", }, }, "arch not found": { pkt: &dhcpv4.DHCPv4{ ClientIPAddr: []byte{0x00, 0x00, 0x00, 0x00}, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClientArch(iana.Arch(255)), dhcpv4.OptClassIdentifier(examplePXEClient), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x05, 0x06, 0x07}), ), }, want: Info{ Arch: iana.Arch(255), Mac: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, ClientType: PXEClient, }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := NewInfo(tt.pkt) if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreFields(Info{}, "Pkt")); diff != "" { t.Fatal(diff) } }) } } func TestArch(t *testing.T) { tests := map[string]struct { pkt *dhcpv4.DHCPv4 want iana.Arch }{ "found": { pkt: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList(dhcpv4.OptClientArch(iana.INTEL_X86PC))}, want: iana.INTEL_X86PC, }, "raspberry pi": { pkt: &dhcpv4.DHCPv4{ClientHWAddr: net.HardwareAddr{0xb8, 0x27, 0xeb, 0x00, 0x00, 0x00}}, want: iana.Arch(41), }, "unknown": { pkt: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList(dhcpv4.OptClientArch(iana.Arch(255)))}, want: iana.Arch(255), }, "unknown: opt 93 len 0": { pkt: &dhcpv4.DHCPv4{}, want: iana.Arch(255), }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := Arch(tt.pkt) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatal(diff) } }) } } func TestIsNetbootClient(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want error }{ "fail invalid message type": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList(dhcpv4.OptMessageType(dhcpv4.MessageTypeInform))}, want: errors.New("")}, "fail no opt60": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList(dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover))}, want: errors.New("")}, "fail bad opt60": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClassIdentifier("BadClient"), )}, want: errors.New("")}, "fail no opt93": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClassIdentifier("HTTPClient:Arch:xxxxx:UNDI:yyyzzz"), )}, want: errors.New("")}, "fail no opt94": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClassIdentifier("HTTPClient:Arch:xxxxx:UNDI:yyyzzz"), dhcpv4.OptClientArch(iana.EFI_ARM64_HTTP), )}, want: errors.New("")}, "fail invalid opt97[0] != 0": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClassIdentifier("HTTPClient:Arch:xxxxx:UNDI:yyyzzz"), dhcpv4.OptClientArch(iana.EFI_ARM64_HTTP), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05}), )}, want: errors.New("")}, "fail invalid len(opt97)": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClassIdentifier("HTTPClient:Arch:xxxxx:UNDI:yyyzzz"), dhcpv4.OptClientArch(iana.EFI_ARM64_HTTP), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x01, 0x02}), )}, want: errors.New("")}, "success len(opt97) == 0": {input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptClassIdentifier("HTTPClient:Arch:xxxxx:UNDI:yyyzzz"), dhcpv4.OptClientArch(iana.EFI_ARM64_HTTP), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{}), )}, want: nil}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { if err := IsNetbootClient(tt.input); (err == nil) != (tt.want == nil) { t.Errorf("isNetbootClient() = %v, want %v", err, tt.want) } }) } } func TestBootfile(t *testing.T) { type args struct { customUC UserClass ipxeTFTPBinServer netip.AddrPort ipxeScript *url.URL ipxeHTTPBinServer *url.URL } tests := map[string]struct { info Info args args want string }{ "ipxe script": { info: Info{ UserClass: Tinkerbell, }, args: args{ ipxeScript: &url.URL{Path: "/ipxe-script"}, }, want: "/ipxe-script", }, "http client": { info: Info{ ClientType: HTTPClient, Mac: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, IPXEBinary: "ipxe.efi", }, args: args{ ipxeHTTPBinServer: &url.URL{Scheme: "http", Host: "1.2.3.4:8080"}, }, want: "http://1.2.3.4:8080/01:02:03:04:05:06/ipxe.efi", }, "firmware ipxe": { info: Info{ UserClass: IPXE, Mac: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, IPXEBinary: "undionly.kpxe", }, args: args{ ipxeTFTPBinServer: netip.MustParseAddrPort("1.2.3.4:69"), }, want: "tftp://1.2.3.4:69/01:02:03:04:05:06/undionly.kpxe", }, "no user class": { info: Info{ Mac: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, IPXEBinary: "undionly.kpxe", }, want: "undionly.kpxe", }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := tt.info.Bootfile(tt.args.customUC, tt.args.ipxeScript, tt.args.ipxeHTTPBinServer, tt.args.ipxeTFTPBinServer) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatal(diff) } }) } } func TestNextServer(t *testing.T) { type args struct { ipxeTFTPBinServer netip.AddrPort ipxeHTTPBinServer *url.URL } tests := map[string]struct { info Info args args want net.IP }{ "http client": { info: Info{ ClientType: HTTPClient, }, args: args{ ipxeHTTPBinServer: &url.URL{Scheme: "http", Host: "1.2.3.4:8989"}, }, want: net.ParseIP("1.2.3.4"), }, "firmware ipxe": { info: Info{ UserClass: IPXE, }, args: args{ ipxeTFTPBinServer: netip.MustParseAddrPort("1.2.3.4:69"), }, want: net.ParseIP("1.2.3.4"), }, "no user class": { info: Info{}, args: args{ ipxeTFTPBinServer: netip.MustParseAddrPort("1.2.3.4:69"), }, want: net.ParseIP("1.2.3.4"), }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := tt.info.NextServer(tt.args.ipxeHTTPBinServer, tt.args.ipxeTFTPBinServer) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatal(diff) } }) } } func TestOpt43(t *testing.T) { rpi9, _ := hex.DecodeString("00001152617370626572727920506920426f6f74") rpi10, _ := hex.DecodeString("00505845") tests := map[string]struct { info Info opts dhcpv4.Options want []byte }{ "not a raspberry pi": { info: Info{ Mac: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, }, opts: dhcpv4.Options{}, want: dhcpv4.Options{}.ToBytes(), }, "raspberry pi": { info: Info{ Mac: net.HardwareAddr{0xb8, 0x27, 0xeb, 0x00, 0x00, 0x00}, }, opts: dhcpv4.Options{}, want: dhcpv4.Options{ 9: rpi9, 10: rpi10, }.ToBytes(), }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := tt.info.AddRPIOpt43(tt.opts) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatal(diff) } }) } } func TestUserClassString(t *testing.T) { u := UserClass("test") if diff := cmp.Diff("test", u.String()); diff != "" { t.Fatal(diff) } } func TestIsRaspberryPI(t *testing.T) { tests := map[string]struct { mac net.HardwareAddr want bool }{ "not a raspberry pi": { mac: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, want: false, }, "raspberry pi": { mac: net.HardwareAddr{0xb8, 0x27, 0xeb, 0x00, 0x00, 0x00}, want: true, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := isRaspberryPI(tt.mac) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatal(diff) } }) } } ================================================ FILE: internal/dhcp/handler/handler.go ================================================ // Package handler holds the interface that backends implement, handlers take in, and the top level dhcp package passes to handlers. package handler import ( "context" "net" "github.com/tinkerbell/smee/internal/dhcp/data" ) // BackendReader is the interface for getting data from a backend. // // Backends implement this interface to provide DHCP and Netboot data to the handlers. type BackendReader interface { // Read data (from a backend) based on a mac address // and return DHCP headers and options, including netboot info. GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) } ================================================ FILE: internal/dhcp/handler/proxy/proxy.go ================================================ /* Package proxy implements a DHCP handler that provides proxyDHCP functionality. "[A] Proxy DHCP server behaves much like a DHCP server by listening for ordinary DHCP client traffic and responding to certain client requests. However, unlike the DHCP server, the PXE Proxy DHCP server does not administer network addresses, and it only responds to clients that identify themselves as PXE clients. The responses given by the PXE Proxy DHCP server contain the mechanism by which the client locates the boot servers or the network addresses and descriptions of the supported, compatible boot servers." Reference: https://www.ibm.com/docs/en/aix/7.1?topic=protocol-preboot-execution-environment-proxy-dhcp-daemon */ package proxy import ( "context" "errors" "fmt" "net" "net/netip" "net/url" "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/tinkerbell/smee/internal/dhcp" "github.com/tinkerbell/smee/internal/dhcp/data" "github.com/tinkerbell/smee/internal/dhcp/handler" oteldhcp "github.com/tinkerbell/smee/internal/dhcp/otel" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "golang.org/x/net/ipv4" ) const tracerName = "github.com/tinkerbell/smee/internal/dhcp/handler/proxy" // Handler holds the configuration details for the running the DHCP server. type Handler struct { // Backend is the backend to use for getting DHCP data. Backend handler.BackendReader // IPAddr is the IP address to use in DHCP responses. // Option 54 and the sname DHCP header. // This could be a load balancer IP address or an ingress IP address or a local IP address. IPAddr netip.Addr // Log is used to log messages. // `logr.Discard()` can be used if no logging is desired. Log logr.Logger // Netboot configuration Netboot Netboot // OTELEnabled is used to determine if netboot options include otel naming. // When true, the netboot filename will be appended with otel information. // For example, the filename will be "snp.efi-00-23b1e307bb35484f535a1f772c06910e-d887dc3912240434-01". // -00--- OTELEnabled bool // AutoProxyEnabled is used to determine if the proxyDHCP handler should do any Backend calls or not. // When enabled no Backend calls are made and responses are sent to all valid network boot clients. AutoProxyEnabled bool } // Netboot holds the netboot configuration details used in running a DHCP server. type Netboot struct { // iPXE binary server IP:Port serving via TFTP. IPXEBinServerTFTP netip.AddrPort // IPXEBinServerHTTP is the URL to the IPXE binary server serving via HTTP(s). IPXEBinServerHTTP *url.URL // IPXEScriptURL is the URL to the IPXE script to use. IPXEScriptURL func(*dhcpv4.DHCPv4) *url.URL // Enabled is whether to enable sending netboot DHCP options. Enabled bool // UserClass (for network booting) allows a custom DHCP option 77 to be used to break out of an iPXE loop. UserClass dhcp.UserClass } // Redirection name comes from section 2.5 of http://www.pix.net/software/pxeboot/archive/pxespec.pdf func (h *Handler) Handle(ctx context.Context, conn *ipv4.PacketConn, dp data.Packet) { // validations if dp.Pkt == nil { h.Log.Error(errors.New("incoming packet is nil"), "not able to respond when the incoming packet is nil") return } upeer, ok := dp.Peer.(*net.UDPAddr) if !ok { h.Log.Error(errors.New("peer is not a UDP connection"), "not able to respond when the peer is not a UDP connection") return } if upeer == nil { h.Log.Error(errors.New("peer is nil"), "not able to respond when the peer is nil") return } if conn == nil { h.Log.Error(errors.New("connection is nil"), "not able to respond when the connection is nil") return } var ifName string if dp.Md != nil { ifName = dp.Md.IfName } log := h.Log.WithValues("mac", dp.Pkt.ClientHWAddr.String(), "xid", dp.Pkt.TransactionID.String(), "interface", ifName) tracer := otel.Tracer(tracerName) var span trace.Span ctx, span = tracer.Start( ctx, fmt.Sprintf("DHCP Packet Received: %v", dp.Pkt.MessageType().String()), trace.WithAttributes(h.encodeToAttributes(dp.Pkt, "request")...), trace.WithAttributes(attribute.String("DHCP.peer", dp.Peer.String())), trace.WithAttributes(attribute.String("DHCP.server.ifname", ifName)), ) defer span.End() // We ignore the error here because: // 1. it's only non-nil if the generation of a transaction id (XID) fails. // 2. We always use the clients transaction id (XID) in responses. See dhcpv4.WithReply(). reply, _ := dhcpv4.NewReplyFromRequest(dp.Pkt) if dp.Pkt.OpCode != dhcpv4.OpcodeBootRequest { // TODO(jacobweinstock): dont understand this, found it in an example here: https://github.com/insomniacslk/dhcp/blob/c51060810aaab9c8a0bd1b0fcbf72bc0b91e6427/dhcpv4/server4/server_test.go#L31 log.V(1).Info("Ignoring packet", "OpCode", dp.Pkt.OpCode) span.SetStatus(codes.Ok, "Ignoring packet: OpCode not BootRequest") return } if err := setMessageType(reply, dp.Pkt.MessageType()); err != nil { log.V(1).Info("Ignoring packet", "error", err.Error()) span.SetStatus(codes.Ok, err.Error()) return } // Set option 97 reply.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, dp.Pkt.GetOneOption(dhcpv4.OptionClientMachineIdentifier))) i := dhcp.NewInfo(dp.Pkt) if !h.Netboot.Enabled { log.V(1).Info("Ignoring packet: netboot is not enabled") span.SetStatus(codes.Ok, "Ignoring packet: netboot is not enabled") return } if err := i.IsNetbootClient; err != nil { log.V(1).Info("Ignoring packet: not from a PXE enabled client", "error", err.Error()) span.SetStatus(codes.Ok, fmt.Sprintf("Ignoring packet: not from a PXE enabled client: %s", err.Error())) return } if i.IPXEBinary == "" { log.V(1).Info("Ignoring packet: no iPXE binary was able to be determined") span.SetStatus(codes.Ok, "Ignoring packet: no iPXE binary was able to be determined") return } // Set option 43 opts := dhcpv4.Options{6: []byte{8}} // PXE Boot Server Discovery Control - bypass, just boot from dhcp header: bootfile. No need to set opt for tftp server address. reply.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionVendorSpecificInformation, i.AddRPIOpt43(opts))) // Set option 60 // The PXE spec says the server should identify itself as a PXEClient or HTTPClient reply.UpdateOption(dhcpv4.OptClassIdentifier(i.ClientTypeFrom().String())) // Set option 54, without this the pxe client will try to broadcast a request message to port 4011 for the ipxe binary. only found to be needed for PXEClient but not prohibitive for HTTPClient. // probably will want this to be the public IP of the proxyDHCP server ns := i.NextServer(h.Netboot.IPXEBinServerHTTP, h.Netboot.IPXEBinServerTFTP) reply.UpdateOption(dhcpv4.OptServerIdentifier(ns)) // add the siaddr (IP address of next server) dhcp packet header to a given packet pkt. // see https://datatracker.ietf.org/doc/html/rfc2131#section-2 // without this the pxe client will try to broadcast a request message to port 4011 for the ipxe script. The value doesnt seem to matter. reply.ServerIPAddr = ns // set sname header // see https://datatracker.ietf.org/doc/html/rfc2131#section-2 reply.ServerHostName = ns.String() // setSNAME(reply, dp.Pkt.GetOneOption(dhcpv4.OptionClassIdentifier), h.Netboot.IPXEBinServerTFTP.Addr().AsSlice(), net.ParseIP(h.Netboot.IPXEBinServerHTTP.Hostname())) // set bootfile header reply.BootFileName = i.Bootfile("", h.Netboot.IPXEScriptURL(dp.Pkt), h.Netboot.IPXEBinServerHTTP, h.Netboot.IPXEBinServerTFTP) if !h.AutoProxyEnabled { // check the backend, if PXE is NOT allowed, set the boot file name to "//not-allowed" _, n, err := h.Backend.GetByMac(ctx, dp.Pkt.ClientHWAddr) if err != nil || (n != nil && !n.AllowNetboot) { l := log.V(1) if err != nil { l = l.WithValues("error", err.Error()) } if n != nil { l = l.WithValues("netbootAllowed", n.AllowNetboot) } l.Info("Ignoring packet") span.SetStatus(codes.Ok, "netboot not allowed") return } } log.Info( "received DHCP packet", "type", dp.Pkt.MessageType().String(), "clientType", i.ClientTypeFrom().String(), "userClass", i.UserClassFrom().String(), ) dst := replyDestination(dp.Peer, dp.Pkt.GatewayIPAddr) cm := &ipv4.ControlMessage{} if dp.Md != nil { cm.IfIndex = dp.Md.IfIndex } log = log.WithValues( "destination", dst.String(), "bootFileName", reply.BootFileName, "nextServer", reply.ServerIPAddr.String(), "messageType", reply.MessageType().String(), "serverHostname", reply.ServerHostName, ) // send the DHCP packet if _, err := conn.WriteTo(reply.ToBytes(), cm, dst); err != nil { log.Error(err, "failed to send ProxyDHCP response") span.SetStatus(codes.Error, err.Error()) return } log.Info("Sent ProxyDHCP response") span.SetAttributes(h.encodeToAttributes(reply, "reply")...) span.SetStatus(codes.Ok, "sent DHCP response") } // encodeToAttributes takes a DHCP packet and returns opentelemetry key/value attributes. func (h *Handler) encodeToAttributes(d *dhcpv4.DHCPv4, namespace string) []attribute.KeyValue { a := &oteldhcp.Encoder{Log: h.Log} return a.Encode(d, namespace, oteldhcp.AllEncoders()...) } func setMessageType(reply *dhcpv4.DHCPv4, reqMsg dhcpv4.MessageType) error { switch mt := reqMsg; mt { case dhcpv4.MessageTypeDiscover: reply.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeOffer)) case dhcpv4.MessageTypeRequest: reply.UpdateOption(dhcpv4.OptMessageType(dhcpv4.MessageTypeAck)) default: return IgnorePacketError{PacketType: mt, Details: "proxyDHCP only responds to Discover or Request message types"} } return nil } // IgnorePacketError is for when a DHCP packet should be ignored. type IgnorePacketError struct { PacketType dhcpv4.MessageType Details string } // Error returns the string representation of ErrIgnorePacket. func (e IgnorePacketError) Error() string { return fmt.Sprintf("Ignoring packet: message type %s: details %s", e.PacketType, e.Details) } // replyDestination determines the destination address for the DHCP reply. // If the giaddr is set, then the reply should be sent to the giaddr. // Otherwise, the reply should be sent to the direct peer. // // From page 22 of https://www.ietf.org/rfc/rfc2131.txt: // "If the 'giaddr' field in a DHCP message from a client is non-zero, // the server sends any return messages to the 'DHCP server' port on // the BOOTP relay agent whose address appears in 'giaddr'.". func replyDestination(directPeer net.Addr, giaddr net.IP) net.Addr { if !giaddr.IsUnspecified() && giaddr != nil { return &net.UDPAddr{IP: giaddr, Port: dhcpv4.ServerPort} } return directPeer } ================================================ FILE: internal/dhcp/handler/reservation/handler.go ================================================ package reservation import ( "context" "errors" "fmt" "net" "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/tinkerbell/smee/internal/dhcp" "github.com/tinkerbell/smee/internal/dhcp/data" oteldhcp "github.com/tinkerbell/smee/internal/dhcp/otel" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "golang.org/x/net/ipv4" ) const tracerName = "github.com/tinkerbell/smee" // setDefaults will update the Handler struct to have default values so as // to avoid panic for nil pointers and such. func (h *Handler) setDefaults() { if h.Backend == nil { h.Backend = noop{} } if h.Log.GetSink() == nil { h.Log = logr.Discard() } } // Handle responds to DHCP messages with DHCP server options. func (h *Handler) Handle(ctx context.Context, conn *ipv4.PacketConn, p data.Packet) { h.setDefaults() if p.Pkt == nil { h.Log.Error(errors.New("incoming packet is nil"), "not able to respond when the incoming packet is nil") return } upeer, ok := p.Peer.(*net.UDPAddr) if !ok { h.Log.Error(errors.New("peer is not a UDP connection"), "not able to respond when the peer is not a UDP connection") return } if upeer == nil { h.Log.Error(errors.New("peer is nil"), "not able to respond when the peer is nil") return } if conn == nil { h.Log.Error(errors.New("connection is nil"), "not able to respond when the connection is nil") return } var ifName string if p.Md != nil { ifName = p.Md.IfName } log := h.Log.WithValues("mac", p.Pkt.ClientHWAddr.String(), "xid", p.Pkt.TransactionID.String(), "interface", ifName) tracer := otel.Tracer(tracerName) var span trace.Span ctx, span = tracer.Start( ctx, fmt.Sprintf("DHCP Packet Received: %v", p.Pkt.MessageType().String()), trace.WithAttributes(h.encodeToAttributes(p.Pkt, "request")...), trace.WithAttributes(attribute.String("DHCP.peer", p.Peer.String())), trace.WithAttributes(attribute.String("DHCP.server.ifname", ifName)), ) defer span.End() var reply *dhcpv4.DHCPv4 switch mt := p.Pkt.MessageType(); mt { case dhcpv4.MessageTypeDiscover: d, n, err := h.readBackend(ctx, p.Pkt.ClientHWAddr) if err != nil { if hardwareNotFound(err) { span.SetStatus(codes.Ok, "no reservation found") return } log.Info("error reading from backend", "error", err) span.SetStatus(codes.Error, err.Error()) return } if d.Disabled { log.Info("DHCP is disabled for this MAC address, no response sent", "type", p.Pkt.MessageType().String()) span.SetStatus(codes.Ok, "disabled DHCP response") return } log.Info("received DHCP packet", "type", p.Pkt.MessageType().String()) reply = h.updateMsg(ctx, p.Pkt, d, n, dhcpv4.MessageTypeOffer) log = log.WithValues("type", dhcpv4.MessageTypeOffer.String()) case dhcpv4.MessageTypeRequest: d, n, err := h.readBackend(ctx, p.Pkt.ClientHWAddr) if err != nil { if hardwareNotFound(err) { span.SetStatus(codes.Ok, "no reservation found") return } log.Info("error reading from backend", "error", err) span.SetStatus(codes.Error, err.Error()) return } if d.Disabled { log.Info("DHCP is disabled for this MAC address, no response sent", "type", p.Pkt.MessageType().String()) span.SetStatus(codes.Ok, "disabled DHCP response") return } log.Info("received DHCP packet", "type", p.Pkt.MessageType().String()) reply = h.updateMsg(ctx, p.Pkt, d, n, dhcpv4.MessageTypeAck) log = log.WithValues("type", dhcpv4.MessageTypeAck.String()) case dhcpv4.MessageTypeRelease: // Since the design of this DHCP server is that all IP addresses are // Host reservations, when a client releases an address, the server // doesn't have anything to do. This case is included for clarity of this // design decision. log.Info("received DHCP release packet, no response required, all IPs are host reservations", "type", p.Pkt.MessageType().String()) span.SetStatus(codes.Ok, "received release, no response required") return default: log.Info("received unknown message type", "type", p.Pkt.MessageType().String()) span.SetStatus(codes.Error, "received unknown message type") return } if bf := reply.BootFileName; bf != "" { log = log.WithValues("bootFileName", bf) } if ns := reply.ServerIPAddr; ns != nil { log = log.WithValues("nextServer", ns.String()) } dst := replyDestination(p.Peer, p.Pkt.GatewayIPAddr) log = log.WithValues("ipAddress", reply.YourIPAddr.String(), "destination", dst.String()) cm := &ipv4.ControlMessage{} if p.Md != nil { cm.IfIndex = p.Md.IfIndex } if _, err := conn.WriteTo(reply.ToBytes(), cm, dst); err != nil { log.Error(err, "failed to send DHCP") span.SetStatus(codes.Error, err.Error()) return } log.Info("sent DHCP response") span.SetAttributes(h.encodeToAttributes(reply, "reply")...) span.SetStatus(codes.Ok, "sent DHCP response") } // replyDestination determines the destination address for the DHCP reply. // If the giaddr is set, then the reply should be sent to the giaddr. // Otherwise, the reply should be sent to the direct peer. // // From page 22 of https://www.ietf.org/rfc/rfc2131.txt: // "If the 'giaddr' field in a DHCP message from a client is non-zero, // the server sends any return messages to the 'DHCP server' port on // the BOOTP relay agent whose address appears in 'giaddr'.". func replyDestination(directPeer net.Addr, giaddr net.IP) net.Addr { if !giaddr.IsUnspecified() && giaddr != nil { return &net.UDPAddr{IP: giaddr, Port: dhcpv4.ServerPort} } return directPeer } // readBackend encapsulates the backend read and opentelemetry handling. func (h *Handler) readBackend(ctx context.Context, mac net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { h.setDefaults() tracer := otel.Tracer(tracerName) ctx, span := tracer.Start(ctx, "Hardware data get") defer span.End() d, n, err := h.Backend.GetByMac(ctx, mac) if err != nil { span.SetStatus(codes.Error, err.Error()) return nil, nil, err } span.SetAttributes(d.EncodeToAttributes()...) span.SetAttributes(n.EncodeToAttributes()...) span.SetStatus(codes.Ok, "done reading from backend") return d, n, nil } // updateMsg handles updating DHCP packets with the data from the backend. func (h *Handler) updateMsg(ctx context.Context, pkt *dhcpv4.DHCPv4, d *data.DHCP, n *data.Netboot, msgType dhcpv4.MessageType) *dhcpv4.DHCPv4 { h.setDefaults() mods := []dhcpv4.Modifier{ dhcpv4.WithMessageType(msgType), dhcpv4.WithGeneric(dhcpv4.OptionServerIdentifier, h.IPAddr.AsSlice()), dhcpv4.WithServerIP(h.IPAddr.AsSlice()), } mods = append(mods, h.setDHCPOpts(ctx, pkt, d)...) if h.Netboot.Enabled && dhcp.IsNetbootClient(pkt) == nil { mods = append(mods, h.setNetworkBootOpts(ctx, pkt, n)) } // We ignore the error here because: // 1. it's only non-nil if the generation of a transaction id (XID) fails. // 2. We always use the clients transaction id (XID) in responses. See dhcpv4.WithReply(). reply, _ := dhcpv4.NewReplyFromRequest(pkt, mods...) return reply } // encodeToAttributes takes a DHCP packet and returns opentelemetry key/value attributes. func (h *Handler) encodeToAttributes(d *dhcpv4.DHCPv4, namespace string) []attribute.KeyValue { h.setDefaults() a := &oteldhcp.Encoder{Log: h.Log} return a.Encode(d, namespace, oteldhcp.AllEncoders()...) } // hardwareNotFound returns true if the error is from a hardware record not being found. func hardwareNotFound(err error) bool { type hardwareNotFound interface { NotFound() bool } te, ok := err.(hardwareNotFound) return ok && te.NotFound() } ================================================ FILE: internal/dhcp/handler/reservation/handler_test.go ================================================ package reservation import ( "context" "errors" "fmt" "log" "net" "net/netip" "net/url" "os" "testing" "time" "github.com/go-logr/stdr" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/iana" "github.com/insomniacslk/dhcp/rfc1035label" "github.com/tinkerbell/smee/internal/dhcp/data" "github.com/tinkerbell/smee/internal/dhcp/otel" "go.opentelemetry.io/otel/attribute" "golang.org/x/net/ipv4" "golang.org/x/net/nettest" ) var errBadBackend = fmt.Errorf("bad backend") type mockBackend struct { err error allowNetboot bool ipxeScript *url.URL hardwareNotFound bool } type hwNotFoundError struct{} func (hwNotFoundError) NotFound() bool { return true } func (hwNotFoundError) Error() string { return "not found" } func (m *mockBackend) GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { if m.err != nil { return nil, nil, m.err } if m.hardwareNotFound { return nil, nil, hwNotFoundError{} } d := &data.DHCP{ MACAddress: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, IPAddress: netip.MustParseAddr("192.168.1.100"), SubnetMask: []byte{255, 255, 255, 0}, DefaultGateway: netip.MustParseAddr("192.168.1.1"), NameServers: []net.IP{ {1, 1, 1, 1}, }, Hostname: "test-host", DomainName: "mydomain.com", BroadcastAddress: netip.MustParseAddr("192.168.1.255"), NTPServers: []net.IP{ {132, 163, 96, 2}, }, LeaseTime: 60, DomainSearch: []string{ "mydomain.com", }, } n := &data.Netboot{ AllowNetboot: m.allowNetboot, IPXEScriptURL: m.ipxeScript, } return d, n, m.err } func (m *mockBackend) GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) { if m.hardwareNotFound { return nil, nil, hwNotFoundError{} } return nil, nil, errors.New("not implemented") } func TestHandle(t *testing.T) { tests := map[string]struct { server Handler req *dhcpv4.DHCPv4 want *dhcpv4.DHCPv4 wantErr error nilPeer bool }{ "success discover message type with netboot options": { server: Handler{ Backend: &mockBackend{ allowNetboot: true, ipxeScript: &url.URL{Scheme: "http", Host: "localhost:8181", Path: "auto.ipxe"}, }, IPAddr: netip.MustParseAddr("127.0.0.1"), Netboot: Netboot{Enabled: true}, }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptUserClass("Tinkerbell"), dhcpv4.OptClassIdentifier("HTTPClient:Arch:xxxxx:UNDI:yyyzzz"), dhcpv4.OptClientArch(iana.EFI_X86_64_HTTP), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05}), ), }, want: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootReply, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, ClientIPAddr: []byte{0, 0, 0, 0}, YourIPAddr: []byte{192, 168, 1, 100}, ServerIPAddr: []byte{0, 0, 0, 0}, GatewayIPAddr: []byte{0, 0, 0, 0}, BootFileName: "http://localhost:8181/auto.ipxe", Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeOffer), dhcpv4.OptServerIdentifier(net.IP{127, 0, 0, 1}), dhcpv4.OptIPAddressLeaseTime(time.Minute), dhcpv4.OptSubnetMask(net.IPMask(net.IP{255, 255, 255, 0}.To4())), dhcpv4.OptRouter([]net.IP{{192, 168, 1, 1}}...), dhcpv4.OptDNS([]net.IP{{1, 1, 1, 1}}...), dhcpv4.OptDomainName("mydomain.com"), dhcpv4.OptHostName("test-host"), dhcpv4.OptBroadcastAddress(net.IP{192, 168, 1, 255}), dhcpv4.OptNTPServers([]net.IP{{132, 163, 96, 2}}...), dhcpv4.OptDomainSearch(&rfc1035label.Labels{Labels: []string{"mydomain.com"}}), dhcpv4.OptClassIdentifier("HTTPClient"), dhcpv4.OptGeneric(dhcpv4.OptionVendorSpecificInformation, dhcpv4.Options{ 6: []byte{8}, 69: otel.TraceparentFromContext(context.Background()), }.ToBytes()), ), }, }, "failure discover message type": { server: Handler{ Backend: &mockBackend{err: errBadBackend}, IPAddr: netip.MustParseAddr("127.0.0.1"), }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), ), }, wantErr: errBadBackend, }, "success request message type with netboot options": { server: Handler{ Backend: &mockBackend{ allowNetboot: true, ipxeScript: &url.URL{Scheme: "http", Host: "localhost:8181", Path: "auto.ipxe"}, }, Netboot: Netboot{Enabled: true}, IPAddr: netip.MustParseAddr("127.0.0.1"), }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, ClientIPAddr: []byte{0, 0, 0, 0}, YourIPAddr: []byte{192, 168, 1, 100}, ServerIPAddr: []byte{127, 0, 0, 1}, GatewayIPAddr: []byte{0, 0, 0, 0}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeRequest), dhcpv4.OptServerIdentifier(net.IP{127, 0, 0, 1}), dhcpv4.OptIPAddressLeaseTime(time.Minute), dhcpv4.OptSubnetMask(net.IPMask(net.IP{255, 255, 255, 0}.To4())), dhcpv4.OptRouter([]net.IP{{192, 168, 1, 1}}...), dhcpv4.OptDNS([]net.IP{{1, 1, 1, 1}}...), dhcpv4.OptDomainName("mydomain.com"), dhcpv4.OptHostName("test-host"), dhcpv4.OptBroadcastAddress(net.IP{192, 168, 1, 255}), dhcpv4.OptNTPServers([]net.IP{{132, 163, 96, 2}}...), dhcpv4.OptDomainSearch(&rfc1035label.Labels{Labels: []string{"mydomain.com"}}), dhcpv4.OptUserClass("Tinkerbell"), dhcpv4.OptClassIdentifier("HTTPClient:Arch:xxxxx:UNDI:yyyzzz"), dhcpv4.OptClientArch(iana.EFI_X86_64_HTTP), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05}), ), }, want: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootReply, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, ClientIPAddr: []byte{0, 0, 0, 0}, YourIPAddr: []byte{192, 168, 1, 100}, ServerIPAddr: []byte{0, 0, 0, 0}, GatewayIPAddr: []byte{0, 0, 0, 0}, BootFileName: "http://localhost:8181/auto.ipxe", Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeAck), dhcpv4.OptServerIdentifier(net.IP{127, 0, 0, 1}), dhcpv4.OptIPAddressLeaseTime(time.Minute), dhcpv4.OptSubnetMask(net.IPMask(net.IP{255, 255, 255, 0}.To4())), dhcpv4.OptRouter([]net.IP{{192, 168, 1, 1}}...), dhcpv4.OptDNS([]net.IP{{1, 1, 1, 1}}...), dhcpv4.OptDomainName("mydomain.com"), dhcpv4.OptHostName("test-host"), dhcpv4.OptBroadcastAddress(net.IP{192, 168, 1, 255}), dhcpv4.OptNTPServers([]net.IP{{132, 163, 96, 2}}...), dhcpv4.OptDomainSearch(&rfc1035label.Labels{Labels: []string{"mydomain.com"}}), dhcpv4.OptClassIdentifier("HTTPClient"), dhcpv4.OptGeneric(dhcpv4.OptionVendorSpecificInformation, dhcpv4.Options{ 6: []byte{8}, 69: otel.TraceparentFromContext(context.Background()), }.ToBytes()), ), }, }, "failure request message type": { server: Handler{ Backend: &mockBackend{err: errBadBackend}, IPAddr: netip.MustParseAddr("127.0.0.1"), }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeRequest), ), }, wantErr: errBadBackend, }, "request release type": { server: Handler{ Backend: &mockBackend{err: errBadBackend}, IPAddr: netip.MustParseAddr("127.0.0.1"), }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeRelease), ), }, wantErr: errBadBackend, }, "unknown message type": { server: Handler{ Backend: &mockBackend{err: errBadBackend}, IPAddr: netip.MustParseAddr("127.0.0.1"), }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeInform), ), }, wantErr: errBadBackend, }, "fail WriteTo": { server: Handler{ Backend: &mockBackend{}, }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), ), }, wantErr: errBadBackend, nilPeer: true, }, "nil incoming packet": { want: nil, wantErr: errBadBackend, }, /*"nil incoming packet": { want: nil, wantErr: errBadBackend, },*/ "failure no hardware found discover": { server: Handler{ Backend: &mockBackend{hardwareNotFound: true}, IPAddr: netip.MustParseAddr("127.0.0.1"), }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), ), }, want: nil, wantErr: errBadBackend, }, "failure no hardware found request": { server: Handler{ Backend: &mockBackend{hardwareNotFound: true}, IPAddr: netip.MustParseAddr("127.0.0.1"), }, req: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeRequest), ), }, want: nil, wantErr: errBadBackend, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { s := tt.server conn, err := nettest.NewLocalPacketListener("udp") if err != nil { t.Fatal("1", err) } defer conn.Close() pc, err := net.ListenPacket("udp4", ":0") if err != nil { t.Fatal("2", err) } defer pc.Close() peer := &net.UDPAddr{IP: net.IP{127, 0, 0, 1}, Port: pc.LocalAddr().(*net.UDPAddr).Port} if tt.nilPeer { peer = nil } con := ipv4.NewPacketConn(conn) con.SetControlMessage(ipv4.FlagInterface, true) n, err := net.InterfaceByName("lo") if err != nil { t.Fatal(err) } s.Handle(context.Background(), con, data.Packet{Peer: peer, Pkt: tt.req, Md: &data.Metadata{IfName: n.Name, IfIndex: n.Index}}) msg, err := client(pc) if !errors.Is(err, tt.wantErr) { t.Fatalf("client() error = %v, wantErr %v", err, tt.wantErr) } if diff := cmp.Diff(msg, tt.want, cmpopts.IgnoreUnexported(dhcpv4.DHCPv4{})); diff != "" { t.Fatal("diff", diff) } }) } } func client(pc net.PacketConn) (*dhcpv4.DHCPv4, error) { buf := make([]byte, 1024) pc.SetReadDeadline(time.Now().Add(time.Millisecond * 100)) if _, _, err := pc.ReadFrom(buf); err != nil { return nil, errBadBackend } msg, err := dhcpv4.FromBytes(buf) if err != nil { return nil, errBadBackend } return msg, nil } func TestUpdateMsg(t *testing.T) { type args struct { m *dhcpv4.DHCPv4 data *data.DHCP netboot *data.Netboot msg dhcpv4.MessageType } tests := map[string]struct { args args want *dhcpv4.DHCPv4 wantErr bool }{ "success": { args: args{ m: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptUserClass("Tinkerbell"), dhcpv4.OptClassIdentifier("HTTPClient"), dhcpv4.OptClientArch(iana.EFI_ARM64_HTTP), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05}), dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), ), }, data: &data.DHCP{IPAddress: netip.MustParseAddr("192.168.1.100"), SubnetMask: net.IPMask(net.IP{255, 255, 255, 0}.To4())}, netboot: &data.Netboot{AllowNetboot: true, IPXEScriptURL: &url.URL{Scheme: "http", Host: "localhost:8181", Path: "auto.ipxe"}}, msg: dhcpv4.MessageTypeDiscover, }, want: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootReply, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, YourIPAddr: []byte{192, 168, 1, 100}, ClientIPAddr: []byte{0, 0, 0, 0}, BootFileName: "http://localhost:8181/auto.ipxe", Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), dhcpv4.OptServerIdentifier(net.IP{127, 0, 0, 1}), dhcpv4.OptIPAddressLeaseTime(3600), dhcpv4.OptSubnetMask(net.IPMask(net.IP{255, 255, 255, 0}.To4())), dhcpv4.OptClassIdentifier("HTTPClient"), dhcpv4.OptGeneric(dhcpv4.OptionVendorSpecificInformation, dhcpv4.Options{ 6: []byte{8}, 69: otel.TraceparentFromContext(context.Background()), }.ToBytes()), ), }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { s := &Handler{ Log: stdr.New(log.New(os.Stdout, "", log.Lshortfile)), IPAddr: netip.MustParseAddr("127.0.0.1"), Netboot: Netboot{ Enabled: true, }, Backend: &mockBackend{ allowNetboot: true, ipxeScript: &url.URL{Scheme: "http", Host: "localhost:8181", Path: "auto.ipxe"}, }, // Listener: netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), 67), } got := s.updateMsg(context.Background(), tt.args.m, tt.args.data, tt.args.netboot, tt.args.msg) if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(dhcpv4.DHCPv4{})); diff != "" { t.Fatal(diff) } }) } } func TestOne(t *testing.T) { t.Skip() h := &Handler{} _, _, err := h.readBackend(context.Background(), nil) t.Fatal(err) } func TestReadBackend(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 wantDHCP *data.DHCP wantNetboot *data.Netboot wantErr error }{ "success": { input: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptUserClass("Tinkerbell"), dhcpv4.OptClassIdentifier("HTTPClient"), dhcpv4.OptClientArch(iana.EFI_ARM64_HTTP), dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}), dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x02, 0x03, 0x04, 0x05}), dhcpv4.OptMessageType(dhcpv4.MessageTypeDiscover), ), }, wantDHCP: &data.DHCP{ MACAddress: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, IPAddress: netip.MustParseAddr("192.168.1.100"), SubnetMask: []byte{255, 255, 255, 0}, DefaultGateway: netip.MustParseAddr("192.168.1.1"), NameServers: []net.IP{{1, 1, 1, 1}}, Hostname: "test-host", DomainName: "mydomain.com", BroadcastAddress: netip.MustParseAddr("192.168.1.255"), NTPServers: []net.IP{{132, 163, 96, 2}}, LeaseTime: 60, DomainSearch: []string{"mydomain.com"}, }, wantNetboot: &data.Netboot{AllowNetboot: true, IPXEScriptURL: &url.URL{Scheme: "http", Host: "localhost:8181", Path: "auto.ipxe"}}, wantErr: nil, }, "failure": { input: &dhcpv4.DHCPv4{}, wantErr: errBadBackend, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { s := &Handler{ Log: stdr.New(log.New(os.Stdout, "", log.Lshortfile)), IPAddr: netip.MustParseAddr("127.0.0.1"), Netboot: Netboot{ Enabled: true, }, Backend: &mockBackend{ err: tt.wantErr, allowNetboot: true, ipxeScript: &url.URL{Scheme: "http", Host: "localhost:8181", Path: "auto.ipxe"}, }, // Listener: netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), 67), } netaddrComparer := cmp.Comparer(func(x, y netip.Addr) bool { i := x.Compare(y) return i == 0 }) gotDHCP, gotNetboot, err := s.readBackend(context.Background(), tt.input.ClientHWAddr) if !errors.Is(err, tt.wantErr) { t.Fatalf("gotErr: %v, wantErr: %v", err, tt.wantErr) } if diff := cmp.Diff(gotDHCP, tt.wantDHCP, netaddrComparer); diff != "" { t.Fatal(diff) } if diff := cmp.Diff(gotNetboot, tt.wantNetboot); diff != "" { t.Fatal(diff) } }) } } func TestEncodeToAttributes(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want []attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{BootFileName: "snp.efi"}, want: []attribute.KeyValue{ attribute.String("DHCP.testing.Header.file", "snp.efi"), attribute.String("DHCP.testing.Header.flags", "Unicast"), attribute.String("DHCP.testing.Header.transactionID", "0x00000000"), }, }, "error": {}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { stdr.SetVerbosity(1) s := &Handler{Log: stdr.New(log.New(os.Stdout, "", log.Lshortfile))} kvs := s.encodeToAttributes(tt.input, "testing") got := attribute.NewSet(kvs...) want := attribute.NewSet(tt.want...) enc := attribute.DefaultEncoder() if diff := cmp.Diff(got.Encoded(enc), want.Encoded(enc)); diff != "" { t.Log(got.Encoded(enc)) t.Log(want.Encoded(enc)) t.Fatal(diff) } }) } } ================================================ FILE: internal/dhcp/handler/reservation/noop.go ================================================ // Package noop is a backend handler that does nothing. package reservation import ( "context" "errors" "net" "github.com/tinkerbell/smee/internal/dhcp/data" ) // Handler is a noop backend. type noop struct{} // GetByMac returns an error. func (h noop) GetByMac(_ context.Context, _ net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { return nil, nil, errors.New("no backend specified, please specify a backend") } // GetByIP returns an error. func (h noop) GetByIP(_ context.Context, _ net.IP) (*data.DHCP, *data.Netboot, error) { return nil, nil, errors.New("no backend specified, please specify a backend") } ================================================ FILE: internal/dhcp/handler/reservation/noop_test.go ================================================ package reservation import ( "context" "errors" "testing" "github.com/google/go-cmp/cmp" ) func TestNoop(t *testing.T) { want := errors.New("no backend specified, please specify a backend") _, _, got := noop{}.GetByMac(context.TODO(), nil) if diff := cmp.Diff(want.Error(), got.Error()); diff != "" { t.Fatal(diff) } _, _, got = noop{}.GetByIP(context.TODO(), nil) if diff := cmp.Diff(want.Error(), got.Error()); diff != "" { t.Fatal(diff) } } ================================================ FILE: internal/dhcp/handler/reservation/option.go ================================================ package reservation import ( "context" "fmt" "net" "net/netip" "net/url" "strings" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/tinkerbell/smee/internal/dhcp" "github.com/tinkerbell/smee/internal/dhcp/data" dhcpotel "github.com/tinkerbell/smee/internal/dhcp/otel" "github.com/tinkerbell/smee/internal/otel" ) // setDHCPOpts takes a client dhcp packet and data (typically from a backend) and creates a slice of DHCP packet modifiers. // m is the DHCP request from a client. d is the data to use to create the DHCP packet modifiers. // This is most likely the place where we would have any business logic for determining DHCP option setting. func (h *Handler) setDHCPOpts(_ context.Context, _ *dhcpv4.DHCPv4, d *data.DHCP) []dhcpv4.Modifier { mods := []dhcpv4.Modifier{ dhcpv4.WithLeaseTime(d.LeaseTime), dhcpv4.WithYourIP(d.IPAddress.AsSlice()), } if len(d.NameServers) > 0 { mods = append(mods, dhcpv4.WithDNS(d.NameServers...)) } if len(d.DomainSearch) > 0 { mods = append(mods, dhcpv4.WithDomainSearchList(d.DomainSearch...)) } if len(d.NTPServers) > 0 { mods = append(mods, dhcpv4.WithOption(dhcpv4.OptNTPServers(d.NTPServers...))) } if d.BroadcastAddress.Compare(netip.Addr{}) != 0 { mods = append(mods, dhcpv4.WithGeneric(dhcpv4.OptionBroadcastAddress, d.BroadcastAddress.AsSlice())) } if d.DomainName != "" { mods = append(mods, dhcpv4.WithGeneric(dhcpv4.OptionDomainName, []byte(d.DomainName))) } if d.Hostname != "" { mods = append(mods, dhcpv4.WithGeneric(dhcpv4.OptionHostName, []byte(d.Hostname))) } if len(d.SubnetMask) > 0 { mods = append(mods, dhcpv4.WithNetmask(d.SubnetMask)) } if d.DefaultGateway.Compare(netip.Addr{}) != 0 { mods = append(mods, dhcpv4.WithRouter(d.DefaultGateway.AsSlice())) } if h.SyslogAddr.Compare(netip.Addr{}) != 0 { mods = append(mods, dhcpv4.WithOption(dhcpv4.OptGeneric(dhcpv4.OptionLogServer, h.SyslogAddr.AsSlice()))) } return mods } // setNetworkBootOpts purpose is to sets 3 or 4 values. 2 DHCP headers, option 43 and optionally option (60). // These headers and options are returned as a dhcvp4.Modifier that can be used to modify a dhcp response. // github.com/insomniacslk/dhcp uses this method to simplify packet manipulation. // // DHCP Headers (https://datatracker.ietf.org/doc/html/rfc2131#section-2) // 'siaddr': IP address of next bootstrap server. represented below as `.ServerIPAddr`. // 'file': Client boot file name. represented below as `.BootFileName`. // // DHCP option // option 60: Class Identifier. https://www.rfc-editor.org/rfc/rfc2132.html#section-9.13 // option 60 is set if the client's option 60 (Class Identifier) starts with HTTPClient. func (h *Handler) setNetworkBootOpts(ctx context.Context, m *dhcpv4.DHCPv4, n *data.Netboot) dhcpv4.Modifier { // m is a received DHCPv4 packet. // d is the reply packet we are building. withNetboot := func(d *dhcpv4.DHCPv4) { // if the client sends opt 60 with HTTPClient then we need to respond with opt 60 // This is outside of the n.AllowNetboot check because we will be sending "/netboot-not-allowed" regardless. if val := m.Options.Get(dhcpv4.OptionClassIdentifier); val != nil { if strings.HasPrefix(string(val), dhcp.HTTPClient.String()) { d.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionClassIdentifier, []byte(dhcp.HTTPClient))) } } d.BootFileName = "/netboot-not-allowed" d.ServerIPAddr = net.IPv4(0, 0, 0, 0) if n.AllowNetboot { i := dhcp.NewInfo(m) if i.IPXEBinary == "" { return } var ipxeScript *url.URL // If the global IPXEScriptURL is set, use that. if h.Netboot.IPXEScriptURL != nil { ipxeScript = h.Netboot.IPXEScriptURL(m) } // If the IPXE script URL is set on the hardware record, use that. if n.IPXEScriptURL != nil { ipxeScript = n.IPXEScriptURL } d.BootFileName, d.ServerIPAddr = h.bootfileAndNextServer(ctx, m, h.Netboot.UserClass, h.Netboot.IPXEBinServerTFTP, h.Netboot.IPXEBinServerHTTP, ipxeScript) pxe := dhcpv4.Options{ // FYI, these are suboptions of option43. ref: https://datatracker.ietf.org/doc/html/rfc2132#section-8.4 // PXE Boot Server Discovery Control - bypass, just boot from filename. 6: []byte{8}, 69: dhcpotel.TraceparentFromContext(ctx), } d.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionVendorSpecificInformation, i.AddRPIOpt43(pxe))) } } return withNetboot } // bootfileAndNextServer returns the bootfile (string) and next server (net.IP). // input arguments `tftp`, `ipxe` and `iscript` use non string types so as to attempt to be more clear about the expectation around what is wanted for these values. // It also helps us avoid having to validate a string in multiple ways. func (h *Handler) bootfileAndNextServer(ctx context.Context, pkt *dhcpv4.DHCPv4, customUC dhcp.UserClass, tftp netip.AddrPort, ipxe, iscript *url.URL) (string, net.IP) { var nextServer net.IP var bootfile string i := dhcp.NewInfo(pkt) if tp := otel.TraceparentStringFromContext(ctx); h.OTELEnabled && tp != "" { i.IPXEBinary = fmt.Sprintf("%s-%v", i.IPXEBinary, tp) } nextServer = i.NextServer(ipxe, tftp) bootfile = i.Bootfile(customUC, iscript, ipxe, tftp) return bootfile, nextServer } ================================================ FILE: internal/dhcp/handler/reservation/option_test.go ================================================ package reservation import ( "context" "net" "net/netip" "net/url" "testing" "time" "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/iana" "github.com/insomniacslk/dhcp/rfc1035label" "github.com/tinkerbell/smee/internal/dhcp" "github.com/tinkerbell/smee/internal/dhcp/data" oteldhcp "github.com/tinkerbell/smee/internal/dhcp/otel" dhcpotel "github.com/tinkerbell/smee/internal/otel" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/propagation" ) const ( examplePXEClient = "PXEClient:Arch:00007:UNDI:003001" exampleHTTPClient = "HTTPClient:Arch:00016:UNDI:003001" ) func TestSetDHCPOpts(t *testing.T) { type args struct { in0 context.Context m *dhcpv4.DHCPv4 d *data.DHCP } tests := map[string]struct { server Handler args args want *dhcpv4.DHCPv4 }{ "success": { server: Handler{Log: logr.Discard(), SyslogAddr: netip.MustParseAddr("192.168.7.7")}, args: args{ in0: context.Background(), m: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList(dhcpv4.OptParameterRequestList(dhcpv4.OptionSubnetMask))}, d: &data.DHCP{ MACAddress: net.HardwareAddr{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, IPAddress: netip.MustParseAddr("192.168.4.4"), SubnetMask: []byte{255, 255, 255, 0}, DefaultGateway: netip.MustParseAddr("192.168.4.1"), NameServers: []net.IP{ {8, 8, 8, 8}, {8, 8, 4, 4}, }, Hostname: "test-server", DomainName: "mynet.local", BroadcastAddress: netip.MustParseAddr("192.168.4.255"), NTPServers: []net.IP{ {132, 163, 96, 2}, {132, 163, 96, 3}, }, LeaseTime: 84600, DomainSearch: []string{ "mynet.local", }, }, }, want: &dhcpv4.DHCPv4{ OpCode: dhcpv4.OpcodeBootRequest, HWType: iana.HWTypeEthernet, ClientHWAddr: net.HardwareAddr{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, ClientIPAddr: []byte{0, 0, 0, 0}, YourIPAddr: []byte{192, 168, 4, 4}, ServerIPAddr: []byte{0, 0, 0, 0}, GatewayIPAddr: []byte{0, 0, 0, 0}, Options: dhcpv4.OptionsFromList( dhcpv4.OptGeneric(dhcpv4.OptionLogServer, []byte{192, 168, 7, 7}), dhcpv4.OptSubnetMask(net.IPMask{255, 255, 255, 0}), dhcpv4.OptBroadcastAddress(net.IP{192, 168, 4, 255}), dhcpv4.OptIPAddressLeaseTime(time.Duration(84600)*time.Second), dhcpv4.OptDomainName("mynet.local"), dhcpv4.OptHostName("test-server"), dhcpv4.OptRouter(net.IP{192, 168, 4, 1}), dhcpv4.OptDNS([]net.IP{ {8, 8, 8, 8}, {8, 8, 4, 4}, }...), dhcpv4.OptNTPServers([]net.IP{ {132, 163, 96, 2}, {132, 163, 96, 3}, }...), dhcpv4.OptDomainSearch(&rfc1035label.Labels{ Labels: []string{"mynet.local"}, }), ), }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { s := &Handler{ Log: tt.server.Log, Netboot: Netboot{ IPXEBinServerTFTP: tt.server.Netboot.IPXEBinServerTFTP, IPXEBinServerHTTP: tt.server.Netboot.IPXEBinServerHTTP, IPXEScriptURL: tt.server.Netboot.IPXEScriptURL, Enabled: tt.server.Netboot.Enabled, UserClass: tt.server.Netboot.UserClass, }, IPAddr: tt.server.IPAddr, Backend: tt.server.Backend, SyslogAddr: tt.server.SyslogAddr, } mods := s.setDHCPOpts(tt.args.in0, tt.args.m, tt.args.d) finalPkt, err := dhcpv4.New(mods...) if err != nil { t.Fatalf("setDHCPOpts() error = %v, wantErr nil", err) } if diff := cmp.Diff(tt.want, finalPkt, cmpopts.IgnoreFields(dhcpv4.DHCPv4{}, "TransactionID")); diff != "" { t.Fatal(diff) } }) } } func TestBootfileAndNextServer(t *testing.T) { type args struct { pkt *dhcpv4.DHCPv4 uClass dhcp.UserClass tftp netip.AddrPort ipxe *url.URL iscript *url.URL } tests := map[string]struct { server *Handler args args otelEnabled bool wantBootFile string wantNextSrv net.IP }{ "success bootfile only": { server: &Handler{Log: logr.Discard()}, args: args{ pkt: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptUserClass(dhcp.Tinkerbell.String()), ), }, iscript: &url.URL{Scheme: "http", Host: "localhost:8080", Path: "/auto.ipxe"}, }, wantBootFile: "http://localhost:8080/auto.ipxe", wantNextSrv: nil, }, "success httpClient": { server: &Handler{Log: logr.Discard()}, args: args{ pkt: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptClientArch(iana.EFI_ARM64_HTTP), dhcpv4.OptClassIdentifier(exampleHTTPClient), ), }, ipxe: &url.URL{Scheme: "http", Host: "127.0.0.1:8181"}, }, wantBootFile: "http://127.0.0.1:8181/01:02:03:04:05:06/snp.efi", wantNextSrv: net.IPv4(127, 0, 0, 1), }, "success userclass iPXE": { server: &Handler{Log: logr.Discard()}, args: args{ pkt: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x07}, Options: dhcpv4.OptionsFromList( dhcpv4.OptClientArch(iana.INTEL_X86PC), dhcpv4.OptUserClass(dhcp.IPXE.String()), ), }, tftp: netip.MustParseAddrPort("192.168.6.5:69"), }, wantBootFile: "tftp://192.168.6.5:69/01:02:03:04:05:07/undionly.kpxe", wantNextSrv: net.ParseIP("192.168.6.5"), }, "success userclass iPXE with otel": { server: &Handler{Log: logr.Discard(), OTELEnabled: true}, otelEnabled: true, args: args{ pkt: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x07}, Options: dhcpv4.OptionsFromList( dhcpv4.OptClientArch(iana.INTEL_X86PC), dhcpv4.OptUserClass(dhcp.IPXE.String()), ), }, tftp: netip.MustParseAddrPort("192.168.6.5:69"), ipxe: &url.URL{Scheme: "tftp", Host: "192.168.6.5:69"}, }, wantBootFile: "tftp://192.168.6.5:69/01:02:03:04:05:07/undionly.kpxe-00-23b1e307bb35484f535a1f772c06910e-d887dc3912240434-01", wantNextSrv: net.ParseIP("192.168.6.5"), }, "success default": { server: &Handler{Log: logr.Discard()}, args: args{ pkt: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x07}, Options: dhcpv4.OptionsFromList( dhcpv4.OptClientArch(iana.INTEL_X86PC), ), }, tftp: netip.MustParseAddrPort("192.168.6.5:69"), ipxe: &url.URL{Scheme: "tftp", Host: "192.168.6.5:69"}, }, wantBootFile: "undionly.kpxe", wantNextSrv: net.ParseIP("192.168.6.5"), }, "success otel enabled, no traceparent": { server: &Handler{Log: logr.Discard(), OTELEnabled: true}, args: args{ pkt: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x07}, Options: dhcpv4.OptionsFromList( dhcpv4.OptClientArch(iana.INTEL_X86PC), ), }, tftp: netip.MustParseAddrPort("192.168.6.5:69"), ipxe: &url.URL{Scheme: "tftp", Host: "192.168.6.5:69"}, }, wantBootFile: "undionly.kpxe", wantNextSrv: net.ParseIP("192.168.6.5"), }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { ctx := context.Background() if tt.otelEnabled { // set global propagator to tracecontext (the default is no-op). prop := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) otel.SetTextMapPropagator(prop) ctx = dhcpotel.ContextWithTraceparentString(ctx, "00-23b1e307bb35484f535a1f772c06910e-d887dc3912240434-01") } bootfile, nextServer := tt.server.bootfileAndNextServer(ctx, tt.args.pkt, tt.args.uClass, tt.args.tftp, tt.args.ipxe, tt.args.iscript) if diff := cmp.Diff(bootfile, tt.wantBootFile); diff != "" { t.Fatal("bootfile", diff) } if diff := cmp.Diff(nextServer, tt.wantNextSrv); diff != "" { t.Fatal("nextServer", diff) } }) } } func TestSetNetworkBootOpts(t *testing.T) { type args struct { in0 context.Context m *dhcpv4.DHCPv4 n *data.Netboot } tests := map[string]struct { server *Handler args args want *dhcpv4.DHCPv4 }{ "netboot not allowed": { server: &Handler{Log: logr.Discard()}, args: args{ in0: context.Background(), m: &dhcpv4.DHCPv4{}, n: &data.Netboot{AllowNetboot: false}, }, want: &dhcpv4.DHCPv4{ServerIPAddr: net.IPv4(0, 0, 0, 0), BootFileName: "/netboot-not-allowed"}, }, "netboot allowed": { server: &Handler{Log: logr.Discard(), Netboot: Netboot{IPXEScriptURL: func(*dhcpv4.DHCPv4) *url.URL { return &url.URL{Scheme: "http", Host: "localhost:8181", Path: "/01:02:03:04:05:06/auto.ipxe"} }}}, args: args{ in0: context.Background(), m: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptUserClass(dhcp.Tinkerbell.String()), dhcpv4.OptClassIdentifier("HTTPClient:xxxxx"), dhcpv4.OptClientArch(iana.EFI_X86_64_HTTP), ), }, n: &data.Netboot{AllowNetboot: true, IPXEScriptURL: &url.URL{Scheme: "http", Host: "localhost:8181", Path: "/01:02:03:04:05:06/auto.ipxe"}}, }, want: &dhcpv4.DHCPv4{BootFileName: "http://localhost:8181/01:02:03:04:05:06/auto.ipxe", Options: dhcpv4.OptionsFromList( dhcpv4.OptGeneric(dhcpv4.OptionVendorSpecificInformation, dhcpv4.Options{ 6: []byte{8}, 69: oteldhcp.TraceparentFromContext(context.Background()), }.ToBytes()), dhcpv4.OptClassIdentifier("HTTPClient"), )}, }, "netboot not allowed, arch unknown": { server: &Handler{Log: logr.Discard(), Netboot: Netboot{IPXEScriptURL: func(*dhcpv4.DHCPv4) *url.URL { return &url.URL{Scheme: "http", Host: "localhost:8181", Path: "/01:02:03:04:05:06/auto.ipxe"} }}}, args: args{ in0: context.Background(), m: &dhcpv4.DHCPv4{ ClientHWAddr: net.HardwareAddr{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, Options: dhcpv4.OptionsFromList( dhcpv4.OptUserClass(dhcp.Tinkerbell.String()), dhcpv4.OptClientArch(iana.UBOOT_ARM64), ), }, n: &data.Netboot{AllowNetboot: true}, }, want: &dhcpv4.DHCPv4{ServerIPAddr: net.IPv4(0, 0, 0, 0), BootFileName: "/netboot-not-allowed"}, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { s := &Handler{ Log: tt.server.Log, Netboot: Netboot{ IPXEBinServerTFTP: tt.server.Netboot.IPXEBinServerTFTP, IPXEBinServerHTTP: tt.server.Netboot.IPXEBinServerHTTP, IPXEScriptURL: tt.server.Netboot.IPXEScriptURL, Enabled: tt.server.Netboot.Enabled, UserClass: tt.server.Netboot.UserClass, }, IPAddr: tt.server.IPAddr, Backend: tt.server.Backend, } gotFunc := s.setNetworkBootOpts(tt.args.in0, tt.args.m, tt.args.n) got := new(dhcpv4.DHCPv4) gotFunc(got) if diff := cmp.Diff(got, tt.want); diff != "" { t.Fatal(diff) } }) } } ================================================ FILE: internal/dhcp/handler/reservation/reservation.go ================================================ // Package reservation is the handler for responding to DHCPv4 messages with only host reservations. package reservation import ( "net/netip" "net/url" "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/tinkerbell/smee/internal/dhcp" "github.com/tinkerbell/smee/internal/dhcp/handler" ) // Handler holds the configuration details for the running the DHCP server. type Handler struct { // Backend is the backend to use for getting DHCP data. Backend handler.BackendReader // IPAddr is the IP address to use in DHCP responses. // Option 54 and the sname DHCP header. // This could be a load balancer IP address or an ingress IP address or a local IP address. IPAddr netip.Addr // Log is used to log messages. // `logr.Discard()` can be used if no logging is desired. Log logr.Logger // Netboot configuration Netboot Netboot // OTELEnabled is used to determine if netboot options include otel naming. // When true, the netboot filename will be appended with otel information. // For example, the filename will be "snp.efi-00-23b1e307bb35484f535a1f772c06910e-d887dc3912240434-01". // -00--- OTELEnabled bool // SyslogAddr is the address to send syslog messages to. DHCP Option 7. SyslogAddr netip.Addr } // Netboot holds the netboot configuration details used in running a DHCP server. type Netboot struct { // iPXE binary server IP:Port serving via TFTP. IPXEBinServerTFTP netip.AddrPort // IPXEBinServerHTTP is the URL to the IPXE binary server serving via HTTP(s). IPXEBinServerHTTP *url.URL // IPXEScriptURL is the URL to the IPXE script to use. IPXEScriptURL func(*dhcpv4.DHCPv4) *url.URL // Enabled is whether to enable sending netboot DHCP options. Enabled bool // UserClass (for network booting) allows a custom DHCP option 77 to be used to break out of an iPXE loop. UserClass dhcp.UserClass } ================================================ FILE: internal/dhcp/otel/otel.go ================================================ // Package otel handles translating DHCP headers and options to otel key/value attributes. package otel import ( "context" "fmt" "net" "strings" "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) const keyNamespace = "DHCP" // Encoder holds the otel key/value attributes. type Encoder struct { Log logr.Logger } type notFoundError struct { optName string } func (e *notFoundError) Error() string { return fmt.Sprintf("%q not found in DHCP packet", e.optName) } func (e *notFoundError) found() bool { return true } type found interface { found() bool } // OptNotFound returns true if err is an option not found error. func OptNotFound(err error) bool { te, ok := err.(found) return ok && te.found() } // Encode runs a slice of encoders against a DHCPv4 packet turning the values into opentelemetry attribute key/value pairs. func (e *Encoder) Encode(pkt *dhcpv4.DHCPv4, namespace string, encoders ...func(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error)) []attribute.KeyValue { if e.Log.GetSink() == nil { e.Log = logr.Discard() } var attrs []attribute.KeyValue for _, elem := range encoders { kv, err := elem(pkt, namespace) if err != nil { e.Log.V(2).Info("opentelemetry attribute not added", "error", fmt.Sprintf("%v", err)) continue } attrs = append(attrs, kv) } return attrs } // AllEncoders returns a slice of all available DHCP otel encoders. func AllEncoders() []func(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { return []func(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error){ EncodeFlags, EncodeTransactionID, EncodeYIADDR, EncodeSIADDR, EncodeCHADDR, EncodeFILE, EncodeOpt1, EncodeOpt3, EncodeOpt6, EncodeOpt12, EncodeOpt15, EncodeOpt28, EncodeOpt42, EncodeOpt51, EncodeOpt53, EncodeOpt54, EncodeOpt60, EncodeOpt93, EncodeOpt94, EncodeOpt97, EncodeOpt119, } } // EncodeFlags takes DHCP flags from a DHCP packet and returns an OTEL key/value pair. // key/value pair. See https://datatracker.ietf.org/doc/html/rfc2131#page-9 func EncodeFlags(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Header.flags", keyNamespace, namespace) if d != nil { return attribute.String(key, d.FlagsToString()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeTransactionID takes the Transaction ID header from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeTransactionID(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Header.transactionID", keyNamespace, namespace) if d != nil { return attribute.String(key, d.TransactionID.String()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt1 takes DHCP Opt 1 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt1(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { opt := "Opt1.SubnetMask" key := fmt.Sprintf("%v.%v.%v", keyNamespace, namespace, opt) if d != nil && d.SubnetMask() != nil { sm := net.IP(d.SubnetMask()).String() return attribute.String(key, sm), nil } return attribute.KeyValue{}, ¬FoundError{optName: opt} } // EncodeOpt3 takes DHCP Opt 3 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt3(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt3.DefaultGateway", keyNamespace, namespace) if d != nil { var routers []string for _, e := range d.Router() { routers = append(routers, e.String()) } if len(routers) > 0 { return attribute.String(key, strings.Join(routers, ",")), nil } } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt6 takes DHCP Opt 6 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt6(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt6.NameServers", keyNamespace, namespace) if d != nil { var ns []string for _, e := range d.DNS() { ns = append(ns, e.String()) } if len(ns) > 0 { return attribute.String(key, strings.Join(ns, ",")), nil } } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt12 takes DHCP Opt 12 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt12(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt12.Hostname", keyNamespace, namespace) if d != nil && d.HostName() != "" { return attribute.String(key, d.HostName()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt15 takes DHCP Opt 15 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt15(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt15.DomainName", keyNamespace, namespace) if d != nil && d.DomainName() != "" { return attribute.String(key, d.DomainName()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt28 takes DHCP Opt 28 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt28(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt28.BroadcastAddress", keyNamespace, namespace) if d != nil && d.BroadcastAddress() != nil { return attribute.String(key, d.BroadcastAddress().String()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt42 takes DHCP Opt 42 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt42(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt42.NTPServers", keyNamespace, namespace) if d != nil { var ntp []string for _, e := range d.NTPServers() { ntp = append(ntp, e.String()) } if len(ntp) > 0 { return attribute.String(key, strings.Join(ntp, ",")), nil } } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt51 takes DHCP Opt 51 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt51(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt51.LeaseTime", keyNamespace, namespace) if d != nil && d.IPAddressLeaseTime(0) != 0 { return attribute.Float64(key, d.IPAddressLeaseTime(0).Seconds()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt53 takes DHCP Opt 53 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt53(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt53.MessageType", keyNamespace, namespace) if d != nil && d.MessageType() != dhcpv4.MessageTypeNone { return attribute.String(key, d.MessageType().String()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt54 takes DHCP Opt 54 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt54(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt54.ServerIdentifier", keyNamespace, namespace) if d != nil && d.ServerIdentifier() != nil { return attribute.String(key, d.ServerIdentifier().String()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt60 takes DHCP Opt 60 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt60(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt60.ClassIdentifier", keyNamespace, namespace) if d != nil && d.ClassIdentifier() != "" { return attribute.String(key, d.ClassIdentifier()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt93 takes DHCP Opt 93 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt93(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt93.ClientIdentifier", keyNamespace, namespace) if d != nil && len(d.ClientArch()) > 0 { var r []string for _, i := range d.ClientArch() { r = append(r, i.String()) } return attribute.StringSlice(key, r), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt94 takes DHCP Opt 94 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt94(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt94.ClientNetworkInterfaceIdentifier", keyNamespace, namespace) if d != nil && len(d.GetOneOption(dhcpv4.OptionClientNetworkInterfaceIdentifier)) > 0 { var r []string for _, i := range d.GetOneOption(dhcpv4.OptionClientNetworkInterfaceIdentifier) { r = append(r, fmt.Sprintf("%v", i)) } // "." delimited follows the same format from tcpdump return attribute.String(key, strings.Join(r, ".")), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt97 takes DHCP Opt 97 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt97(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt97.ClientMachineIdentifier", keyNamespace, namespace) if d != nil && len(d.GetOneOption(dhcpv4.OptionClientMachineIdentifier)) > 0 { var r []string for _, i := range d.GetOneOption(dhcpv4.OptionClientMachineIdentifier) { r = append(r, fmt.Sprintf("%v", i)) } // "." delimited follows the same format from tcpdump return attribute.String(key, strings.Join(r, ".")), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeOpt119 takes DHCP Opt 119 from a DHCP packet and returns an OTEL key/value pair. // See https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml func EncodeOpt119(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Opt119.DomainSearch", keyNamespace, namespace) if d != nil { if l := d.DomainSearch(); l != nil { return attribute.String(key, strings.Join(l.Labels, ",")), nil } } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeYIADDR takes the yiaddr header from a DHCP packet and returns an OTEL // key/value pair. See https://datatracker.ietf.org/doc/html/rfc2131#page-9 func EncodeYIADDR(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Header.yiaddr", keyNamespace, namespace) if d != nil && d.YourIPAddr != nil { return attribute.String(key, d.YourIPAddr.String()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeSIADDR takes the siaddr header from a DHCP packet and returns an OTEL // key/value pair. See https://datatracker.ietf.org/doc/html/rfc2131#page-9 func EncodeSIADDR(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Header.siaddr", keyNamespace, namespace) if d != nil && d.ServerIPAddr != nil { return attribute.String(key, d.ServerIPAddr.String()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeCHADDR takes the CHADDR header from a DHCP packet and returns an OTEL // key/value pair. See https://datatracker.ietf.org/doc/html/rfc2131#page-9 func EncodeCHADDR(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Header.chaddr", keyNamespace, namespace) if d != nil && d.ClientHWAddr != nil { return attribute.String(key, d.ClientHWAddr.String()), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // EncodeFILE takes the file header from a DHCP packet and returns an OTEL // key/value pair. See https://datatracker.ietf.org/doc/html/rfc2131#page-9 func EncodeFILE(d *dhcpv4.DHCPv4, namespace string) (attribute.KeyValue, error) { key := fmt.Sprintf("%v.%v.Header.file", keyNamespace, namespace) if d != nil && d.BootFileName != "" { return attribute.String(key, d.BootFileName), nil } return attribute.KeyValue{}, ¬FoundError{optName: key} } // TraceparentFromContext extracts the binary trace id, span id, and trace flags // from the running span in ctx and returns a 26 byte []byte with the traceparent // encoded and ready to pass into a suboption (most likely 69) of opt43. func TraceparentFromContext(ctx context.Context) []byte { sc := trace.SpanContextFromContext(ctx) tpBytes := make([]byte, 0, 26) // the otel spec says 16 bytes for trace id and 8 for spans are good enough // for everyone copy them into a []byte that we can deliver over option43 tid := [16]byte(sc.TraceID()) // type TraceID [16]byte sid := [8]byte(sc.SpanID()) // type SpanID [8]byte tpBytes = append(tpBytes, 0x00) // traceparent version tpBytes = append(tpBytes, tid[:]...) // trace id tpBytes = append(tpBytes, sid[:]...) // span id if sc.IsSampled() { tpBytes = append(tpBytes, 0x01) // trace flags } else { tpBytes = append(tpBytes, 0x00) } return tpBytes } ================================================ FILE: internal/dhcp/otel/otel_test.go ================================================ package otel import ( "bytes" "context" "net" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/iana" "github.com/insomniacslk/dhcp/rfc1035label" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) func TestEncode(t *testing.T) { tests := map[string]struct { allEncoders bool pkt *dhcpv4.DHCPv4 want []attribute.KeyValue }{ "no encoders": {pkt: &dhcpv4.DHCPv4{}, want: nil}, "all encoders": {allEncoders: true, pkt: &dhcpv4.DHCPv4{BootFileName: "ipxe.efi", Flags: 0}, want: []attribute.KeyValue{ {Key: attribute.Key("DHCP.test.Header.flags"), Value: attribute.StringValue("Unicast")}, {Key: attribute.Key("DHCP.test.Header.transactionID"), Value: attribute.StringValue("0x00000000")}, {Key: attribute.Key("DHCP.test.Header.file"), Value: attribute.StringValue("ipxe.efi")}, }}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { e := &Encoder{} got := e.Encode(tt.pkt, "test") if tt.allEncoders { got = e.Encode(tt.pkt, "test", AllEncoders()...) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Logf("%+v", got) t.Fatal(diff) } }) } } func TestEncodeError(t *testing.T) { tests := map[string]struct { input *notFoundError want string }{ "success": {input: ¬FoundError{optName: "opt1"}, want: "\"opt1\" not found in DHCP packet"}, "success nil error": {input: ¬FoundError{}, want: "\"\" not found in DHCP packet"}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := tt.input.Error() if diff := cmp.Diff(got, tt.want); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt1(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptSubnetMask(net.IPMask(net.IP{255, 255, 255, 0}.To4())), )}, want: attribute.String("DHCP.testing.Opt1.SubnetMask", "255.255.255.0"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt1(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt1() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt3(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptRouter([]net.IP{{192, 168, 1, 1}}...), )}, want: attribute.String("DHCP.testing.Opt3.DefaultGateway", "192.168.1.1"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt3(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt13() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt6(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptDNS([]net.IP{{1, 1, 1, 1}}...), )}, want: attribute.String("DHCP.testing.Opt6.NameServers", "1.1.1.1"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt6(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt6() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt12(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptHostName("test-host"), )}, want: attribute.String("DHCP.testing.Opt12.Hostname", "test-host"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt12(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt12() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt15(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptDomainName("example.com"), )}, want: attribute.String("DHCP.testing.Opt15.DomainName", "example.com"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt15(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt15() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt28(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptBroadcastAddress(net.IP{192, 168, 1, 255}), )}, want: attribute.String("DHCP.testing.Opt28.BroadcastAddress", "192.168.1.255"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt28(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt28() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt42(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptNTPServers([]net.IP{{132, 163, 96, 2}}...), )}, want: attribute.String("DHCP.testing.Opt42.NTPServers", "132.163.96.2"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt42(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt42() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt51(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptIPAddressLeaseTime(time.Minute), )}, want: attribute.String("DHCP.testing.Opt51.LeaseTime", "60"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt51(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt51() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt53(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptMessageType(dhcpv4.MessageTypeOffer), )}, want: attribute.String("DHCP.testing.Opt53.MessageType", "OFFER"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt53(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt53() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt54(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptServerIdentifier(net.IP{127, 0, 0, 1}), )}, want: attribute.String("DHCP.testing.Opt54.ServerIdentifier", "127.0.0.1"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt54(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt54() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt60(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptClassIdentifier("foobar"), )}, want: attribute.String("DHCP.testing.Opt60.ClassIdentifier", "foobar"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt60(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt60() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetOpt93(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptClientArch(iana.INTEL_X86PC), )}, want: attribute.StringSlice("DHCP.testing.Opt93.ClientIdentifier", []string{"Intel x86PC"}), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt93(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt93() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Log(tt.input.ClientArch()) t.Fatal(diff) } }) } } func TestSetOpt94(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptGeneric(dhcpv4.OptionClientNetworkInterfaceIdentifier, []byte{0x01, 0x02, 0x01}), )}, want: attribute.String("DHCP.testing.Opt94.ClientNetworkInterfaceIdentifier", "1.2.1"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt94(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt94() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Log(tt.input.ClientArch()) t.Fatal(diff) } }) } } func TestSetOpt97(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptGeneric(dhcpv4.OptionClientMachineIdentifier, []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}), )}, want: attribute.String("DHCP.testing.Opt97.ClientMachineIdentifier", "0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt97(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt97() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Log(tt.input.GetOneOption(dhcpv4.OptionClientMachineIdentifier)) t.Fatal(diff) } }) } } func TestSetOpt119(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{Options: dhcpv4.OptionsFromList( dhcpv4.OptDomainSearch(&rfc1035label.Labels{Labels: []string{"mydomain.com"}}), )}, want: attribute.String("DHCP.testing.Opt119.DomainSearch", "mydomain.com"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeOpt119(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setOpt119() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetHeaderFlags(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{}, want: attribute.String("DHCP.testing.Header.flags", "Unicast"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeFlags(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setHeaderFlags() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetHeaderTransactionID(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{TransactionID: dhcpv4.TransactionID{0x00, 0x00, 0x00, 0x00}}, want: attribute.String("DHCP.testing.Header.transactionID", "0x00000000"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeTransactionID(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("EncodeTransactionID() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetHeaderYIADDR(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{YourIPAddr: []byte{192, 168, 2, 100}}, want: attribute.String("DHCP.testing.Header.yiaddr", "192.168.2.100"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeYIADDR(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setHeaderYIADDR() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetHeaderSIADDR(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{ServerIPAddr: []byte{127, 0, 0, 1}}, want: attribute.String("DHCP.testing.Header.siaddr", "127.0.0.1"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeSIADDR(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setHeaderSIADDR() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetHeaderCHADDR(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{ClientHWAddr: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}}, want: attribute.String("DHCP.testing.Header.chaddr", "01:02:03:04:05:06"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeCHADDR(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setHeaderCHADDR() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestSetHeaderFILE(t *testing.T) { tests := map[string]struct { input *dhcpv4.DHCPv4 want attribute.KeyValue wantErr error }{ "success": { input: &dhcpv4.DHCPv4{BootFileName: "snp.efi"}, want: attribute.String("DHCP.testing.Header.file", "snp.efi"), }, "error": {wantErr: ¬FoundError{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeFILE(tt.input, "testing") if tt.wantErr != nil && !OptNotFound(err) { t.Fatalf("setHeaderFILE() error (type: %T) = %[1]v, wantErr (type: %T) %[2]v", err, tt.wantErr) } if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreUnexported(attribute.Value{})); diff != "" { t.Fatal(diff) } }) } } func TestTraceparentFromContext(t *testing.T) { want := []byte{0, 1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 6, 7, 8, 0, 0, 0, 0, 1} sc := trace.NewSpanContext(trace.SpanContextConfig{ TraceID: trace.TraceID{0x01, 0x02, 0x03, 0x04}, SpanID: trace.SpanID{0x05, 0x06, 0x07, 0x08}, TraceFlags: trace.TraceFlags(1), }) rmSpan := trace.ContextWithRemoteSpanContext(context.Background(), sc) got := TraceparentFromContext(rmSpan) if !bytes.Equal(got, want) { t.Errorf("binaryTpFromContext() = %v, want %v", got, want) } } ================================================ FILE: internal/dhcp/server/dhcp.go ================================================ // Package dhcp providers UDP listening and serving functionality. package server import ( "context" "net" "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4/server4" "github.com/tinkerbell/smee/internal/dhcp/data" "golang.org/x/net/ipv4" ) // Handler is a type that defines the handler function to be called every time a // valid DHCPv4 message is received // type Handler func(ctx context.Context, conn net.PacketConn, d data.Packet). type Handler interface { Handle(ctx context.Context, conn *ipv4.PacketConn, d data.Packet) } // DHCP represents a DHCPv4 server object. type DHCP struct { Conn net.PacketConn Handlers []Handler Logger logr.Logger } // Serve serves requests. func (s *DHCP) Serve(ctx context.Context) error { go func() { <-ctx.Done() _ = s.Close() }() s.Logger.Info("Server listening on", "addr", s.Conn.LocalAddr()) nConn := ipv4.NewPacketConn(s.Conn) if err := nConn.SetControlMessage(ipv4.FlagInterface, true); err != nil { s.Logger.Info("error setting control message", "err", err) return err } defer func() { _ = nConn.Close() }() for { // Max UDP packet size is 65535. Max DHCPv4 packet size is 576. An ethernet frame is 1500 bytes. // We use 4096 as a reasonable buffer size. dhcpv4.FromBytes will handle the rest. rbuf := make([]byte, 4096) n, cm, peer, err := nConn.ReadFrom(rbuf) if err != nil { select { case <-ctx.Done(): return nil default: } s.Logger.Info("error reading from packet conn", "err", err) return err } m, err := dhcpv4.FromBytes(rbuf[:n]) if err != nil { s.Logger.Info("error parsing DHCPv4 request", "err", err) continue } upeer, ok := peer.(*net.UDPAddr) if !ok { s.Logger.Info("not a UDP connection? Peer is", "peer", peer) continue } // Set peer to broadcast if the client did not have an IP. if upeer.IP == nil || upeer.IP.To4().Equal(net.IPv4zero) { upeer = &net.UDPAddr{ IP: net.IPv4bcast, Port: upeer.Port, } } var ifName string if n, err := net.InterfaceByIndex(cm.IfIndex); err == nil { ifName = n.Name } for _, handler := range s.Handlers { go handler.Handle(ctx, nConn, data.Packet{Peer: upeer, Pkt: m, Md: &data.Metadata{IfName: ifName, IfIndex: cm.IfIndex}}) } } } // Close sends a termination request to the server, and closes the UDP listener. func (s *DHCP) Close() error { return s.Conn.Close() } // NewServer initializes and returns a new Server object. func NewServer(ifname string, addr *net.UDPAddr, handler ...Handler) (*DHCP, error) { s := &DHCP{ Handlers: handler, Logger: logr.Discard(), } if s.Conn == nil { var err error conn, err := server4.NewIPv4UDPConn(ifname, addr) if err != nil { return nil, err } s.Conn = conn } return s, nil } ================================================ FILE: internal/dhcp/server/dhcp_test.go ================================================ package server import ( "context" "net" "net/netip" "testing" "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4/nclient4" "github.com/tinkerbell/smee/internal/dhcp/data" "golang.org/x/net/ipv4" "golang.org/x/net/nettest" ) type mock struct { Log logr.Logger ServerIP net.IP LeaseTime uint32 YourIP net.IP NameServers []net.IP SubnetMask net.IPMask Router net.IP } func (m *mock) Handle(_ context.Context, conn *ipv4.PacketConn, d data.Packet) { if m.Log.GetSink() == nil { m.Log = logr.Discard() } mods := m.setOpts() switch mt := d.Pkt.MessageType(); mt { case dhcpv4.MessageTypeDiscover: mods = append(mods, dhcpv4.WithMessageType(dhcpv4.MessageTypeOffer)) case dhcpv4.MessageTypeRequest: mods = append(mods, dhcpv4.WithMessageType(dhcpv4.MessageTypeAck)) case dhcpv4.MessageTypeRelease: mods = append(mods, dhcpv4.WithMessageType(dhcpv4.MessageTypeAck)) default: m.Log.Info("unsupported message type", "type", mt.String()) return } reply, err := dhcpv4.NewReplyFromRequest(d.Pkt, mods...) if err != nil { m.Log.Error(err, "error creating reply") return } cm := &ipv4.ControlMessage{IfIndex: d.Md.IfIndex} if _, err := conn.WriteTo(reply.ToBytes(), cm, d.Peer); err != nil { m.Log.Error(err, "failed to send reply") return } m.Log.Info("sent reply") } func (m *mock) setOpts() []dhcpv4.Modifier { mods := []dhcpv4.Modifier{ dhcpv4.WithGeneric(dhcpv4.OptionServerIdentifier, m.ServerIP), dhcpv4.WithServerIP(m.ServerIP), dhcpv4.WithLeaseTime(m.LeaseTime), dhcpv4.WithYourIP(m.YourIP), dhcpv4.WithDNS(m.NameServers...), dhcpv4.WithNetmask(m.SubnetMask), dhcpv4.WithRouter(m.Router), } return mods } func dhcp(ctx context.Context) (*dhcpv4.DHCPv4, error) { rifs, err := nettest.RoutedInterface("ip", net.FlagUp|net.FlagBroadcast) if err != nil { return nil, err } c, err := nclient4.New(rifs.Name, nclient4.WithServerAddr(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 7676}), nclient4.WithUnicast(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 7677}), ) if err != nil { return nil, err } defer c.Close() return c.DiscoverOffer(ctx) } func TestServe(t *testing.T) { tests := map[string]struct { h Handler addr netip.AddrPort }{ "success": {addr: netip.MustParseAddrPort("127.0.0.1:7676"), h: &mock{}}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { s, err := NewServer("lo", net.UDPAddrFromAddrPort(tt.addr), tt.h) if err != nil { t.Fatal(err) } ctx, done := context.WithCancel(context.Background()) defer done() go s.Serve(ctx) // make client calls d, err := dhcp(ctx) if err != nil { t.Fatal(err) } t.Log(d) done() }) } } ================================================ FILE: internal/ipxe/http/http.go ================================================ // package bhttp is the http server for smee. package http import ( "context" "encoding/json" "errors" "fmt" "net/http" "runtime" "time" "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus/promhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // Config is the configuration for the http server. type Config struct { GitRev string StartTime time.Time Logger logr.Logger TrustedProxies []string } // HandlerMapping is a map of routes to http.HandlerFuncs. type HandlerMapping map[string]http.HandlerFunc // ServeHTTP sets up all the HTTP routes using a stdlib mux and starts the http // server, which will block. App functionality is instrumented in Prometheus and OpenTelemetry. func (s *Config) ServeHTTP(ctx context.Context, addr string, handlers HandlerMapping) error { mux := http.NewServeMux() for pattern, handler := range handlers { mux.Handle(otelFuncWrapper(pattern, handler)) } mux.Handle("/metrics", promhttp.Handler()) mux.HandleFunc("/healthcheck", s.serveHealthchecker(s.GitRev, s.StartTime)) // wrap the mux with an OpenTelemetry interceptor otelHandler := otelhttp.NewHandler(mux, "smee-http") // add X-Forwarded-For support if trusted proxies are configured var xffHandler http.Handler if len(s.TrustedProxies) > 0 { xffmw, err := newXFF(xffOptions{ AllowedSubnets: s.TrustedProxies, }) if err != nil { s.Logger.Error(err, "failed to create new xff object") panic(fmt.Errorf("failed to create new xff object: %v", err)) } xffHandler = xffmw.Handler(&loggingMiddleware{ handler: otelHandler, log: s.Logger, }) } else { xffHandler = &loggingMiddleware{ handler: otelHandler, log: s.Logger, } } server := http.Server{ Addr: addr, Handler: xffHandler, // Mitigate Slowloris attacks. 30 seconds is based on Apache's recommended 20-40 // recommendation. Smee doesn't really have many headers so 20s should be plenty of time. // https://en.wikipedia.org/wiki/Slowloris_(computer_security) ReadHeaderTimeout: 20 * time.Second, } go func() { <-ctx.Done() s.Logger.Info("shutting down http server") _ = server.Shutdown(ctx) }() if err := server.ListenAndServe(); err != nil { if errors.Is(err, http.ErrServerClosed) { return nil } s.Logger.Error(err, "listen and serve http") return err } return nil } func (s *Config) serveHealthchecker(rev string, start time.Time) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") res := struct { GitRev string `json:"git_rev"` Uptime float64 `json:"uptime"` Goroutines int `json:"goroutines"` }{ GitRev: rev, Uptime: time.Since(start).Seconds(), Goroutines: runtime.NumGoroutine(), } if err := json.NewEncoder(w).Encode(&res); err != nil { w.WriteHeader(http.StatusInternalServerError) s.Logger.Error(err, "marshaling healthcheck json") } } } // otelFuncWrapper takes a route and an http handler function, wraps the function // with otelhttp, and returns the route again and http.Handler all set for mux.Handle(). func otelFuncWrapper(route string, h func(w http.ResponseWriter, req *http.Request)) (string, http.Handler) { return route, otelhttp.WithRouteTag(route, http.HandlerFunc(h)) } ================================================ FILE: internal/ipxe/http/middleware.go ================================================ package http import ( "fmt" "net" "net/http" "time" "github.com/go-logr/logr" ) type loggingMiddleware struct { handler http.Handler log logr.Logger } // ServeHTTP implements http.Handler and add logging before and after the request. func (h *loggingMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) { var ( start = time.Now() method = req.Method uri = req.RequestURI client = clientIP(req.RemoteAddr) ) log := uri != "/metrics" res := &responseWriter{ResponseWriter: w} h.handler.ServeHTTP(res, req) // process the request // The "X-Global-Logging" header allows all registered HTTP handlers to disable this global logging // by setting the header to any non empty string. This is useful for handlers that handle partial content of // larger file. The ISO handler, for example. r := res.Header().Get("X-Global-Logging") if log && r == "" { h.log.Info("response", "method", method, "uri", uri, "client", client, "duration", time.Since(start), "status", res.statusCode) } } type responseWriter struct { http.ResponseWriter statusCode int } func (w *responseWriter) Write(b []byte) (int, error) { if w.statusCode == 0 { w.statusCode = 200 } n, err := w.ResponseWriter.Write(b) if err != nil { return 0, fmt.Errorf("failed writing response: %w", err) } return n, nil } func (w *responseWriter) WriteHeader(code int) { if w.statusCode == 0 { w.statusCode = code } w.ResponseWriter.WriteHeader(code) } func clientIP(str string) string { host, _, err := net.SplitHostPort(str) if err != nil { return "?" } return host } ================================================ FILE: internal/ipxe/http/xff.go ================================================ /* https://github.com/sebest/xff Copyright (c) 2015 Sebastien Estienne (sebastien.estienne@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package http import ( "net" "net/http" "strings" ) // xffOptions is a configuration container to setup the XFF middleware. type xffOptions struct { // AllowedSubnets is a list of Subnets from which we will accept the // X-Forwarded-For header. // If this list is empty we will accept every Subnets (default). AllowedSubnets []string // Debugging flag adds additional output to debug server side XFF issues. Debug bool } // xff http handler. type xff struct { // Set to true if all IPs or Subnets are allowed. allowAll bool // List of IP subnets that are allowed. allowedMasks []net.IPNet } // New creates a new XFF handler with the provided options. func newXFF(options xffOptions) (*xff, error) { allowedMasks, err := toMasks(options.AllowedSubnets) if err != nil { return nil, err } xff := &xff{ allowAll: len(options.AllowedSubnets) == 0, allowedMasks: allowedMasks, } return xff, nil } // Handler updates RemoteAdd from X-Fowarded-For Headers. func (xff *xff) Handler(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.RemoteAddr = getRemoteAddrIfAllowed(r, xff.allowed) h.ServeHTTP(w, r) }) } // getRemoteAddrIfAllowed parses the given request, resolves the X-Forwarded-For header // and returns the resolved remote address if allowed. func getRemoteAddrIfAllowed(r *http.Request, allowed func(sip string) bool) string { if xffh := r.Header.Get("X-Forwarded-For"); xffh != "" { if sip, sport, err := net.SplitHostPort(r.RemoteAddr); err == nil && sip != "" { if allowed(sip) { if xip := parse(xffh, allowed); xip != "" { return net.JoinHostPort(xip, sport) } } } } return r.RemoteAddr } // parse parses the value of the X-Forwarded-For Header and returns the IP address. func parse(ipList string, allowed func(string) bool) string { ips := strings.Split(ipList, ",") if len(ips) == 0 { return "" } // simple case of only 1 proxy if len(ips) == 1 { ip := strings.TrimSpace(ips[0]) if net.ParseIP(ip) != nil { return ip } return "" } // multiple proxies // common form of X-F-F is: client, proxy1, proxy2, ... proxyN-1 // so we verify backwards and return the first unallowed/untrusted proxy lastIP := "" for i := len(ips) - 1; i >= 0; i-- { ip := strings.TrimSpace(ips[i]) if net.ParseIP(ip) == nil { break } lastIP = ip if !allowed(ip) { break } } return lastIP } // converts a list of subnets' string to a list of net.IPNet. func toMasks(ips []string) (masks []net.IPNet, err error) { for _, cidr := range ips { var network *net.IPNet _, network, err = net.ParseCIDR(cidr) if err != nil { return } masks = append(masks, *network) } return } // checks that the IP is allowed. func (xff *xff) allowed(sip string) bool { if xff.allowAll { return true } else if ip := net.ParseIP(sip); ip != nil && ipInMasks(ip, xff.allowedMasks) { return true } return false } // checks if a net.IP is in a list of net.IPNet. func ipInMasks(ip net.IP, masks []net.IPNet) bool { for _, mask := range masks { if mask.Contains(ip) { return true } } return false } ================================================ FILE: internal/ipxe/http/xff_test.go ================================================ /* https://github.com/sebest/xff Copyright (c) 2015 Sebastien Estienne (sebastien.estienne@gmail.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package http import ( "net" "testing" "github.com/stretchr/testify/assert" ) func TestParse_none(t *testing.T) { res := parse("", nil) assert.Equal(t, "", res) } func allowAll(string) bool { return true } func TestParse_localhost(t *testing.T) { res := parse("127.0.0.1", allowAll) assert.Equal(t, "127.0.0.1", res) } func TestParse_invalid(t *testing.T) { res := parse("invalid", allowAll) assert.Equal(t, "", res) } func TestParse_invalid_sioux(t *testing.T) { res := parse("123#1#2#3", allowAll) assert.Equal(t, "", res) } func TestParse_invalid_private_lookalike(t *testing.T) { res := parse("102.3.2.1", allowAll) assert.Equal(t, "102.3.2.1", res) } func TestParse_valid(t *testing.T) { res := parse("68.45.152.220", allowAll) assert.Equal(t, "68.45.152.220", res) } func TestParse_multi_first(t *testing.T) { res := parse("12.13.14.15, 68.45.152.220", allowAll) assert.Equal(t, "12.13.14.15", res) } func TestParse_multi_with_invalid(t *testing.T) { res := parse("invalid, 190.57.149.90", allowAll) assert.Equal(t, "190.57.149.90", res) } func TestParse_multi_with_invalid2(t *testing.T) { res := parse("190.57.149.90, invalid", allowAll) assert.Equal(t, "", res) } func TestParse_multi_with_invalid_sioux(t *testing.T) { res := parse("190.57.149.90, 123#1#2#3", allowAll) assert.Equal(t, "", res) } func TestParse_ipv6_with_port(t *testing.T) { res := parse("2604:2000:71a9:bf00:f178:a500:9a2d:670d", allowAll) assert.Equal(t, "2604:2000:71a9:bf00:f178:a500:9a2d:670d", res) } func TestToMasks_empty(t *testing.T) { ips := []string{} masks, err := toMasks(ips) assert.Empty(t, masks) assert.Nil(t, err) } func TestToMasks(t *testing.T) { ips := []string{"127.0.0.1/32", "10.0.0.0/8"} masks, err := toMasks(ips) _, ipnet1, _ := net.ParseCIDR("127.0.0.1/32") _, ipnet2, _ := net.ParseCIDR("10.0.0.0/8") assert.Equal(t, []net.IPNet{*ipnet1, *ipnet2}, masks) assert.Nil(t, err) } func TestToMasks_error(t *testing.T) { ips := []string{"error"} masks, err := toMasks(ips) assert.Empty(t, masks) assert.Equal(t, &net.ParseError{Type: "CIDR address", Text: "error"}, err) } func TestAllowed_all(t *testing.T) { m, _ := newXFF(xffOptions{ AllowedSubnets: []string{}, }) assert.True(t, m.allowed("127.0.0.1")) } func TestAllowed_yes(t *testing.T) { m, _ := newXFF(xffOptions{ AllowedSubnets: []string{"127.0.0.0/16"}, }) assert.True(t, m.allowed("127.0.0.1")) m, _ = newXFF(xffOptions{ AllowedSubnets: []string{"127.0.0.1/32"}, }) assert.True(t, m.allowed("127.0.0.1")) } func TestAllowed_no(t *testing.T) { m, _ := newXFF(xffOptions{ AllowedSubnets: []string{"127.0.0.0/16"}, }) assert.False(t, m.allowed("127.1.0.1")) m, _ = newXFF(xffOptions{ AllowedSubnets: []string{"127.0.0.1/32"}, }) assert.False(t, m.allowed("127.0.0.2")) } func TestParseUnallowedMidway(t *testing.T) { m, _ := newXFF(xffOptions{ AllowedSubnets: []string{"127.0.0.0/16"}, }) res := parse("1.1.1.1, 8.8.8.8, 127.0.0.1, 127.0.0.2", m.allowed) assert.Equal(t, "8.8.8.8", res) } func TestParseMany(t *testing.T) { m, _ := newXFF(xffOptions{ AllowedSubnets: []string{"127.0.0.0/16"}, }) res := parse("1.1.1.1, 127.0.0.1, 127.0.0.2, 127.0.0.3", m.allowed) assert.Equal(t, "1.1.1.1", res) } ================================================ FILE: internal/ipxe/script/auto.go ================================================ package script import ( "bytes" "text/template" ) func GenerateTemplate(d any, script string) (string, error) { t := template.New("auto.ipxe") t, err := t.Parse(script) if err != nil { return "", err } buffer := new(bytes.Buffer) if err := t.Execute(buffer, d); err != nil { return "", err } return buffer.String(), nil } ================================================ FILE: internal/ipxe/script/auto_test.go ================================================ package script import ( "testing" "github.com/google/go-cmp/cmp" ) func TestGenerateTemplate(t *testing.T) { tests := map[string]struct { h Hook script string want string wantErr bool }{ "no vlan": { h: Hook{ Arch: "x86_64", TinkGRPCAuthority: "1.2.3.4:42113", TinkerbellTLS: false, WorkerID: "3c:ec:ef:4c:4f:54", SyslogHost: "1.2.3.4", DownloadURL: "http://location:8080/to/kernel/and/initrd", Facility: "onprem", ExtraKernelParams: []string{"tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0", "tinkerbell=packet"}, HWAddr: "3c:ec:ef:4c:4f:54", Retries: 10, RetryDelay: 3, }, script: HookScript, want: `#!ipxe echo Loading the Tinkerbell Hook iPXE script... set arch x86_64 set download-url http://location:8080/to/kernel/and/initrd set kernel vmlinuz-${arch} set initrd initramfs-${arch} set retries:int32 10 set retry_delay:int32 3 set idx:int32 0 :retry_kernel kernel ${download-url}/${kernel} tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0 tinkerbell=packet \ facility=onprem syslog_host=1.2.3.4 grpc_authority=1.2.3.4:42113 tinkerbell_tls=false tinkerbell_insecure_tls=false worker_id=3c:ec:ef:4c:4f:54 hw_addr=3c:ec:ef:4c:4f:54 \ modules=loop,squashfs,sd-mod,usb-storage intel_iommu=on iommu=pt initrd=initramfs-${arch} console=tty0 console=ttyS1,115200 && goto download_initrd || iseq ${idx} ${retries} && goto kernel-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_kernel :download_initrd set idx:int32 0 :retry_initrd initrd ${download-url}/${initrd} && goto boot || iseq ${idx} ${retries} && goto initrd-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_initrd :boot set idx:int32 0 :retry_boot boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot :kernel-error echo Failed to load kernel imgfree exit :initrd-error echo Failed to load initrd imgfree exit :boot-error echo Failed to boot imgfree exit `, }, "with vlan": { h: Hook{ Arch: "x86_64", TinkGRPCAuthority: "1.2.3.4:42113", TinkerbellTLS: false, WorkerID: "3c:ec:ef:4c:4f:54", SyslogHost: "1.2.3.4", DownloadURL: "http://location:8080/to/kernel/and/initrd", Facility: "onprem", ExtraKernelParams: []string{"tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0", "tinkerbell=packet"}, HWAddr: "3c:ec:ef:4c:4f:54", VLANID: "16", Retries: 10, RetryDelay: 3, }, script: HookScript, want: `#!ipxe echo Loading the Tinkerbell Hook iPXE script... set arch x86_64 set download-url http://location:8080/to/kernel/and/initrd set kernel vmlinuz-${arch} set initrd initramfs-${arch} set retries:int32 10 set retry_delay:int32 3 set idx:int32 0 :retry_kernel kernel ${download-url}/${kernel} vlan_id=16 tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0 tinkerbell=packet \ facility=onprem syslog_host=1.2.3.4 grpc_authority=1.2.3.4:42113 tinkerbell_tls=false tinkerbell_insecure_tls=false worker_id=3c:ec:ef:4c:4f:54 hw_addr=3c:ec:ef:4c:4f:54 \ modules=loop,squashfs,sd-mod,usb-storage intel_iommu=on iommu=pt initrd=initramfs-${arch} console=tty0 console=ttyS1,115200 && goto download_initrd || iseq ${idx} ${retries} && goto kernel-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_kernel :download_initrd set idx:int32 0 :retry_initrd initrd ${download-url}/${initrd} && goto boot || iseq ${idx} ${retries} && goto initrd-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_initrd :boot set idx:int32 0 :retry_boot boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot :kernel-error echo Failed to load kernel imgfree exit :initrd-error echo Failed to load initrd imgfree exit :boot-error echo Failed to boot imgfree exit `, }, "parse error": { h: Hook{}, script: "bad {{ }", wantErr: true, }, "execute error": { h: Hook{}, script: "{{ .A }}", wantErr: true, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := GenerateTemplate(tt.h, tt.script) if (err != nil) != tt.wantErr { t.Errorf("Auto.autoDotIPXE() error = %v, wantErr %v", err, tt.wantErr) return } if diff := cmp.Diff(got, tt.want); diff != "" { t.Errorf("Auto.autoDotIPXE() mismatch (-want +got):\n%s", diff) } }) } } ================================================ FILE: internal/ipxe/script/custom.go ================================================ package script import "net/url" // CustomScript is the template for the custom script. // It will either chain to a URL or execute an iPXE script. var CustomScript = `#!ipxe echo Loading custom Tinkerbell iPXE script... {{- if .Chain }} chain --autofree {{ .Chain }} {{- else }} {{ .Script }} {{- end }} ` // Custom holds either a URL to chain to or a script to execute. // There is no validation of the script. type Custom struct { Chain *url.URL Script string } ================================================ FILE: internal/ipxe/script/hook.go ================================================ package script // HookScript is the default iPXE script for loading Hook. var HookScript = `#!ipxe echo Loading the Tinkerbell Hook iPXE script... {{- if .TraceID }} echo Debug TraceID: {{ .TraceID }} {{- end }} set arch {{ .Arch }} set download-url {{ .DownloadURL }} set kernel {{ if .Kernel }}{{ .Kernel }}{{ else }}vmlinuz-${arch}{{ end }} set initrd {{ if .Initrd }}{{ .Initrd }}{{ else }}initramfs-${arch}{{ end }} set retries:int32 {{ .Retries }} set retry_delay:int32 {{ .RetryDelay }} set idx:int32 0 :retry_kernel kernel ${download-url}/${kernel} {{- if ne .VLANID "" }} vlan_id={{ .VLANID }} {{- end }} {{- range .ExtraKernelParams}} {{.}} {{- end}} \ facility={{ .Facility }} syslog_host={{ .SyslogHost }} grpc_authority={{ .TinkGRPCAuthority }} tinkerbell_tls={{ .TinkerbellTLS }} tinkerbell_insecure_tls={{ .TinkerbellInsecureTLS }} worker_id={{ .WorkerID }} hw_addr={{ .HWAddr }} \ modules=loop,squashfs,sd-mod,usb-storage intel_iommu=on iommu=pt initrd=initramfs-${arch} console=tty0 console=ttyS1,115200 && goto download_initrd || iseq ${idx} ${retries} && goto kernel-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_kernel :download_initrd set idx:int32 0 :retry_initrd initrd ${download-url}/${initrd} && goto boot || iseq ${idx} ${retries} && goto initrd-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_initrd :boot set idx:int32 0 :retry_boot boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot :kernel-error echo Failed to load kernel imgfree exit :initrd-error echo Failed to load initrd imgfree exit :boot-error echo Failed to boot imgfree exit ` // Hook holds the values used to generate the iPXE script that loads the Hook OS. type Hook struct { Arch string // example x86_64 Console string // example ttyS1,115200 DownloadURL string // example https://location:8080/to/kernel/and/initrd ExtraKernelParams []string // example tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0 Facility string HWAddr string // example 3c:ec:ef:4c:4f:54 SyslogHost string TinkerbellTLS bool TinkerbellInsecureTLS bool TinkGRPCAuthority string // example 192.168.2.111:42113 TraceID string VLANID string // string number between 1-4095 WorkerID string // example 3c:ec:ef:4c:4f:54 or worker1 Retries int // number of retries to attempt when fetching kernel and initrd files RetryDelay int // number of seconds to wait between retries Kernel string // name of the kernel file Initrd string // name of the initrd file } ================================================ FILE: internal/ipxe/script/ipxe.go ================================================ package script import ( "context" "errors" "fmt" "net" "net/http" "net/url" "path" "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus" "github.com/tinkerbell/smee/internal/dhcp/handler" "github.com/tinkerbell/smee/internal/metric" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" ) type Handler struct { Logger logr.Logger Backend handler.BackendReader OSIEURL string ExtraKernelParams []string PublicSyslogFQDN string TinkServerTLS bool TinkServerInsecureTLS bool TinkServerGRPCAddr string IPXEScriptRetries int IPXEScriptRetryDelay int StaticIPXEEnabled bool } type data struct { AllowNetboot bool // If true, the client will be provided netboot options in the DHCP offer/ack. Console string MACAddress net.HardwareAddr Arch string VLANID string WorkflowID string Facility string IPXEScript string IPXEScriptURL *url.URL OSIE OSIE } // OSIE or OS Installation Environment is the data about where the OSIE parts are located. type OSIE struct { // BaseURL is the URL where the OSIE parts are located. BaseURL *url.URL // Kernel is the name of the kernel file. Kernel string // Initrd is the name of the initrd file. Initrd string } // getByMac uses the handler.BackendReader to get the (hardware) data and then // translates it to the script.Data struct. func getByMac(ctx context.Context, mac net.HardwareAddr, br handler.BackendReader) (data, error) { if br == nil { return data{}, errors.New("backend is nil") } d, n, err := br.GetByMac(ctx, mac) if err != nil { return data{}, err } return data{ AllowNetboot: n.AllowNetboot, Console: "", MACAddress: d.MACAddress, Arch: d.Arch, VLANID: d.VLANID, WorkflowID: d.MACAddress.String(), Facility: n.Facility, IPXEScript: n.IPXEScript, IPXEScriptURL: n.IPXEScriptURL, OSIE: OSIE(n.OSIE), }, nil } func getByIP(ctx context.Context, ip net.IP, br handler.BackendReader) (data, error) { if br == nil { return data{}, errors.New("backend is nil") } d, n, err := br.GetByIP(ctx, ip) if err != nil { return data{}, err } return data{ AllowNetboot: n.AllowNetboot, Console: "", MACAddress: d.MACAddress, Arch: d.Arch, VLANID: d.VLANID, WorkflowID: d.MACAddress.String(), Facility: n.Facility, IPXEScript: n.IPXEScript, IPXEScriptURL: n.IPXEScriptURL, OSIE: OSIE(n.OSIE), }, nil } // HandlerFunc returns a http.HandlerFunc that serves the ipxe script. // It is expected that the request path is //auto.ipxe. func (h *Handler) HandlerFunc() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if path.Base(r.URL.Path) != "auto.ipxe" { h.Logger.Info("URL path not supported", "path", r.URL.Path) w.WriteHeader(http.StatusNotFound) return } labels := prometheus.Labels{"from": "http", "op": "file"} metric.JobsTotal.With(labels).Inc() metric.JobsInProgress.With(labels).Inc() defer metric.JobsInProgress.With(labels).Dec() timer := prometheus.NewTimer(metric.JobDuration.With(labels)) defer timer.ObserveDuration() ctx := r.Context() // Should we serve a custom ipxe script? // This gates serving PXE file by // 1. the existence of a hardware record in tink server // AND // 2. the network.interfaces[].netboot.allow_pxe value, in the tink server hardware record, equal to true // This allows serving custom ipxe scripts, starting up into OSIE or other installation environments // without a tink workflow present. // Try to get the MAC address from the URL path, if not available get the source IP address. if ha, err := getMAC(r.URL.Path); err == nil { hw, err := getByMac(ctx, ha, h.Backend) if err != nil && h.StaticIPXEEnabled { h.Logger.Info("serving static ipxe script", "mac", ha, "error", err) h.serveStaticIPXEScript(w) return } if err != nil || !hw.AllowNetboot { w.WriteHeader(http.StatusNotFound) h.Logger.Info("the hardware data for this machine, or lack there of, does not allow it to pxe", "client", ha, "error", err) return } h.serveBootScript(ctx, w, path.Base(r.URL.Path), hw) return } if ip, err := getIP(r.RemoteAddr); err == nil { hw, err := getByIP(ctx, ip, h.Backend) if err != nil && h.StaticIPXEEnabled { h.Logger.Info("serving static ipxe script", "client", r.RemoteAddr, "error", err) h.serveStaticIPXEScript(w) return } if err != nil || !hw.AllowNetboot { w.WriteHeader(http.StatusNotFound) h.Logger.Info("the hardware data for this machine, or lack there of, does not allow it to pxe", "client", r.RemoteAddr, "error", err) return } h.serveBootScript(ctx, w, path.Base(r.URL.Path), hw) return } // If we get here, we were unable to get the MAC address from the URL path or the source IP address. w.WriteHeader(http.StatusNotFound) h.Logger.Info("unable to get the MAC address from the URL path or the source IP address", "client", r.RemoteAddr, "urlPath", r.URL.Path) } } func (h *Handler) serveStaticIPXEScript(w http.ResponseWriter) { // Serve static iPXE script. auto := Hook{ DownloadURL: h.OSIEURL, ExtraKernelParams: h.ExtraKernelParams, SyslogHost: h.PublicSyslogFQDN, TinkerbellTLS: h.TinkServerTLS, TinkGRPCAuthority: h.TinkServerGRPCAddr, } script, err := GenerateTemplate(auto, StaticScript) if err != nil { w.WriteHeader(http.StatusInternalServerError) h.Logger.Error(err, "error generating the static ipxe script") return } if _, err := w.Write([]byte(script)); err != nil { h.Logger.Error(err, "unable to send the static ipxe script") w.WriteHeader(http.StatusInternalServerError) return } } func getIP(remoteAddr string) (net.IP, error) { host, _, err := net.SplitHostPort(remoteAddr) if err != nil { return net.IP{}, fmt.Errorf("error parsing client address: %w: client: %v", err, remoteAddr) } ip := net.ParseIP(host) return ip, nil } func getMAC(urlPath string) (net.HardwareAddr, error) { mac := path.Base(path.Dir(urlPath)) ha, err := net.ParseMAC(mac) if err != nil { return net.HardwareAddr{}, fmt.Errorf("URL path not supported, the second to last element in the URL path must be a valid mac address, err: %w", err) } return ha, nil } func (h *Handler) serveBootScript(ctx context.Context, w http.ResponseWriter, name string, hw data) { span := trace.SpanFromContext(ctx) span.SetAttributes(attribute.String("smee.script_name", name)) var script []byte // check if the custom script should be used if hw.IPXEScriptURL != nil || hw.IPXEScript != "" { name = "custom.ipxe" } switch name { case "auto.ipxe": s, err := h.defaultScript(span, hw) if err != nil { w.WriteHeader(http.StatusInternalServerError) h.Logger.Error(err, "error with default ipxe script", "script", name) span.SetStatus(codes.Error, err.Error()) return } script = []byte(s) case "custom.ipxe": cs, err := h.customScript(hw) if err != nil { w.WriteHeader(http.StatusInternalServerError) h.Logger.Error(err, "error with custom ipxe script", "script", name) span.SetStatus(codes.Error, err.Error()) return } script = []byte(cs) default: w.WriteHeader(http.StatusNotFound) err := fmt.Errorf("boot script %q not found", name) h.Logger.Error(err, "boot script not found", "script", name) span.SetStatus(codes.Error, err.Error()) return } span.SetAttributes(attribute.String("ipxe-script", string(script))) if _, err := w.Write(script); err != nil { h.Logger.Error(err, "unable to write boot script", "script", name) span.SetStatus(codes.Error, err.Error()) return } } func (h *Handler) defaultScript(span trace.Span, hw data) (string, error) { mac := hw.MACAddress arch := hw.Arch if arch == "" { arch = "x86_64" } // The worker ID will default to the mac address or use the one specified. wID := mac.String() if hw.WorkflowID != "" { wID = hw.WorkflowID } auto := Hook{ Arch: arch, Console: "", DownloadURL: h.OSIEURL, ExtraKernelParams: h.ExtraKernelParams, Facility: hw.Facility, HWAddr: mac.String(), SyslogHost: h.PublicSyslogFQDN, TinkerbellTLS: h.TinkServerTLS, TinkerbellInsecureTLS: h.TinkServerInsecureTLS, TinkGRPCAuthority: h.TinkServerGRPCAddr, VLANID: hw.VLANID, WorkerID: wID, Retries: h.IPXEScriptRetries, RetryDelay: h.IPXEScriptRetryDelay, } if hw.OSIE.BaseURL != nil && hw.OSIE.BaseURL.String() != "" { auto.DownloadURL = hw.OSIE.BaseURL.String() } if hw.OSIE.Kernel != "" { auto.Kernel = hw.OSIE.Kernel } if hw.OSIE.Initrd != "" { auto.Initrd = hw.OSIE.Initrd } if sc := span.SpanContext(); sc.IsSampled() { auto.TraceID = sc.TraceID().String() } return GenerateTemplate(auto, HookScript) } // customScript returns the custom script or chain URL if defined in the hardware data otherwise an error. func (h *Handler) customScript(hw data) (string, error) { if chain := hw.IPXEScriptURL; chain != nil && chain.String() != "" { if chain.Scheme != "http" && chain.Scheme != "https" { return "", fmt.Errorf("invalid URL scheme: %v", chain.Scheme) } c := Custom{Chain: chain} return GenerateTemplate(c, CustomScript) } if script := hw.IPXEScript; script != "" { c := Custom{Script: script} return GenerateTemplate(c, CustomScript) } return "", errors.New("no custom script or chain defined in the hardware data") } ================================================ FILE: internal/ipxe/script/ipxe_test.go ================================================ package script import ( "context" "net" "net/http/httptest" "net/url" "testing" "github.com/google/go-cmp/cmp" "github.com/tinkerbell/smee/internal/metric" "go.opentelemetry.io/otel/trace" ) func TestCustomScript(t *testing.T) { tests := map[string]struct { ipxeURL string ipxeScript string want string shouldErr bool }{ "got script": {want: "#!ipxe\n\necho Loading custom Tinkerbell iPXE script...\n#!ipxe\nautoboot\n", ipxeScript: "#!ipxe\nautoboot"}, "got url": {want: "#!ipxe\n\necho Loading custom Tinkerbell iPXE script...\nchain --autofree https://boot.netboot.xyz\n", ipxeURL: "https://boot.netboot.xyz"}, "invalid URL prefix": {want: "", ipxeURL: "invalid", shouldErr: true}, "invalid URL": {want: "", ipxeURL: "http://invalid.:123.com", shouldErr: true}, "no script or url": {want: "", shouldErr: true}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { h := &Handler{} u, err := url.Parse(tt.ipxeURL) if err != nil && !tt.shouldErr { t.Fatal(err) } d := data{MACAddress: net.HardwareAddr{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, IPXEScript: tt.ipxeScript, IPXEScriptURL: u} got, err := h.customScript(d) if err != nil && !tt.shouldErr { t.Fatal(err) } if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatal(diff) } }) } } func TestDefaultScript(t *testing.T) { one := `#!ipxe echo Loading the Tinkerbell Hook iPXE script... set arch x86_64 set download-url http://127.1.1.1 set kernel vmlinuz-${arch} set initrd initramfs-${arch} set retries:int32 10 set retry_delay:int32 3 set idx:int32 0 :retry_kernel kernel ${download-url}/${kernel} vlan_id=1234 \ facility=onprem syslog_host= grpc_authority= tinkerbell_tls=false tinkerbell_insecure_tls=false worker_id=00:01:02:03:04:05 hw_addr=00:01:02:03:04:05 \ modules=loop,squashfs,sd-mod,usb-storage intel_iommu=on iommu=pt initrd=initramfs-${arch} console=tty0 console=ttyS1,115200 && goto download_initrd || iseq ${idx} ${retries} && goto kernel-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_kernel :download_initrd set idx:int32 0 :retry_initrd initrd ${download-url}/${initrd} && goto boot || iseq ${idx} ${retries} && goto initrd-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_initrd :boot set idx:int32 0 :retry_boot boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot :kernel-error echo Failed to load kernel imgfree exit :initrd-error echo Failed to load initrd imgfree exit :boot-error echo Failed to boot imgfree exit ` tests := map[string]struct { want string }{ "success with defaults": {want: one}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { h := &Handler{ OSIEURL: "http://127.1.1.1", IPXEScriptRetries: 10, IPXEScriptRetryDelay: 3, } d := data{MACAddress: net.HardwareAddr{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, VLANID: "1234", Facility: "onprem", Arch: "x86_64"} sp := trace.SpanFromContext(context.Background()) got, err := h.defaultScript(sp, d) if err != nil { t.Fatal(err) } if diff := cmp.Diff(tt.want, got); diff != "" { t.Log(got) t.Fatal(diff) } }) } } func TestStaticScript(t *testing.T) { want := `#!ipxe echo Loading the static Tinkerbell iPXE script... set arch ${buildarch} # Tinkerbell only supports 64 bit archectures. # The build architecture does not necessarily represent the architecture of the machine on which iPXE is running. # https://ipxe.org/cfg/buildarch iseq ${arch} i386 && set arch x86_64 || iseq ${arch} arm32 && set arch aarch64 || iseq ${arch} arm64 && set arch aarch64 || set download-url http://127.0.0.1 set retries:int32 0 set retry_delay:int32 0 set worker_id ${mac} set grpc_authority 127.0.0.1:42113 set syslog_host 127.1.1.1 set tinkerbell_tls false echo worker_id=${mac} echo grpc_authority=127.0.0.1:42113 echo syslog_host=127.1.1.1 echo tinkerbell_tls=false set idx:int32 0 :retry_kernel kernel ${download-url}/vmlinuz-${arch} \ syslog_host=${syslog_host} grpc_authority=${grpc_authority} tinkerbell_tls=${tinkerbell_tls} worker_id=${worker_id} hw_addr=${mac} \ console=tty1 console=tty2 console=ttyAMA0,115200 console=ttyAMA1,115200 console=ttyS0,115200 console=ttyS1,115200 k=v k2=v2 \ intel_iommu=on iommu=pt k=v k2=v2 initrd=initramfs-${arch} && goto download_initrd || iseq ${idx} ${retries} && goto kernel-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_kernel :download_initrd set idx:int32 0 :retry_initrd initrd ${download-url}/initramfs-${arch} && goto boot || iseq ${idx} ${retries} && goto initrd-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_initrd :boot set idx:int32 0 :retry_boot boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot :kernel-error echo Failed to load kernel imgfree exit :initrd-error echo Failed to load initrd imgfree exit :boot-error echo Failed to boot imgfree exit ` metric.Init() h := &Handler{ OSIEURL: "http://127.0.0.1", ExtraKernelParams: []string{"k=v", "k2=v2"}, PublicSyslogFQDN: "127.1.1.1", TinkServerTLS: false, TinkServerGRPCAddr: "127.0.0.1:42113", StaticIPXEEnabled: true, } hf := h.HandlerFunc() writer := httptest.NewRecorder() req := httptest.NewRequest("GET", "/auto.ipxe", nil) hf(writer, req) if writer.Code != 200 { t.Errorf("expected status code 200, got %d", writer.Code) } if diff := cmp.Diff(writer.Body.String(), want); diff != "" { t.Fatalf("expected custom script, got %s", diff) } } ================================================ FILE: internal/ipxe/script/static.go ================================================ package script // StaticScript is the iPXE script used when in the auto-proxy mode. // It is built to be generic enough for all hardware to use. var StaticScript = `#!ipxe echo Loading the static Tinkerbell iPXE script... set arch ${buildarch} # Tinkerbell only supports 64 bit archectures. # The build architecture does not necessarily represent the architecture of the machine on which iPXE is running. # https://ipxe.org/cfg/buildarch iseq ${arch} i386 && set arch x86_64 || iseq ${arch} arm32 && set arch aarch64 || iseq ${arch} arm64 && set arch aarch64 || set download-url {{ .DownloadURL }} set retries:int32 {{ .Retries }} set retry_delay:int32 {{ .RetryDelay }} set worker_id ${mac} set grpc_authority {{ .TinkGRPCAuthority }} set syslog_host {{ .SyslogHost }} set tinkerbell_tls {{ .TinkerbellTLS }} echo worker_id=${mac} echo grpc_authority={{ .TinkGRPCAuthority }} echo syslog_host={{ .SyslogHost }} echo tinkerbell_tls={{ .TinkerbellTLS }} set idx:int32 0 :retry_kernel kernel ${download-url}/vmlinuz-${arch} \ syslog_host=${syslog_host} grpc_authority=${grpc_authority} tinkerbell_tls=${tinkerbell_tls} worker_id=${worker_id} hw_addr=${mac} \ console=tty1 console=tty2 console=ttyAMA0,115200 console=ttyAMA1,115200 console=ttyS0,115200 console=ttyS1,115200 {{- range .ExtraKernelParams}} {{.}} {{- end}} \ intel_iommu=on iommu=pt {{- range .ExtraKernelParams}} {{.}} {{- end}} initrd=initramfs-${arch} && goto download_initrd || iseq ${idx} ${retries} && goto kernel-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_kernel :download_initrd set idx:int32 0 :retry_initrd initrd ${download-url}/initramfs-${arch} && goto boot || iseq ${idx} ${retries} && goto initrd-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_initrd :boot set idx:int32 0 :retry_boot boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot :kernel-error echo Failed to load kernel imgfree exit :initrd-error echo Failed to load initrd imgfree exit :boot-error echo Failed to boot imgfree exit ` ================================================ FILE: internal/iso/internal/LICENSE ================================================ Copyright 2009 The Go Authors. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google LLC nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: internal/iso/internal/acsii.go ================================================ // Copyright 2021 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package internal // EqualFold is [strings.EqualFold], ASCII only. It reports whether s and t // are equal, ASCII-case-insensitively. func EqualFold(s, t string) bool { if len(s) != len(t) { return false } for i := 0; i < len(s); i++ { if lower(s[i]) != lower(t[i]) { return false } } return true } // lower returns the ASCII lowercase version of b. func lower(b byte) byte { if 'A' <= b && b <= 'Z' { return b + ('a' - 'A') } return b } // IsPrint returns whether s is ASCII and printable according to // https://tools.ietf.org/html/rfc20#section-4.2. func IsPrint(s string) bool { for i := 0; i < len(s); i++ { if s[i] < ' ' || s[i] > '~' { return false } } return true } ================================================ FILE: internal/iso/internal/acsii_test.go ================================================ // Copyright 2021 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package internal import "testing" func TestEqualFold(t *testing.T) { var tests = []struct { name string a, b string want bool }{ { name: "empty", want: true, }, { name: "simple match", a: "CHUNKED", b: "chunked", want: true, }, { name: "same string", a: "chunked", b: "chunked", want: true, }, { name: "Unicode Kelvin symbol", a: "chunKed", // This "K" is 'KELVIN SIGN' (\u212A) b: "chunked", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := EqualFold(tt.a, tt.b); got != tt.want { t.Errorf("AsciiEqualFold(%q,%q): got %v want %v", tt.a, tt.b, got, tt.want) } }) } } func TestIsPrint(t *testing.T) { var tests = []struct { name string in string want bool }{ { name: "empty", want: true, }, { name: "ASCII low", in: "This is a space: ' '", want: true, }, { name: "ASCII high", in: "This is a tilde: '~'", want: true, }, { name: "ASCII low non-print", in: "This is a unit separator: \x1F", want: false, }, { name: "Ascii high non-print", in: "This is a Delete: \x7F", want: false, }, { name: "Unicode letter", in: "Today it's 280K outside: it's freezing!", // This "K" is 'KELVIN SIGN' (\u212A) want: false, }, { name: "Unicode emoji", in: "Gophers like 🧀", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsPrint(tt.in); got != tt.want { t.Errorf("IsASCIIPrint(%q): got %v want %v", tt.in, got, tt.want) } }) } } ================================================ FILE: internal/iso/internal/context.go ================================================ package internal import "context" type patchCtxKeyType string const isoPatchCtxKey patchCtxKeyType = "iso-patch" func WithPatch(ctx context.Context, patch []byte) context.Context { return context.WithValue(ctx, isoPatchCtxKey, patch) } func GetPatch(ctx context.Context) []byte { patch, ok := ctx.Value(isoPatchCtxKey).([]byte) if !ok { return nil } return patch } ================================================ FILE: internal/iso/internal/reverseproxy.go ================================================ // Copyright 2011 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // HTTP reverse proxy handler package internal import ( "context" "errors" "fmt" "io" "log" "mime" "net" "net/http" "net/http/httptrace" "net/textproto" "net/url" "strings" "sync" "time" "golang.org/x/net/http/httpguts" ) // A ProxyRequest contains a request to be rewritten by a [ReverseProxy]. type ProxyRequest struct { // In is the request received by the proxy. // The Rewrite function must not modify In. In *http.Request // Out is the request which will be sent by the proxy. // The Rewrite function may modify or replace this request. // Hop-by-hop headers are removed from this request // before Rewrite is called. Out *http.Request } // SetURL routes the outbound request to the scheme, host, and base path // provided in target. If the target's path is "/base" and the incoming // request was for "/dir", the target request will be for "/base/dir". // // SetURL rewrites the outbound Host header to match the target's host. // To preserve the inbound request's Host header (the default behavior // of [NewSingleHostReverseProxy]): // // rewriteFunc := func(r *httputil.ProxyRequest) { // r.SetURL(url) // r.Out.Host = r.In.Host // } func (r *ProxyRequest) SetURL(target *url.URL) { rewriteRequestURL(r.Out, target) r.Out.Host = "" } // SetXForwarded sets the X-Forwarded-For, X-Forwarded-Host, and // X-Forwarded-Proto headers of the outbound request. // // - The X-Forwarded-For header is set to the client IP address. // - The X-Forwarded-Host header is set to the host name requested // by the client. // - The X-Forwarded-Proto header is set to "http" or "https", depending // on whether the inbound request was made on a TLS-enabled connection. // // If the outbound request contains an existing X-Forwarded-For header, // SetXForwarded appends the client IP address to it. To append to the // inbound request's X-Forwarded-For header (the default behavior of // [ReverseProxy] when using a Director function), copy the header // from the inbound request before calling SetXForwarded: // // rewriteFunc := func(r *httputil.ProxyRequest) { // r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] // r.SetXForwarded() // } func (r *ProxyRequest) SetXForwarded() { clientIP, _, err := net.SplitHostPort(r.In.RemoteAddr) if err == nil { prior := r.Out.Header["X-Forwarded-For"] if len(prior) > 0 { clientIP = strings.Join(prior, ", ") + ", " + clientIP } r.Out.Header.Set("X-Forwarded-For", clientIP) } else { r.Out.Header.Del("X-Forwarded-For") } r.Out.Header.Set("X-Forwarded-Host", r.In.Host) if r.In.TLS == nil { r.Out.Header.Set("X-Forwarded-Proto", "http") } else { r.Out.Header.Set("X-Forwarded-Proto", "https") } } // ReverseProxy is an HTTP Handler that takes an incoming request and // sends it to another server, proxying the response back to the // client. // // 1xx responses are forwarded to the client if the underlying // transport supports ClientTrace.Got1xxResponse. type ReverseProxy struct { // Rewrite must be a function which modifies // the request into a new request to be sent // using Transport. Its response is then copied // back to the original client unmodified. // Rewrite must not access the provided ProxyRequest // or its contents after returning. // // The Forwarded, X-Forwarded, X-Forwarded-Host, // and X-Forwarded-Proto headers are removed from the // outbound request before Rewrite is called. See also // the ProxyRequest.SetXForwarded method. // // Unparsable query parameters are removed from the // outbound request before Rewrite is called. // The Rewrite function may copy the inbound URL's // RawQuery to the outbound URL to preserve the original // parameter string. Note that this can lead to security // issues if the proxy's interpretation of query parameters // does not match that of the downstream server. // // At most one of Rewrite or Director may be set. Rewrite func(*ProxyRequest) // Director is a function which modifies // the request into a new request to be sent // using Transport. Its response is then copied // back to the original client unmodified. // Director must not access the provided Request // after returning. // // By default, the X-Forwarded-For header is set to the // value of the client IP address. If an X-Forwarded-For // header already exists, the client IP is appended to the // existing values. As a special case, if the header // exists in the Request.Header map but has a nil value // (such as when set by the Director func), the X-Forwarded-For // header is not modified. // // To prevent IP spoofing, be sure to delete any pre-existing // X-Forwarded-For header coming from the client or // an untrusted proxy. // // Hop-by-hop headers are removed from the request after // Director returns, which can remove headers added by // Director. Use a Rewrite function instead to ensure // modifications to the request are preserved. // // Unparsable query parameters are removed from the outbound // request if Request.Form is set after Director returns. // // At most one of Rewrite or Director may be set. Director func(*http.Request) // The transport used to perform proxy requests. // If nil, http.DefaultTransport is used. Transport http.RoundTripper // FlushInterval specifies the flush interval // to flush to the client while copying the // response body. // If zero, no periodic flushing is done. // A negative value means to flush immediately // after each write to the client. // The FlushInterval is ignored when ReverseProxy // recognizes a response as a streaming response, or // if its ContentLength is -1; for such responses, writes // are flushed to the client immediately. FlushInterval time.Duration // ErrorLog specifies an optional logger for errors // that occur when attempting to proxy the request. // If nil, logging is done via the log package's standard logger. ErrorLog *log.Logger // BufferPool optionally specifies a buffer pool to // get byte slices for use by io.CopyBuffer when // copying HTTP response bodies. BufferPool BufferPool // ModifyResponse is an optional function that modifies the // Response from the backend. It is called if the backend // returns a response at all, with any HTTP status code. // If the backend is unreachable, the optional ErrorHandler is // called without any call to ModifyResponse. // // If ModifyResponse returns an error, ErrorHandler is called // with its error value. If ErrorHandler is nil, its default // implementation is used. ModifyResponse func(*http.Response) error // ErrorHandler is an optional function that handles errors // reaching the backend or errors from ModifyResponse. // // If nil, the default is to log the provided error and return // a 502 Status Bad Gateway response. ErrorHandler func(http.ResponseWriter, *http.Request, error) // CopyBuffer is an optional function for handling the copying of the // response body. If nil, an internal implementation is used. CopyBuffer CopyBuffer } // A BufferPool is an interface for getting and returning temporary // byte slices for use by [io.CopyBuffer]. type BufferPool interface { Get() []byte Put([]byte) } type CopyBuffer interface { Copy(ctx context.Context, dst io.Writer, src io.Reader, buf []byte) (int64, error) } func singleJoiningSlash(a, b string) string { aslash := strings.HasSuffix(a, "/") bslash := strings.HasPrefix(b, "/") switch { case aslash && bslash: return a + b[1:] case !aslash && !bslash: return a + "/" + b } return a + b } func joinURLPath(a, b *url.URL) (path, rawpath string) { if a.RawPath == "" && b.RawPath == "" { return singleJoiningSlash(a.Path, b.Path), "" } // Same as singleJoiningSlash, but uses EscapedPath to determine // whether a slash should be added apath := a.EscapedPath() bpath := b.EscapedPath() aslash := strings.HasSuffix(apath, "/") bslash := strings.HasPrefix(bpath, "/") switch { case aslash && bslash: return a.Path + b.Path[1:], apath + bpath[1:] case !aslash && !bslash: return a.Path + "/" + b.Path, apath + "/" + bpath } return a.Path + b.Path, apath + bpath } // NewSingleHostReverseProxy returns a new [ReverseProxy] that routes // URLs to the scheme, host, and base path provided in target. If the // target's path is "/base" and the incoming request was for "/dir", // the target request will be for /base/dir. // // NewSingleHostReverseProxy does not rewrite the Host header. // // To customize the ReverseProxy behavior beyond what // NewSingleHostReverseProxy provides, use ReverseProxy directly // with a Rewrite function. The ProxyRequest SetURL method // may be used to route the outbound request. (Note that SetURL, // unlike NewSingleHostReverseProxy, rewrites the Host header // of the outbound request by default.) // // proxy := &ReverseProxy{ // Rewrite: func(r *ProxyRequest) { // r.SetURL(target) // r.Out.Host = r.In.Host // if desired // }, // } func NewSingleHostReverseProxy(target *url.URL) *ReverseProxy { director := func(req *http.Request) { rewriteRequestURL(req, target) } return &ReverseProxy{Director: director} } func rewriteRequestURL(req *http.Request, target *url.URL) { targetQuery := target.RawQuery req.URL.Scheme = target.Scheme req.URL.Host = target.Host req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL) if targetQuery == "" || req.URL.RawQuery == "" { req.URL.RawQuery = targetQuery + req.URL.RawQuery } else { req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery } } func copyHeader(dst, src http.Header) { for k, vv := range src { for _, v := range vv { dst.Add(k, v) } } } // Hop-by-hop headers. These are removed when sent to the backend. // As of RFC 7230, hop-by-hop headers are required to appear in the // Connection header field. These are the headers defined by the // obsoleted RFC 2616 (section 13.5.1) and are used for backward // compatibility. var hopHeaders = []string{ "Connection", "Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Te", // canonicalized version of "TE" "Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522 "Transfer-Encoding", "Upgrade", } func (p *ReverseProxy) defaultErrorHandler(rw http.ResponseWriter, req *http.Request, err error) { p.logf("http: proxy error: %v", err) rw.WriteHeader(http.StatusBadGateway) } func (p *ReverseProxy) getErrorHandler() func(http.ResponseWriter, *http.Request, error) { if p.ErrorHandler != nil { return p.ErrorHandler } return p.defaultErrorHandler } // modifyResponse conditionally runs the optional ModifyResponse hook // and reports whether the request should proceed. func (p *ReverseProxy) modifyResponse(rw http.ResponseWriter, res *http.Response, req *http.Request) bool { if p.ModifyResponse == nil { return true } if err := p.ModifyResponse(res); err != nil { res.Body.Close() p.getErrorHandler()(rw, req, err) return false } return true } func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { transport := p.Transport if transport == nil { transport = http.DefaultTransport } ctx := req.Context() if ctx.Done() != nil { // CloseNotifier predates context.Context, and has been // entirely superseded by it. If the request contains // a Context that carries a cancellation signal, don't // bother spinning up a goroutine to watch the CloseNotify // channel (if any). // // If the request Context has a nil Done channel (which // means it is either context.Background, or a custom // Context implementation with no cancellation signal), // then consult the CloseNotifier if available. } else if cn, ok := rw.(http.CloseNotifier); ok { var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) defer cancel() notifyChan := cn.CloseNotify() go func() { select { case <-notifyChan: cancel() case <-ctx.Done(): } }() } outreq := req.Clone(ctx) if req.ContentLength == 0 { outreq.Body = nil // Issue 16036: nil Body for http.Transport retries } if outreq.Body != nil { // Reading from the request body after returning from a handler is not // allowed, and the RoundTrip goroutine that reads the Body can outlive // this handler. This can lead to a crash if the handler panics (see // Issue 46866). Although calling Close doesn't guarantee there isn't // any Read in flight after the handle returns, in practice it's safe to // read after closing it. defer outreq.Body.Close() } if outreq.Header == nil { outreq.Header = make(http.Header) // Issue 33142: historical behavior was to always allocate } if (p.Director != nil) == (p.Rewrite != nil) { p.getErrorHandler()(rw, req, errors.New("ReverseProxy must have exactly one of Director or Rewrite set")) return } if p.Director != nil { p.Director(outreq) if outreq.Form != nil { outreq.URL.RawQuery = cleanQueryParams(outreq.URL.RawQuery) } } outreq.Close = false reqUpType := upgradeType(outreq.Header) if !IsPrint(reqUpType) { p.getErrorHandler()(rw, req, fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType)) return } removeHopByHopHeaders(outreq.Header) // Issue 21096: tell backend applications that care about trailer support // that we support trailers. (We do, but we don't go out of our way to // advertise that unless the incoming client request thought it was worth // mentioning.) Note that we look at req.Header, not outreq.Header, since // the latter has passed through removeHopByHopHeaders. if httpguts.HeaderValuesContainsToken(req.Header["Te"], "trailers") { outreq.Header.Set("Te", "trailers") } // After stripping all the hop-by-hop connection headers above, add back any // necessary for protocol upgrades, such as for websockets. if reqUpType != "" { outreq.Header.Set("Connection", "Upgrade") outreq.Header.Set("Upgrade", reqUpType) } if p.Rewrite != nil { // Strip client-provided forwarding headers. // The Rewrite func may use SetXForwarded to set new values // for these or copy the previous values from the inbound request. outreq.Header.Del("Forwarded") outreq.Header.Del("X-Forwarded-For") outreq.Header.Del("X-Forwarded-Host") outreq.Header.Del("X-Forwarded-Proto") // Remove unparsable query parameters from the outbound request. outreq.URL.RawQuery = cleanQueryParams(outreq.URL.RawQuery) pr := &ProxyRequest{ In: req, Out: outreq, } p.Rewrite(pr) outreq = pr.Out } else { if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { // If we aren't the first proxy retain prior // X-Forwarded-For information as a comma+space // separated list and fold multiple headers into one. prior, ok := outreq.Header["X-Forwarded-For"] omit := ok && prior == nil // Issue 38079: nil now means don't populate the header if len(prior) > 0 { clientIP = strings.Join(prior, ", ") + ", " + clientIP } if !omit { outreq.Header.Set("X-Forwarded-For", clientIP) } } } if _, ok := outreq.Header["User-Agent"]; !ok { // If the outbound request doesn't have a User-Agent header set, // don't send the default Go HTTP client User-Agent. outreq.Header.Set("User-Agent", "") } var ( roundTripMutex sync.Mutex roundTripDone bool ) trace := &httptrace.ClientTrace{ Got1xxResponse: func(code int, header textproto.MIMEHeader) error { roundTripMutex.Lock() defer roundTripMutex.Unlock() if roundTripDone { // If RoundTrip has returned, don't try to further modify // the ResponseWriter's header map. return nil } h := rw.Header() copyHeader(h, http.Header(header)) rw.WriteHeader(code) // Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses clear(h) return nil }, } outreq = outreq.WithContext(httptrace.WithClientTrace(outreq.Context(), trace)) res, err := transport.RoundTrip(outreq) roundTripMutex.Lock() roundTripDone = true roundTripMutex.Unlock() if err != nil { p.getErrorHandler()(rw, outreq, err) return } // Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc) if res.StatusCode == http.StatusSwitchingProtocols { if !p.modifyResponse(rw, res, outreq) { return } p.handleUpgradeResponse(rw, outreq, res) return } removeHopByHopHeaders(res.Header) if !p.modifyResponse(rw, res, outreq) { return } copyHeader(rw.Header(), res.Header) // The "Trailer" header isn't included in the Transport's response, // at least for *http.Transport. Build it up from Trailer. announcedTrailers := len(res.Trailer) if announcedTrailers > 0 { trailerKeys := make([]string, 0, len(res.Trailer)) for k := range res.Trailer { trailerKeys = append(trailerKeys, k) } rw.Header().Add("Trailer", strings.Join(trailerKeys, ", ")) } rw.WriteHeader(res.StatusCode) var resContext context.Context if res.Request != nil { resContext = res.Request.Context() } err = p.copyResponse(resContext, rw, res.Body, p.flushInterval(res)) if err != nil { defer res.Body.Close() // Since we're streaming the response, if we run into an error all we can do // is abort the request. Issue 23643: ReverseProxy should use ErrAbortHandler // on read error while copying body. if !shouldPanicOnCopyError(req) { p.logf("suppressing panic for copyResponse error in test; copy error: %v", err) return } panic(http.ErrAbortHandler) } res.Body.Close() // close now, instead of defer, to populate res.Trailer if len(res.Trailer) > 0 { // Force chunking if we saw a response trailer. // This prevents net/http from calculating the length for short // bodies and adding a Content-Length. http.NewResponseController(rw).Flush() } if len(res.Trailer) == announcedTrailers { copyHeader(rw.Header(), res.Trailer) return } for k, vv := range res.Trailer { k = http.TrailerPrefix + k for _, v := range vv { rw.Header().Add(k, v) } } } var inOurTests bool // whether we're in our own tests // shouldPanicOnCopyError reports whether the reverse proxy should // panic with http.ErrAbortHandler. This is the right thing to do by // default, but Go 1.10 and earlier did not, so existing unit tests // weren't expecting panics. Only panic in our own tests, or when // running under the HTTP server. func shouldPanicOnCopyError(req *http.Request) bool { if inOurTests { // Our tests know to handle this panic. return true } if req.Context().Value(http.ServerContextKey) != nil { // We seem to be running under an HTTP server, so // it'll recover the panic. return true } // Otherwise act like Go 1.10 and earlier to not break // existing tests. return false } // removeHopByHopHeaders removes hop-by-hop headers. func removeHopByHopHeaders(h http.Header) { // RFC 7230, section 6.1: Remove headers listed in the "Connection" header. for _, f := range h["Connection"] { for _, sf := range strings.Split(f, ",") { if sf = textproto.TrimString(sf); sf != "" { h.Del(sf) } } } // RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers. // This behavior is superseded by the RFC 7230 Connection header, but // preserve it for backwards compatibility. for _, f := range hopHeaders { h.Del(f) } } // flushInterval returns the p.FlushInterval value, conditionally // overriding its value for a specific request/response. func (p *ReverseProxy) flushInterval(res *http.Response) time.Duration { resCT := res.Header.Get("Content-Type") // For Server-Sent Events responses, flush immediately. // The MIME type is defined in https://www.w3.org/TR/eventsource/#text-event-stream if baseCT, _, _ := mime.ParseMediaType(resCT); baseCT == "text/event-stream" { return -1 // negative means immediately } // We might have the case of streaming for which Content-Length might be unset. if res.ContentLength == -1 { return -1 } return p.FlushInterval } func (p *ReverseProxy) copyResponse(ctx context.Context, dst http.ResponseWriter, src io.Reader, flushInterval time.Duration) error { var w io.Writer = dst if flushInterval != 0 { mlw := &maxLatencyWriter{ dst: dst, flush: http.NewResponseController(dst).Flush, latency: flushInterval, } defer mlw.stop() // set up initial timer so headers get flushed even if body writes are delayed mlw.flushPending = true mlw.t = time.AfterFunc(flushInterval, mlw.delayedFlush) w = mlw } var buf []byte if p.BufferPool != nil { buf = p.BufferPool.Get() defer p.BufferPool.Put(buf) } var err error if p.CopyBuffer != nil { _, err = p.CopyBuffer.Copy(ctx, w, src, buf) } else { _, err = p.copyBuffer(w, src, buf) } return err } // copyBuffer returns any write errors or non-EOF read errors, and the amount // of bytes written. func (p *ReverseProxy) copyBuffer(dst io.Writer, src io.Reader, buf []byte) (int64, error) { if len(buf) == 0 { buf = make([]byte, 32*1024) } var written int64 for { nr, rerr := src.Read(buf) if rerr != nil && rerr != io.EOF && rerr != context.Canceled { p.logf("httputil: ReverseProxy read error during body copy: %v", rerr) } if nr > 0 { nw, werr := dst.Write(buf[:nr]) if nw > 0 { written += int64(nw) } if werr != nil { return written, werr } if nr != nw { return written, io.ErrShortWrite } } if rerr != nil { if rerr == io.EOF { rerr = nil } return written, rerr } } } func (p *ReverseProxy) logf(format string, args ...any) { if p.ErrorLog != nil { p.ErrorLog.Printf(format, args...) } else { log.Printf(format, args...) } } type maxLatencyWriter struct { dst io.Writer flush func() error latency time.Duration // non-zero; negative means to flush immediately mu sync.Mutex // protects t, flushPending, and dst.Flush t *time.Timer flushPending bool } func (m *maxLatencyWriter) Write(p []byte) (n int, err error) { m.mu.Lock() defer m.mu.Unlock() n, err = m.dst.Write(p) if m.latency < 0 { m.flush() return } if m.flushPending { return } if m.t == nil { m.t = time.AfterFunc(m.latency, m.delayedFlush) } else { m.t.Reset(m.latency) } m.flushPending = true return } func (m *maxLatencyWriter) delayedFlush() { m.mu.Lock() defer m.mu.Unlock() if !m.flushPending { // if stop was called but AfterFunc already started this goroutine return } m.flush() m.flushPending = false } func (m *maxLatencyWriter) stop() { m.mu.Lock() defer m.mu.Unlock() m.flushPending = false if m.t != nil { m.t.Stop() } } func upgradeType(h http.Header) string { if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") { return "" } return h.Get("Upgrade") } func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.Request, res *http.Response) { reqUpType := upgradeType(req.Header) resUpType := upgradeType(res.Header) if !IsPrint(resUpType) { // We know reqUpType is ASCII, it's checked by the caller. p.getErrorHandler()(rw, req, fmt.Errorf("backend tried to switch to invalid protocol %q", resUpType)) } if !EqualFold(reqUpType, resUpType) { p.getErrorHandler()(rw, req, fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType)) return } backConn, ok := res.Body.(io.ReadWriteCloser) if !ok { p.getErrorHandler()(rw, req, fmt.Errorf("internal error: 101 switching protocols response with non-writable body")) return } rc := http.NewResponseController(rw) conn, brw, hijackErr := rc.Hijack() if errors.Is(hijackErr, http.ErrNotSupported) { p.getErrorHandler()(rw, req, fmt.Errorf("can't switch protocols using non-Hijacker ResponseWriter type %T", rw)) return } backConnCloseCh := make(chan bool) go func() { // Ensure that the cancellation of a request closes the backend. // See issue https://golang.org/issue/35559. select { case <-req.Context().Done(): case <-backConnCloseCh: } backConn.Close() }() defer close(backConnCloseCh) if hijackErr != nil { p.getErrorHandler()(rw, req, fmt.Errorf("Hijack failed on protocol switch: %v", hijackErr)) return } defer conn.Close() copyHeader(rw.Header(), res.Header) res.Header = rw.Header() res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above if err := res.Write(brw); err != nil { p.getErrorHandler()(rw, req, fmt.Errorf("response write: %v", err)) return } if err := brw.Flush(); err != nil { p.getErrorHandler()(rw, req, fmt.Errorf("response flush: %v", err)) return } errc := make(chan error, 1) spc := switchProtocolCopier{user: conn, backend: backConn} go spc.copyToBackend(errc) go spc.copyFromBackend(errc) <-errc } // switchProtocolCopier exists so goroutines proxying data back and // forth have nice names in stacks. type switchProtocolCopier struct { user, backend io.ReadWriter } func (c switchProtocolCopier) copyFromBackend(errc chan<- error) { _, err := io.Copy(c.user, c.backend) errc <- err } func (c switchProtocolCopier) copyToBackend(errc chan<- error) { _, err := io.Copy(c.backend, c.user) errc <- err } func cleanQueryParams(s string) string { reencode := func(s string) string { v, _ := url.ParseQuery(s) return v.Encode() } for i := 0; i < len(s); { switch s[i] { case ';': return reencode(s) case '%': if i+2 >= len(s) || !ishex(s[i+1]) || !ishex(s[i+2]) { return reencode(s) } i += 3 default: i++ } } return s } func ishex(c byte) bool { switch { case '0' <= c && c <= '9': return true case 'a' <= c && c <= 'f': return true case 'A' <= c && c <= 'F': return true } return false } ================================================ FILE: internal/iso/internal/reverseproxy_test.go ================================================ // Copyright 2011 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // Reverse proxy tests. package internal import ( "bufio" "bytes" "context" "errors" "fmt" "io" "log" "net/http" "net/http/httptest" "net/http/httptrace" "net/textproto" "net/url" "os" "reflect" "slices" "strconv" "strings" "sync" "testing" "time" ) const fakeHopHeader = "X-Fake-Hop-Header-For-Test" func init() { inOurTests = true hopHeaders = append(hopHeaders, fakeHopHeader) } func TestReverseProxy(t *testing.T) { const backendResponse = "I am the backend" const backendStatus = 404 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" && r.FormValue("mode") == "hangup" { c, _, _ := w.(http.Hijacker).Hijack() c.Close() return } if len(r.TransferEncoding) > 0 { t.Errorf("backend got unexpected TransferEncoding: %v", r.TransferEncoding) } if r.Header.Get("X-Forwarded-For") == "" { t.Errorf("didn't get X-Forwarded-For header") } if c := r.Header.Get("Connection"); c != "" { t.Errorf("handler got Connection header value %q", c) } if c := r.Header.Get("Te"); c != "trailers" { t.Errorf("handler got Te header value %q; want 'trailers'", c) } if c := r.Header.Get("Upgrade"); c != "" { t.Errorf("handler got Upgrade header value %q", c) } if c := r.Header.Get("Proxy-Connection"); c != "" { t.Errorf("handler got Proxy-Connection header value %q", c) } if g, e := r.Host, "some-name"; g != e { t.Errorf("backend got Host header %q, want %q", g, e) } w.Header().Set("Trailers", "not a special header field name") w.Header().Set("Trailer", "X-Trailer") w.Header().Set("X-Foo", "bar") w.Header().Set("Upgrade", "foo") w.Header().Set(fakeHopHeader, "foo") w.Header().Add("X-Multi-Value", "foo") w.Header().Add("X-Multi-Value", "bar") http.SetCookie(w, &http.Cookie{Name: "flavor", Value: "chocolateChip"}) w.WriteHeader(backendStatus) w.Write([]byte(backendResponse)) w.Header().Set("X-Trailer", "trailer_value") w.Header().Set(http.TrailerPrefix+"X-Unannounced-Trailer", "unannounced_trailer_value") })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests frontend := httptest.NewServer(proxyHandler) defer frontend.Close() frontendClient := frontend.Client() getReq, _ := http.NewRequest("GET", frontend.URL, nil) getReq.Host = "some-name" getReq.Header.Set("Connection", "close, TE") getReq.Header.Add("Te", "foo") getReq.Header.Add("Te", "bar, trailers") getReq.Header.Set("Proxy-Connection", "should be deleted") getReq.Header.Set("Upgrade", "foo") getReq.Close = true res, err := frontendClient.Do(getReq) if err != nil { t.Fatalf("Get: %v", err) } if g, e := res.StatusCode, backendStatus; g != e { t.Errorf("got res.StatusCode %d; expected %d", g, e) } if g, e := res.Header.Get("X-Foo"), "bar"; g != e { t.Errorf("got X-Foo %q; expected %q", g, e) } if c := res.Header.Get(fakeHopHeader); c != "" { t.Errorf("got %s header value %q", fakeHopHeader, c) } if g, e := res.Header.Get("Trailers"), "not a special header field name"; g != e { t.Errorf("header Trailers = %q; want %q", g, e) } if g, e := len(res.Header["X-Multi-Value"]), 2; g != e { t.Errorf("got %d X-Multi-Value header values; expected %d", g, e) } if g, e := len(res.Header["Set-Cookie"]), 1; g != e { t.Fatalf("got %d SetCookies, want %d", g, e) } if g, e := res.Trailer, (http.Header{"X-Trailer": nil}); !reflect.DeepEqual(g, e) { t.Errorf("before reading body, Trailer = %#v; want %#v", g, e) } if cookie := res.Cookies()[0]; cookie.Name != "flavor" { t.Errorf("unexpected cookie %q", cookie.Name) } bodyBytes, _ := io.ReadAll(res.Body) if g, e := string(bodyBytes), backendResponse; g != e { t.Errorf("got body %q; expected %q", g, e) } if g, e := res.Trailer.Get("X-Trailer"), "trailer_value"; g != e { t.Errorf("Trailer(X-Trailer) = %q ; want %q", g, e) } if g, e := res.Trailer.Get("X-Unannounced-Trailer"), "unannounced_trailer_value"; g != e { t.Errorf("Trailer(X-Unannounced-Trailer) = %q ; want %q", g, e) } // Test that a backend failing to be reached or one which doesn't return // a response results in a StatusBadGateway. getReq, _ = http.NewRequest("GET", frontend.URL+"/?mode=hangup", nil) getReq.Close = true res, err = frontendClient.Do(getReq) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusBadGateway { t.Errorf("request to bad proxy = %v; want 502 StatusBadGateway", res.Status) } } // Issue 16875: remove any proxied headers mentioned in the "Connection" // header value. func TestReverseProxyStripHeadersPresentInConnection(t *testing.T) { const fakeConnectionToken = "X-Fake-Connection-Token" const backendResponse = "I am the backend" // someConnHeader is some arbitrary header to be declared as a hop-by-hop header // in the Request's Connection header. const someConnHeader = "X-Some-Conn-Header" backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c := r.Header.Get("Connection"); c != "" { t.Errorf("handler got header %q = %q; want empty", "Connection", c) } if c := r.Header.Get(fakeConnectionToken); c != "" { t.Errorf("handler got header %q = %q; want empty", fakeConnectionToken, c) } if c := r.Header.Get(someConnHeader); c != "" { t.Errorf("handler got header %q = %q; want empty", someConnHeader, c) } w.Header().Add("Connection", "Upgrade, "+fakeConnectionToken) w.Header().Add("Connection", someConnHeader) w.Header().Set(someConnHeader, "should be deleted") w.Header().Set(fakeConnectionToken, "should be deleted") io.WriteString(w, backendResponse) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { proxyHandler.ServeHTTP(w, r) if c := r.Header.Get(someConnHeader); c != "should be deleted" { t.Errorf("handler modified header %q = %q; want %q", someConnHeader, c, "should be deleted") } if c := r.Header.Get(fakeConnectionToken); c != "should be deleted" { t.Errorf("handler modified header %q = %q; want %q", fakeConnectionToken, c, "should be deleted") } c := r.Header["Connection"] var cf []string for _, f := range c { for _, sf := range strings.Split(f, ",") { if sf = strings.TrimSpace(sf); sf != "" { cf = append(cf, sf) } } } slices.Sort(cf) expectedValues := []string{"Upgrade", someConnHeader, fakeConnectionToken} slices.Sort(expectedValues) if !reflect.DeepEqual(cf, expectedValues) { t.Errorf("handler modified header %q = %q; want %q", "Connection", cf, expectedValues) } })) defer frontend.Close() getReq, _ := http.NewRequest("GET", frontend.URL, nil) getReq.Header.Add("Connection", "Upgrade, "+fakeConnectionToken) getReq.Header.Add("Connection", someConnHeader) getReq.Header.Set(someConnHeader, "should be deleted") getReq.Header.Set(fakeConnectionToken, "should be deleted") res, err := frontend.Client().Do(getReq) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() bodyBytes, err := io.ReadAll(res.Body) if err != nil { t.Fatalf("reading body: %v", err) } if got, want := string(bodyBytes), backendResponse; got != want { t.Errorf("got body %q; want %q", got, want) } if c := res.Header.Get("Connection"); c != "" { t.Errorf("handler got header %q = %q; want empty", "Connection", c) } if c := res.Header.Get(someConnHeader); c != "" { t.Errorf("handler got header %q = %q; want empty", someConnHeader, c) } if c := res.Header.Get(fakeConnectionToken); c != "" { t.Errorf("handler got header %q = %q; want empty", fakeConnectionToken, c) } } func TestReverseProxyStripEmptyConnection(t *testing.T) { // See Issue 46313. const backendResponse = "I am the backend" // someConnHeader is some arbitrary header to be declared as a hop-by-hop header // in the Request's Connection header. const someConnHeader = "X-Some-Conn-Header" backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if c := r.Header.Values("Connection"); len(c) != 0 { t.Errorf("handler got header %q = %v; want empty", "Connection", c) } if c := r.Header.Get(someConnHeader); c != "" { t.Errorf("handler got header %q = %q; want empty", someConnHeader, c) } w.Header().Add("Connection", "") w.Header().Add("Connection", someConnHeader) w.Header().Set(someConnHeader, "should be deleted") io.WriteString(w, backendResponse) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { proxyHandler.ServeHTTP(w, r) if c := r.Header.Get(someConnHeader); c != "should be deleted" { t.Errorf("handler modified header %q = %q; want %q", someConnHeader, c, "should be deleted") } })) defer frontend.Close() getReq, _ := http.NewRequest("GET", frontend.URL, nil) getReq.Header.Add("Connection", "") getReq.Header.Add("Connection", someConnHeader) getReq.Header.Set(someConnHeader, "should be deleted") res, err := frontend.Client().Do(getReq) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() bodyBytes, err := io.ReadAll(res.Body) if err != nil { t.Fatalf("reading body: %v", err) } if got, want := string(bodyBytes), backendResponse; got != want { t.Errorf("got body %q; want %q", got, want) } if c := res.Header.Get("Connection"); c != "" { t.Errorf("handler got header %q = %q; want empty", "Connection", c) } if c := res.Header.Get(someConnHeader); c != "" { t.Errorf("handler got header %q = %q; want empty", someConnHeader, c) } } func TestXForwardedFor(t *testing.T) { const prevForwardedFor = "client ip" const backendResponse = "I am the backend" const backendStatus = 404 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Forwarded-For") == "" { t.Errorf("didn't get X-Forwarded-For header") } if !strings.Contains(r.Header.Get("X-Forwarded-For"), prevForwardedFor) { t.Errorf("X-Forwarded-For didn't contain prior data") } w.WriteHeader(backendStatus) w.Write([]byte(backendResponse)) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) frontend := httptest.NewServer(proxyHandler) defer frontend.Close() getReq, _ := http.NewRequest("GET", frontend.URL, nil) getReq.Header.Set("Connection", "close") getReq.Header.Set("X-Forwarded-For", prevForwardedFor) getReq.Close = true res, err := frontend.Client().Do(getReq) if err != nil { t.Fatalf("Get: %v", err) } if g, e := res.StatusCode, backendStatus; g != e { t.Errorf("got res.StatusCode %d; expected %d", g, e) } bodyBytes, _ := io.ReadAll(res.Body) if g, e := string(bodyBytes), backendResponse; g != e { t.Errorf("got body %q; expected %q", g, e) } } // Issue 38079: don't append to X-Forwarded-For if it's present but nil func TestXForwardedFor_Omit(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if v := r.Header.Get("X-Forwarded-For"); v != "" { t.Errorf("got X-Forwarded-For header: %q", v) } w.Write([]byte("hi")) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) frontend := httptest.NewServer(proxyHandler) defer frontend.Close() oldDirector := proxyHandler.Director proxyHandler.Director = func(r *http.Request) { r.Header["X-Forwarded-For"] = nil oldDirector(r) } getReq, _ := http.NewRequest("GET", frontend.URL, nil) getReq.Host = "some-name" getReq.Close = true res, err := frontend.Client().Do(getReq) if err != nil { t.Fatalf("Get: %v", err) } res.Body.Close() } func TestReverseProxyRewriteStripsForwarded(t *testing.T) { headers := []string{ "Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", } backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for _, h := range headers { if v := r.Header.Get(h); v != "" { t.Errorf("got %v header: %q", h, v) } } })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := &ReverseProxy{ Rewrite: func(r *ProxyRequest) { r.SetURL(backendURL) }, } frontend := httptest.NewServer(proxyHandler) defer frontend.Close() getReq, _ := http.NewRequest("GET", frontend.URL, nil) getReq.Host = "some-name" getReq.Close = true for _, h := range headers { getReq.Header.Set(h, "x") } res, err := frontend.Client().Do(getReq) if err != nil { t.Fatalf("Get: %v", err) } res.Body.Close() } var proxyQueryTests = []struct { baseSuffix string // suffix to add to backend URL reqSuffix string // suffix to add to frontend's request URL want string // what backend should see for final request URL (without ?) }{ {"", "", ""}, {"?sta=tic", "?us=er", "sta=tic&us=er"}, {"", "?us=er", "us=er"}, {"?sta=tic", "", "sta=tic"}, } func TestReverseProxyQuery(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Got-Query", r.URL.RawQuery) w.Write([]byte("hi")) })) defer backend.Close() for i, tt := range proxyQueryTests { backendURL, err := url.Parse(backend.URL + tt.baseSuffix) if err != nil { t.Fatal(err) } frontend := httptest.NewServer(NewSingleHostReverseProxy(backendURL)) req, _ := http.NewRequest("GET", frontend.URL+tt.reqSuffix, nil) req.Close = true res, err := frontend.Client().Do(req) if err != nil { t.Fatalf("%d. Get: %v", i, err) } if g, e := res.Header.Get("X-Got-Query"), tt.want; g != e { t.Errorf("%d. got query %q; expected %q", i, g, e) } res.Body.Close() frontend.Close() } } func TestReverseProxyFlushInterval(t *testing.T) { const expected = "hi" backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(expected)) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.FlushInterval = time.Microsecond frontend := httptest.NewServer(proxyHandler) defer frontend.Close() req, _ := http.NewRequest("GET", frontend.URL, nil) req.Close = true res, err := frontend.Client().Do(req) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() if bodyBytes, _ := io.ReadAll(res.Body); string(bodyBytes) != expected { t.Errorf("got body %q; expected %q", bodyBytes, expected) } } type mockFlusher struct { http.ResponseWriter flushed bool } func (m *mockFlusher) Flush() { m.flushed = true } type wrappedRW struct { http.ResponseWriter } func (w *wrappedRW) Unwrap() http.ResponseWriter { return w.ResponseWriter } func TestReverseProxyResponseControllerFlushInterval(t *testing.T) { const expected = "hi" backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(expected)) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } mf := &mockFlusher{} proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.FlushInterval = -1 // flush immediately proxyWithMiddleware := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mf.ResponseWriter = w w = &wrappedRW{mf} proxyHandler.ServeHTTP(w, r) }) frontend := httptest.NewServer(proxyWithMiddleware) defer frontend.Close() req, _ := http.NewRequest("GET", frontend.URL, nil) req.Close = true res, err := frontend.Client().Do(req) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() if bodyBytes, _ := io.ReadAll(res.Body); string(bodyBytes) != expected { t.Errorf("got body %q; expected %q", bodyBytes, expected) } if !mf.flushed { t.Errorf("response writer was not flushed") } } func TestReverseProxyFlushIntervalHeaders(t *testing.T) { const expected = "hi" stopCh := make(chan struct{}) backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("MyHeader", expected) w.WriteHeader(200) w.(http.Flusher).Flush() <-stopCh })) defer backend.Close() defer close(stopCh) backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.FlushInterval = time.Microsecond frontend := httptest.NewServer(proxyHandler) defer frontend.Close() req, _ := http.NewRequest("GET", frontend.URL, nil) req.Close = true ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second) defer cancel() req = req.WithContext(ctx) res, err := frontend.Client().Do(req) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() if res.Header.Get("MyHeader") != expected { t.Errorf("got header %q; expected %q", res.Header.Get("MyHeader"), expected) } } func TestReverseProxyCancellation(t *testing.T) { const backendResponse = "I am the backend" reqInFlight := make(chan struct{}) backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(reqInFlight) // cause the client to cancel its request select { case <-time.After(10 * time.Second): // Note: this should only happen in broken implementations, and the // closenotify case should be instantaneous. t.Error("Handler never saw CloseNotify") return case <-w.(http.CloseNotifier).CloseNotify(): } w.WriteHeader(http.StatusOK) w.Write([]byte(backendResponse)) })) defer backend.Close() backend.Config.ErrorLog = log.New(io.Discard, "", 0) backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) // Discards errors of the form: // http: proxy error: read tcp 127.0.0.1:44643: use of closed network connection proxyHandler.ErrorLog = log.New(io.Discard, "", 0) frontend := httptest.NewServer(proxyHandler) defer frontend.Close() frontendClient := frontend.Client() getReq, _ := http.NewRequest("GET", frontend.URL, nil) go func() { <-reqInFlight frontendClient.Transport.(*http.Transport).CancelRequest(getReq) }() res, err := frontendClient.Do(getReq) if res != nil { t.Errorf("got response %v; want nil", res.Status) } if err == nil { // This should be an error like: // Get "http://127.0.0.1:58079": read tcp 127.0.0.1:58079: // use of closed network connection t.Error("Server.Client().Do() returned nil error; want non-nil error") } } func req(t *testing.T, v string) *http.Request { req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(v))) if err != nil { t.Fatal(err) } return req } // Issue 12344 func TestNilBody(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hi")) })) defer backend.Close() frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { backURL, _ := url.Parse(backend.URL) rp := NewSingleHostReverseProxy(backURL) r := req(t, "GET / HTTP/1.0\r\n\r\n") r.Body = nil // this accidentally worked in Go 1.4 and below, so keep it working rp.ServeHTTP(w, r) })) defer frontend.Close() res, err := http.Get(frontend.URL) if err != nil { t.Fatal(err) } defer res.Body.Close() slurp, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } if string(slurp) != "hi" { t.Errorf("Got %q; want %q", slurp, "hi") } } // Issue 15524 func TestUserAgentHeader(t *testing.T) { var gotUA string backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotUA = r.Header.Get("User-Agent") })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := new(ReverseProxy) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests proxyHandler.Director = func(req *http.Request) { req.URL = backendURL } frontend := httptest.NewServer(proxyHandler) defer frontend.Close() frontendClient := frontend.Client() for _, sentUA := range []string{"explicit UA", ""} { getReq, _ := http.NewRequest("GET", frontend.URL, nil) getReq.Header.Set("User-Agent", sentUA) getReq.Close = true res, err := frontendClient.Do(getReq) if err != nil { t.Fatalf("Get: %v", err) } res.Body.Close() if got, want := gotUA, sentUA; got != want { t.Errorf("got forwarded User-Agent %q, want %q", got, want) } } } type bufferPool struct { get func() []byte put func([]byte) } func (bp bufferPool) Get() []byte { return bp.get() } func (bp bufferPool) Put(v []byte) { bp.put(v) } func TestReverseProxyGetPutBuffer(t *testing.T) { const msg = "hi" backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, msg) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } var ( mu sync.Mutex log []string ) addLog := func(event string) { mu.Lock() defer mu.Unlock() log = append(log, event) } rp := NewSingleHostReverseProxy(backendURL) const size = 1234 rp.BufferPool = bufferPool{ get: func() []byte { addLog("getBuf") return make([]byte, size) }, put: func(p []byte) { addLog("putBuf-" + strconv.Itoa(len(p))) }, } frontend := httptest.NewServer(rp) defer frontend.Close() req, _ := http.NewRequest("GET", frontend.URL, nil) req.Close = true res, err := frontend.Client().Do(req) if err != nil { t.Fatalf("Get: %v", err) } slurp, err := io.ReadAll(res.Body) res.Body.Close() if err != nil { t.Fatalf("reading body: %v", err) } if string(slurp) != msg { t.Errorf("msg = %q; want %q", slurp, msg) } wantLog := []string{"getBuf", "putBuf-" + strconv.Itoa(size)} mu.Lock() defer mu.Unlock() if !reflect.DeepEqual(log, wantLog) { t.Errorf("Log events = %q; want %q", log, wantLog) } } func TestReverseProxy_Post(t *testing.T) { const backendResponse = "I am the backend" const backendStatus = 200 var requestBody = bytes.Repeat([]byte("a"), 1<<20) backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slurp, err := io.ReadAll(r.Body) if err != nil { t.Errorf("Backend body read = %v", err) } if len(slurp) != len(requestBody) { t.Errorf("Backend read %d request body bytes; want %d", len(slurp), len(requestBody)) } if !bytes.Equal(slurp, requestBody) { t.Error("Backend read wrong request body.") // 1MB; omitting details } w.Write([]byte(backendResponse)) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) frontend := httptest.NewServer(proxyHandler) defer frontend.Close() postReq, _ := http.NewRequest("POST", frontend.URL, bytes.NewReader(requestBody)) res, err := frontend.Client().Do(postReq) if err != nil { t.Fatalf("Do: %v", err) } if g, e := res.StatusCode, backendStatus; g != e { t.Errorf("got res.StatusCode %d; expected %d", g, e) } bodyBytes, _ := io.ReadAll(res.Body) if g, e := string(bodyBytes), backendResponse; g != e { t.Errorf("got body %q; expected %q", g, e) } } type RoundTripperFunc func(*http.Request) (*http.Response, error) func (fn RoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } // Issue 16036: send a Request with a nil Body when possible func TestReverseProxy_NilBody(t *testing.T) { backendURL, _ := url.Parse("http://fake.tld/") proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests proxyHandler.Transport = RoundTripperFunc(func(req *http.Request) (*http.Response, error) { if req.Body != nil { t.Error("Body != nil; want a nil Body") } return nil, errors.New("done testing the interesting part; so force a 502 Gateway error") }) frontend := httptest.NewServer(proxyHandler) defer frontend.Close() res, err := frontend.Client().Get(frontend.URL) if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != 502 { t.Errorf("status code = %v; want 502 (Gateway Error)", res.Status) } } // Issue 33142: always allocate the request headers func TestReverseProxy_AllocatedHeader(t *testing.T) { proxyHandler := new(ReverseProxy) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests proxyHandler.Director = func(*http.Request) {} // noop proxyHandler.Transport = RoundTripperFunc(func(req *http.Request) (*http.Response, error) { if req.Header == nil { t.Error("Header == nil; want a non-nil Header") } return nil, errors.New("done testing the interesting part; so force a 502 Gateway error") }) proxyHandler.ServeHTTP(httptest.NewRecorder(), &http.Request{ Method: "GET", URL: &url.URL{Scheme: "http", Host: "fake.tld", Path: "/"}, Proto: "HTTP/1.0", ProtoMajor: 1, }) } // Issue 14237. Test ModifyResponse and that an error from it // causes the proxy to return StatusBadGateway, or StatusOK otherwise. func TestReverseProxyModifyResponse(t *testing.T) { backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("X-Hit-Mod", fmt.Sprintf("%v", r.URL.Path == "/mod")) })) defer backendServer.Close() rpURL, _ := url.Parse(backendServer.URL) rproxy := NewSingleHostReverseProxy(rpURL) rproxy.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests rproxy.ModifyResponse = func(resp *http.Response) error { if resp.Header.Get("X-Hit-Mod") != "true" { return fmt.Errorf("tried to by-pass proxy") } return nil } frontendProxy := httptest.NewServer(rproxy) defer frontendProxy.Close() tests := []struct { url string wantCode int }{ {frontendProxy.URL + "/mod", http.StatusOK}, {frontendProxy.URL + "/schedule", http.StatusBadGateway}, } for i, tt := range tests { resp, err := http.Get(tt.url) if err != nil { t.Fatalf("failed to reach proxy: %v", err) } if g, e := resp.StatusCode, tt.wantCode; g != e { t.Errorf("#%d: got res.StatusCode %d; expected %d", i, g, e) } resp.Body.Close() } } type failingRoundTripper struct{} func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { return nil, errors.New("some error") } type staticResponseRoundTripper struct{ res *http.Response } func (rt staticResponseRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { return rt.res, nil } func TestReverseProxyErrorHandler(t *testing.T) { tests := []struct { name string wantCode int errorHandler func(http.ResponseWriter, *http.Request, error) transport http.RoundTripper // defaults to failingRoundTripper modifyResponse func(*http.Response) error }{ { name: "default", wantCode: http.StatusBadGateway, }, { name: "errorhandler", wantCode: http.StatusTeapot, errorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { rw.WriteHeader(http.StatusTeapot) }, }, { name: "modifyresponse_noerr", transport: staticResponseRoundTripper{ &http.Response{StatusCode: 345, Body: http.NoBody}, }, modifyResponse: func(res *http.Response) error { res.StatusCode++ return nil }, errorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { rw.WriteHeader(http.StatusTeapot) }, wantCode: 346, }, { name: "modifyresponse_err", transport: staticResponseRoundTripper{ &http.Response{StatusCode: 345, Body: http.NoBody}, }, modifyResponse: func(res *http.Response) error { res.StatusCode++ return errors.New("some error to trigger errorHandler") }, errorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { rw.WriteHeader(http.StatusTeapot) }, wantCode: http.StatusTeapot, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { target := &url.URL{ Scheme: "http", Host: "dummy.tld", Path: "/", } rproxy := NewSingleHostReverseProxy(target) rproxy.Transport = tt.transport rproxy.ModifyResponse = tt.modifyResponse if rproxy.Transport == nil { rproxy.Transport = failingRoundTripper{} } rproxy.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests if tt.errorHandler != nil { rproxy.ErrorHandler = tt.errorHandler } frontendProxy := httptest.NewServer(rproxy) defer frontendProxy.Close() resp, err := http.Get(frontendProxy.URL + "/test") if err != nil { t.Fatalf("failed to reach proxy: %v", err) } if g, e := resp.StatusCode, tt.wantCode; g != e { t.Errorf("got res.StatusCode %d; expected %d", g, e) } resp.Body.Close() }) } } // Issue 16659: log errors from short read func TestReverseProxy_CopyBuffer(t *testing.T) { backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { out := "this call was relayed by the reverse proxy" // Coerce a wrong content length to induce io.UnexpectedEOF w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out)*2)) fmt.Fprintln(w, out) })) defer backendServer.Close() rpURL, err := url.Parse(backendServer.URL) if err != nil { t.Fatal(err) } var proxyLog bytes.Buffer rproxy := NewSingleHostReverseProxy(rpURL) rproxy.ErrorLog = log.New(&proxyLog, "", log.Lshortfile) donec := make(chan bool, 1) frontendProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { donec <- true }() rproxy.ServeHTTP(w, r) })) defer frontendProxy.Close() if _, err = frontendProxy.Client().Get(frontendProxy.URL); err == nil { t.Fatalf("want non-nil error") } // The race detector complains about the proxyLog usage in logf in copyBuffer // and our usage below with proxyLog.Bytes() so we're explicitly using a // channel to ensure that the ReverseProxy's ServeHTTP is done before we // continue after Get. <-donec expected := []string{ "EOF", "read", } for _, phrase := range expected { if !bytes.Contains(proxyLog.Bytes(), []byte(phrase)) { t.Errorf("expected log to contain phrase %q", phrase) } } } type staticTransport struct { res *http.Response } func (t *staticTransport) RoundTrip(r *http.Request) (*http.Response, error) { return t.res, nil } func BenchmarkServeHTTP(b *testing.B) { res := &http.Response{ StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), } proxy := &ReverseProxy{ Director: func(*http.Request) {}, Transport: &staticTransport{res}, } w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) b.ReportAllocs() for i := 0; i < b.N; i++ { proxy.ServeHTTP(w, r) } } func TestServeHTTPDeepCopy(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello Gopher!")) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } type result struct { before, after string } resultChan := make(chan result, 1) proxyHandler := NewSingleHostReverseProxy(backendURL) frontend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { before := r.URL.String() proxyHandler.ServeHTTP(w, r) after := r.URL.String() resultChan <- result{before: before, after: after} })) defer frontend.Close() want := result{before: "/", after: "/"} res, err := frontend.Client().Get(frontend.URL) if err != nil { t.Fatalf("Do: %v", err) } res.Body.Close() got := <-resultChan if got != want { t.Errorf("got = %+v; want = %+v", got, want) } } // Issue 18327: verify we always do a deep copy of the Request.Header map // before any mutations. func TestClonesRequestHeaders(t *testing.T) { log.SetOutput(io.Discard) defer log.SetOutput(os.Stderr) req, _ := http.NewRequest("GET", "http://foo.tld/", nil) req.RemoteAddr = "1.2.3.4:56789" rp := &ReverseProxy{ Director: func(req *http.Request) { req.Header.Set("From-Director", "1") }, Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { if v := req.Header.Get("From-Director"); v != "1" { t.Errorf("From-Directory value = %q; want 1", v) } return nil, io.EOF }), } rp.ServeHTTP(httptest.NewRecorder(), req) for _, h := range []string{ "From-Director", "X-Forwarded-For", } { if req.Header.Get(h) != "" { t.Errorf("%v header mutation modified caller's request", h) } } } type roundTripperFunc func(req *http.Request) (*http.Response, error) func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func TestModifyResponseClosesBody(t *testing.T) { req, _ := http.NewRequest("GET", "http://foo.tld/", nil) req.RemoteAddr = "1.2.3.4:56789" closeCheck := new(checkCloser) logBuf := new(strings.Builder) outErr := errors.New("ModifyResponse error") rp := &ReverseProxy{ Director: func(req *http.Request) {}, Transport: &staticTransport{&http.Response{ StatusCode: 200, Body: closeCheck, }}, ErrorLog: log.New(logBuf, "", 0), ModifyResponse: func(*http.Response) error { return outErr }, } rec := httptest.NewRecorder() rp.ServeHTTP(rec, req) res := rec.Result() if g, e := res.StatusCode, http.StatusBadGateway; g != e { t.Errorf("got res.StatusCode %d; expected %d", g, e) } if !closeCheck.closed { t.Errorf("body should have been closed") } if g, e := logBuf.String(), outErr.Error(); !strings.Contains(g, e) { t.Errorf("ErrorLog %q does not contain %q", g, e) } } type checkCloser struct { closed bool } func (cc *checkCloser) Close() error { cc.closed = true return nil } func (cc *checkCloser) Read(b []byte) (int, error) { return len(b), nil } // Issue 23643: panic on body copy error func TestReverseProxy_PanicBodyError(t *testing.T) { log.SetOutput(io.Discard) defer log.SetOutput(os.Stderr) backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { out := "this call was relayed by the reverse proxy" // Coerce a wrong content length to induce io.ErrUnexpectedEOF w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out)*2)) fmt.Fprintln(w, out) })) defer backendServer.Close() rpURL, err := url.Parse(backendServer.URL) if err != nil { t.Fatal(err) } rproxy := NewSingleHostReverseProxy(rpURL) // Ensure that the handler panics when the body read encounters an // io.ErrUnexpectedEOF defer func() { err := recover() if err == nil { t.Fatal("handler should have panicked") } if err != http.ErrAbortHandler { t.Fatal("expected ErrAbortHandler, got", err) } }() req, _ := http.NewRequest("GET", "http://foo.tld/", nil) rproxy.ServeHTTP(httptest.NewRecorder(), req) } /* Commented out because `neverEnding` is not available and not something I want to copy in. // Issue #46866: panic without closing incoming request body causes a panic func TestReverseProxy_PanicClosesIncomingBody(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { out := "this call was relayed by the reverse proxy" // Coerce a wrong content length to induce io.ErrUnexpectedEOF w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out)*2)) fmt.Fprintln(w, out) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests frontend := httptest.NewServer(proxyHandler) defer frontend.Close() frontendClient := frontend.Client() var wg sync.WaitGroup for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 10; j++ { const reqLen = 6 * 1024 * 1024 req, _ := http.NewRequest("POST", frontend.URL, &io.LimitedReader{R: neverEnding('x'), N: reqLen}) req.ContentLength = reqLen resp, _ := frontendClient.Transport.RoundTrip(req) if resp != nil { io.Copy(io.Discard, resp.Body) resp.Body.Close() } } }() } wg.Wait() } */ func TestSelectFlushInterval(t *testing.T) { tests := []struct { name string p *ReverseProxy res *http.Response want time.Duration }{ { name: "default", res: &http.Response{}, p: &ReverseProxy{FlushInterval: 123}, want: 123, }, { name: "server-sent events overrides non-zero", res: &http.Response{ Header: http.Header{ "Content-Type": {"text/event-stream"}, }, }, p: &ReverseProxy{FlushInterval: 123}, want: -1, }, { name: "server-sent events overrides zero", res: &http.Response{ Header: http.Header{ "Content-Type": {"text/event-stream"}, }, }, p: &ReverseProxy{FlushInterval: 0}, want: -1, }, { name: "server-sent events with media-type parameters overrides non-zero", res: &http.Response{ Header: http.Header{ "Content-Type": {"text/event-stream;charset=utf-8"}, }, }, p: &ReverseProxy{FlushInterval: 123}, want: -1, }, { name: "server-sent events with media-type parameters overrides zero", res: &http.Response{ Header: http.Header{ "Content-Type": {"text/event-stream;charset=utf-8"}, }, }, p: &ReverseProxy{FlushInterval: 0}, want: -1, }, { name: "Content-Length: -1, overrides non-zero", res: &http.Response{ ContentLength: -1, }, p: &ReverseProxy{FlushInterval: 123}, want: -1, }, { name: "Content-Length: -1, overrides zero", res: &http.Response{ ContentLength: -1, }, p: &ReverseProxy{FlushInterval: 0}, want: -1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.p.flushInterval(tt.res) if got != tt.want { t.Errorf("flushLatency = %v; want %v", got, tt.want) } }) } } func TestReverseProxyWebSocket(t *testing.T) { backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if upgradeType(r.Header) != "websocket" { t.Error("unexpected backend request") http.Error(w, "unexpected request", 400) return } c, _, err := w.(http.Hijacker).Hijack() if err != nil { t.Error(err) return } defer c.Close() io.WriteString(c, "HTTP/1.1 101 Switching Protocols\r\nConnection: upgrade\r\nUpgrade: WebSocket\r\n\r\n") bs := bufio.NewScanner(c) if !bs.Scan() { t.Errorf("backend failed to read line from client: %v", bs.Err()) return } fmt.Fprintf(c, "backend got %q\n", bs.Text()) })) defer backendServer.Close() backURL, _ := url.Parse(backendServer.URL) rproxy := NewSingleHostReverseProxy(backURL) rproxy.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests rproxy.ModifyResponse = func(res *http.Response) error { res.Header.Add("X-Modified", "true") return nil } handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.Header().Set("X-Header", "X-Value") rproxy.ServeHTTP(rw, req) if got, want := rw.Header().Get("X-Modified"), "true"; got != want { t.Errorf("response writer X-Modified header = %q; want %q", got, want) } }) frontendProxy := httptest.NewServer(handler) defer frontendProxy.Close() req, _ := http.NewRequest("GET", frontendProxy.URL, nil) req.Header.Set("Connection", "Upgrade") req.Header.Set("Upgrade", "websocket") c := frontendProxy.Client() res, err := c.Do(req) if err != nil { t.Fatal(err) } if res.StatusCode != 101 { t.Fatalf("status = %v; want 101", res.Status) } got := res.Header.Get("X-Header") want := "X-Value" if got != want { t.Errorf("Header(XHeader) = %q; want %q", got, want) } if !EqualFold(upgradeType(res.Header), "websocket") { t.Fatalf("not websocket upgrade; got %#v", res.Header) } rwc, ok := res.Body.(io.ReadWriteCloser) if !ok { t.Fatalf("response body is of type %T; does not implement ReadWriteCloser", res.Body) } defer rwc.Close() if got, want := res.Header.Get("X-Modified"), "true"; got != want { t.Errorf("response X-Modified header = %q; want %q", got, want) } io.WriteString(rwc, "Hello\n") bs := bufio.NewScanner(rwc) if !bs.Scan() { t.Fatalf("Scan: %v", bs.Err()) } got = bs.Text() want = `backend got "Hello"` if got != want { t.Errorf("got %#q, want %#q", got, want) } } func TestReverseProxyWebSocketCancellation(t *testing.T) { n := 5 triggerCancelCh := make(chan bool, n) nthResponse := func(i int) string { return fmt.Sprintf("backend response #%d\n", i) } terminalMsg := "final message" cst := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if g, ws := upgradeType(r.Header), "websocket"; g != ws { t.Errorf("Unexpected upgrade type %q, want %q", g, ws) http.Error(w, "Unexpected request", 400) return } conn, bufrw, err := w.(http.Hijacker).Hijack() if err != nil { t.Error(err) return } defer conn.Close() upgradeMsg := "HTTP/1.1 101 Switching Protocols\r\nConnection: upgrade\r\nUpgrade: WebSocket\r\n\r\n" if _, err := io.WriteString(conn, upgradeMsg); err != nil { t.Error(err) return } if _, _, err := bufrw.ReadLine(); err != nil { t.Errorf("Failed to read line from client: %v", err) return } for i := 0; i < n; i++ { if _, err := bufrw.WriteString(nthResponse(i)); err != nil { select { case <-triggerCancelCh: default: t.Errorf("Writing response #%d failed: %v", i, err) } return } bufrw.Flush() time.Sleep(time.Second) } if _, err := bufrw.WriteString(terminalMsg); err != nil { select { case <-triggerCancelCh: default: t.Errorf("Failed to write terminal message: %v", err) } } bufrw.Flush() })) defer cst.Close() backendURL, _ := url.Parse(cst.URL) rproxy := NewSingleHostReverseProxy(backendURL) rproxy.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests rproxy.ModifyResponse = func(res *http.Response) error { res.Header.Add("X-Modified", "true") return nil } handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.Header().Set("X-Header", "X-Value") ctx, cancel := context.WithCancel(req.Context()) go func() { <-triggerCancelCh cancel() }() rproxy.ServeHTTP(rw, req.WithContext(ctx)) }) frontendProxy := httptest.NewServer(handler) defer frontendProxy.Close() req, _ := http.NewRequest("GET", frontendProxy.URL, nil) req.Header.Set("Connection", "Upgrade") req.Header.Set("Upgrade", "websocket") res, err := frontendProxy.Client().Do(req) if err != nil { t.Fatalf("Dialing to frontend proxy: %v", err) } defer res.Body.Close() if g, w := res.StatusCode, 101; g != w { t.Fatalf("Switching protocols failed, got: %d, want: %d", g, w) } if g, w := res.Header.Get("X-Header"), "X-Value"; g != w { t.Errorf("X-Header mismatch\n\tgot: %q\n\twant: %q", g, w) } if g, w := upgradeType(res.Header), "websocket"; !EqualFold(g, w) { t.Fatalf("Upgrade header mismatch\n\tgot: %q\n\twant: %q", g, w) } rwc, ok := res.Body.(io.ReadWriteCloser) if !ok { t.Fatalf("Response body type mismatch, got %T, want io.ReadWriteCloser", res.Body) } if got, want := res.Header.Get("X-Modified"), "true"; got != want { t.Errorf("response X-Modified header = %q; want %q", got, want) } if _, err := io.WriteString(rwc, "Hello\n"); err != nil { t.Fatalf("Failed to write first message: %v", err) } // Read loop. br := bufio.NewReader(rwc) for { line, err := br.ReadString('\n') switch { case line == terminalMsg: // this case before "err == io.EOF" t.Fatalf("The websocket request was not canceled, unfortunately!") case err == io.EOF: return case err != nil: t.Fatalf("Unexpected error: %v", err) case line == nthResponse(0): // We've gotten the first response back // Let's trigger a cancel. close(triggerCancelCh) } } } func TestUnannouncedTrailer(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.(http.Flusher).Flush() w.Header().Set(http.TrailerPrefix+"X-Unannounced-Trailer", "unannounced_trailer_value") })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests frontend := httptest.NewServer(proxyHandler) defer frontend.Close() frontendClient := frontend.Client() res, err := frontendClient.Get(frontend.URL) if err != nil { t.Fatalf("Get: %v", err) } io.ReadAll(res.Body) if g, w := res.Trailer.Get("X-Unannounced-Trailer"), "unannounced_trailer_value"; g != w { t.Errorf("Trailer(X-Unannounced-Trailer) = %q; want %q", g, w) } } func TestSetURL(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(r.Host)) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := &ReverseProxy{ Rewrite: func(r *ProxyRequest) { r.SetURL(backendURL) }, } frontend := httptest.NewServer(proxyHandler) defer frontend.Close() frontendClient := frontend.Client() res, err := frontendClient.Get(frontend.URL) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { t.Fatalf("Reading body: %v", err) } if got, want := string(body), backendURL.Host; got != want { t.Errorf("backend got Host %q, want %q", got, want) } } func TestSingleJoinSlash(t *testing.T) { tests := []struct { slasha string slashb string expected string }{ {"https://www.google.com/", "/favicon.ico", "https://www.google.com/favicon.ico"}, {"https://www.google.com", "/favicon.ico", "https://www.google.com/favicon.ico"}, {"https://www.google.com", "favicon.ico", "https://www.google.com/favicon.ico"}, {"https://www.google.com", "", "https://www.google.com/"}, {"", "favicon.ico", "/favicon.ico"}, } for _, tt := range tests { if got := singleJoiningSlash(tt.slasha, tt.slashb); got != tt.expected { t.Errorf("singleJoiningSlash(%q,%q) want %q got %q", tt.slasha, tt.slashb, tt.expected, got) } } } func TestJoinURLPath(t *testing.T) { tests := []struct { a *url.URL b *url.URL wantPath string wantRaw string }{ {&url.URL{Path: "/a/b"}, &url.URL{Path: "/c"}, "/a/b/c", ""}, {&url.URL{Path: "/a/b", RawPath: "badpath"}, &url.URL{Path: "c"}, "/a/b/c", "/a/b/c"}, {&url.URL{Path: "/a/b", RawPath: "/a%2Fb"}, &url.URL{Path: "/c"}, "/a/b/c", "/a%2Fb/c"}, {&url.URL{Path: "/a/b", RawPath: "/a%2Fb"}, &url.URL{Path: "/c"}, "/a/b/c", "/a%2Fb/c"}, {&url.URL{Path: "/a/b/", RawPath: "/a%2Fb%2F"}, &url.URL{Path: "c"}, "/a/b//c", "/a%2Fb%2F/c"}, {&url.URL{Path: "/a/b/", RawPath: "/a%2Fb/"}, &url.URL{Path: "/c/d", RawPath: "/c%2Fd"}, "/a/b/c/d", "/a%2Fb/c%2Fd"}, } for _, tt := range tests { p, rp := joinURLPath(tt.a, tt.b) if p != tt.wantPath || rp != tt.wantRaw { t.Errorf("joinURLPath(URL(%q,%q),URL(%q,%q)) want (%q,%q) got (%q,%q)", tt.a.Path, tt.a.RawPath, tt.b.Path, tt.b.RawPath, tt.wantPath, tt.wantRaw, p, rp) } } } func TestReverseProxyRewriteReplacesOut(t *testing.T) { const content = "response_content" backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(content)) })) defer backend.Close() proxyHandler := &ReverseProxy{ Rewrite: func(r *ProxyRequest) { r.Out, _ = http.NewRequest("GET", backend.URL, nil) }, } frontend := httptest.NewServer(proxyHandler) defer frontend.Close() res, err := frontend.Client().Get(frontend.URL) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) if got, want := string(body), content; got != want { t.Errorf("got response %q, want %q", got, want) } } func Test1xxHeadersNotModifiedAfterRoundTrip(t *testing.T) { // https://go.dev/issue/65123: We use httptrace.Got1xxResponse to capture 1xx responses // and proxy them. httptrace handlers can execute after RoundTrip returns, in particular // after experiencing connection errors. When this happens, we shouldn't modify the // ResponseWriter headers after ReverseProxy.ServeHTTP returns. backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for i := 0; i < 5; i++ { w.WriteHeader(103) } })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests rw := &testResponseWriter{} func() { // Cancel the request (and cause RoundTrip to return) immediately upon // seeing a 1xx response. ctx, cancel := context.WithCancel(context.Background()) defer cancel() ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ Got1xxResponse: func(code int, header textproto.MIMEHeader) error { cancel() return nil }, }) req, _ := http.NewRequestWithContext(ctx, "GET", "http://go.dev/", nil) proxyHandler.ServeHTTP(rw, req) }() // Trigger data race while iterating over response headers. // When run with -race, this causes the condition in https://go.dev/issue/65123 often // enough to detect reliably. for _ = range rw.Header() { } } func Test1xxResponses(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Add("Link", "; rel=preload; as=style") h.Add("Link", "; rel=preload; as=script") w.WriteHeader(http.StatusEarlyHints) h.Add("Link", "; rel=preload; as=script") w.WriteHeader(http.StatusProcessing) w.Write([]byte("Hello")) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := NewSingleHostReverseProxy(backendURL) proxyHandler.ErrorLog = log.New(io.Discard, "", 0) // quiet for tests frontend := httptest.NewServer(proxyHandler) defer frontend.Close() frontendClient := frontend.Client() checkLinkHeaders := func(t *testing.T, expected, got []string) { t.Helper() if len(expected) != len(got) { t.Errorf("Expected %d link headers; got %d", len(expected), len(got)) } for i := range expected { if i >= len(got) { t.Errorf("Expected %q link header; got nothing", expected[i]) continue } if expected[i] != got[i] { t.Errorf("Expected %q link header; got %q", expected[i], got[i]) } } } var respCounter uint8 trace := &httptrace.ClientTrace{ Got1xxResponse: func(code int, header textproto.MIMEHeader) error { switch code { case http.StatusEarlyHints: checkLinkHeaders(t, []string{"; rel=preload; as=style", "; rel=preload; as=script"}, header["Link"]) case http.StatusProcessing: checkLinkHeaders(t, []string{"; rel=preload; as=style", "; rel=preload; as=script", "; rel=preload; as=script"}, header["Link"]) default: t.Error("Unexpected 1xx response") } respCounter++ return nil }, } req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), "GET", frontend.URL, nil) res, err := frontendClient.Do(req) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() if respCounter != 2 { t.Errorf("Expected 2 1xx responses; got %d", respCounter) } checkLinkHeaders(t, []string{"; rel=preload; as=style", "; rel=preload; as=script", "; rel=preload; as=script"}, res.Header["Link"]) body, _ := io.ReadAll(res.Body) if string(body) != "Hello" { t.Errorf("Read body %q; want Hello", body) } } const ( testWantsCleanQuery = true testWantsRawQuery = false ) func TestReverseProxyQueryParameterSmugglingDirectorDoesNotParseForm(t *testing.T) { testReverseProxyQueryParameterSmuggling(t, testWantsRawQuery, func(u *url.URL) *ReverseProxy { proxyHandler := NewSingleHostReverseProxy(u) oldDirector := proxyHandler.Director proxyHandler.Director = func(r *http.Request) { oldDirector(r) } return proxyHandler }) } func TestReverseProxyQueryParameterSmugglingDirectorParsesForm(t *testing.T) { testReverseProxyQueryParameterSmuggling(t, testWantsCleanQuery, func(u *url.URL) *ReverseProxy { proxyHandler := NewSingleHostReverseProxy(u) oldDirector := proxyHandler.Director proxyHandler.Director = func(r *http.Request) { // Parsing the form causes ReverseProxy to remove unparsable // query parameters before forwarding. r.FormValue("a") oldDirector(r) } return proxyHandler }) } func TestReverseProxyQueryParameterSmugglingRewrite(t *testing.T) { testReverseProxyQueryParameterSmuggling(t, testWantsCleanQuery, func(u *url.URL) *ReverseProxy { return &ReverseProxy{ Rewrite: func(r *ProxyRequest) { r.SetURL(u) }, } }) } func TestReverseProxyQueryParameterSmugglingRewritePreservesRawQuery(t *testing.T) { testReverseProxyQueryParameterSmuggling(t, testWantsRawQuery, func(u *url.URL) *ReverseProxy { return &ReverseProxy{ Rewrite: func(r *ProxyRequest) { r.SetURL(u) r.Out.URL.RawQuery = r.In.URL.RawQuery }, } }) } func testReverseProxyQueryParameterSmuggling(t *testing.T, wantCleanQuery bool, newProxy func(*url.URL) *ReverseProxy) { const content = "response_content" backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(r.URL.RawQuery)) })) defer backend.Close() backendURL, err := url.Parse(backend.URL) if err != nil { t.Fatal(err) } proxyHandler := newProxy(backendURL) frontend := httptest.NewServer(proxyHandler) defer frontend.Close() // Don't spam output with logs of queries containing semicolons. backend.Config.ErrorLog = log.New(io.Discard, "", 0) frontend.Config.ErrorLog = log.New(io.Discard, "", 0) for _, test := range []struct { rawQuery string cleanQuery string }{{ rawQuery: "a=1&a=2;b=3", cleanQuery: "a=1", }, { rawQuery: "a=1&a=%zz&b=3", cleanQuery: "a=1&b=3", }} { res, err := frontend.Client().Get(frontend.URL + "?" + test.rawQuery) if err != nil { t.Fatalf("Get: %v", err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) wantQuery := test.rawQuery if wantCleanQuery { wantQuery = test.cleanQuery } if got, want := string(body), wantQuery; got != want { t.Errorf("proxy forwarded raw query %q as %q, want %q", test.rawQuery, got, want) } } } type testResponseWriter struct { h http.Header writeHeader func(int) write func([]byte) (int, error) } func (rw *testResponseWriter) Header() http.Header { if rw.h == nil { rw.h = make(http.Header) } return rw.h } func (rw *testResponseWriter) WriteHeader(statusCode int) { if rw.writeHeader != nil { rw.writeHeader(statusCode) } } func (rw *testResponseWriter) Write(p []byte) (int, error) { if rw.write != nil { return rw.write(p) } return len(p), nil } ================================================ FILE: internal/iso/ipam.go ================================================ package iso import ( "fmt" "net" "net/netip" "strings" "github.com/tinkerbell/smee/internal/dhcp/data" ) func parseIPAM(d *data.DHCP) string { if d == nil { return "" } // return format is ipam=:::::::: ipam := make([]string, 9) ipam[0] = func() string { m := d.MACAddress.String() return strings.ReplaceAll(m, ":", "-") }() ipam[1] = func() string { if d.VLANID != "" { return d.VLANID } return "" }() ipam[2] = func() string { if d.IPAddress.Compare(netip.Addr{}) != 0 { return d.IPAddress.String() } return "" }() ipam[3] = func() string { if d.SubnetMask != nil { return net.IP(d.SubnetMask).String() } return "" }() ipam[4] = func() string { if d.DefaultGateway.Compare(netip.Addr{}) != 0 { return d.DefaultGateway.String() } return "" }() ipam[5] = d.Hostname ipam[6] = func() string { var nameservers []string for _, e := range d.NameServers { nameservers = append(nameservers, e.String()) } if len(nameservers) > 0 { return strings.Join(nameservers, ",") } return "" }() ipam[7] = func() string { if len(d.DomainSearch) > 0 { return strings.Join(d.DomainSearch, ",") } return "" }() ipam[8] = func() string { var ntp []string for _, e := range d.NTPServers { ntp = append(ntp, e.String()) } if len(ntp) > 0 { return strings.Join(ntp, ",") } return "" }() return fmt.Sprintf("ipam=%s", strings.Join(ipam, ":")) } ================================================ FILE: internal/iso/ipam_test.go ================================================ package iso import ( "net" "net/netip" "testing" "github.com/google/go-cmp/cmp" "github.com/tinkerbell/smee/internal/dhcp/data" ) func TestParseIPAM(t *testing.T) { tests := map[string]struct { input *data.DHCP want string }{ "empty": {}, "only MAC": { input: &data.DHCP{MACAddress: net.HardwareAddr{0xde, 0xed, 0xbe, 0xef, 0xfe, 0xed}}, want: "ipam=de-ed-be-ef-fe-ed::::::::", }, "everything": { input: &data.DHCP{ MACAddress: net.HardwareAddr{0xde, 0xed, 0xbe, 0xef, 0xfe, 0xed}, IPAddress: netip.AddrFrom4([4]byte{127, 0, 0, 1}), SubnetMask: net.IPv4Mask(255, 255, 255, 0), DefaultGateway: netip.AddrFrom4([4]byte{127, 0, 0, 2}), NameServers: []net.IP{{1, 1, 1, 1}, {4, 4, 4, 4}}, Hostname: "myhost", NTPServers: []net.IP{{129, 6, 15, 28}, {129, 6, 15, 29}}, DomainSearch: []string{"example.com", "example.org"}, VLANID: "400", }, want: "ipam=de-ed-be-ef-fe-ed:400:127.0.0.1:255.255.255.0:127.0.0.2:myhost:1.1.1.1,4.4.4.4:example.com,example.org:129.6.15.28,129.6.15.29", }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { got := parseIPAM(tt.input) if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("diff: %v", diff) } }) } } ================================================ FILE: internal/iso/iso.go ================================================ package iso import ( "bytes" "context" "crypto/rand" "errors" "fmt" "io" "math/big" "net" "net/http" "net/url" "path" "path/filepath" "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" "github.com/go-logr/logr" "github.com/tinkerbell/smee/internal/dhcp/data" "github.com/tinkerbell/smee/internal/iso/internal" ) const ( defaultConsoles = "console=ttyAMA0 console=ttyS0 console=tty0 console=tty1 console=ttyS1" ) // BackendReader is an interface that defines the method to read data from a backend. type BackendReader interface { // Read data (from a backend) based on a mac address // and return DHCP headers and options, including netboot info. GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) } // Handler is a struct that contains the necessary fields to patch an ISO file with // relevant information for the Tink worker. type Handler struct { Backend BackendReader ExtraKernelParams []string Logger logr.Logger // MagicString is the string pattern that will be matched // in the source iso before patching. The field can be set // during build time by setting this field. // Ref: https://github.com/tinkerbell/hook/blob/main/linuxkit-templates/hook.template.yaml MagicString string // SourceISO is the source url where the unmodified iso lives. // It must be a valid url.URL{} object and must have a url.URL{}.Scheme of HTTP or HTTPS. SourceISO string Syslog string TinkServerTLS bool TinkServerGRPCAddr string StaticIPAMEnabled bool // parsedURL derives a url.URL from the SourceISO field. // It needed for validation of SourceISO and easier modification. parsedURL *url.URL magicStrPadding []byte } // HandlerFunc returns a reverse proxy HTTP handler function that performs ISO patching. func (h *Handler) HandlerFunc() (http.HandlerFunc, error) { target, err := url.Parse(h.SourceISO) if err != nil { return nil, err } h.parsedURL = target proxy := &internal.ReverseProxy{ Rewrite: func(r *internal.ProxyRequest) { r.SetURL(target) }, } proxy.Transport = h proxy.FlushInterval = -1 proxy.CopyBuffer = h h.magicStrPadding = bytes.Repeat([]byte{' '}, len(h.MagicString)) return proxy.ServeHTTP, nil } // Copy implements the internal.CopyBuffer interface. // This implementation allows us to inspect and patch content on its way to the client without buffering the entire response // in memory. This allows memory use to be constant regardless of the size of the response. func (h *Handler) Copy(ctx context.Context, dst io.Writer, src io.Reader, buf []byte) (int64, error) { if len(buf) == 0 { buf = make([]byte, 32*1024) } var written int64 for { nr, rerr := src.Read(buf) if rerr != nil && rerr != io.EOF && rerr != context.Canceled { //nolint: errorlint // going to defer to the stdlib on this one. h.Logger.Info("httputil: ReverseProxy read error during body copy: %v", rerr) } if nr > 0 { // This is the patching check and handling. b := buf[:nr] i := bytes.Index(b, []byte(h.MagicString)) if i != -1 { dup := make([]byte, len(b)) copy(dup, b) copy(dup[i:], h.magicStrPadding) copy(dup[i:], internal.GetPatch(ctx)) b = dup } nw, werr := dst.Write(b) if nw > 0 { written += int64(nw) } if werr != nil { return written, werr } if nr != nw { return written, io.ErrShortWrite } } if rerr != nil { if rerr == io.EOF { rerr = nil } return written, rerr } } } // RoundTrip is a method on the Handler struct that implements the http.RoundTripper interface. // This method is called by the internal.NewSingleHostReverseProxy to handle the incoming request. // The method is responsible for validating the incoming request and getting the source ISO. func (h *Handler) RoundTrip(req *http.Request) (*http.Response, error) { log := h.Logger.WithValues("method", req.Method, "inboundURI", req.RequestURI, "remoteAddr", req.RemoteAddr) log.V(1).Info("starting the ISO patching HTTP handler") if filepath.Ext(req.URL.Path) != ".iso" { log.Info("extension not supported, only supported extension is '.iso'") return &http.Response{ Status: fmt.Sprintf("%d %s", http.StatusNotFound, http.StatusText(http.StatusNotFound)), StatusCode: http.StatusNotFound, Body: http.NoBody, Request: req, }, nil } // The incoming request url is expected to have the mac address present. // Fetch the mac and validate if there's a hardware object // associated with the mac. // // We serve the iso only if this validation passes. ha, err := getMAC(req.URL.Path) if err != nil { log.Info("unable to parse mac address in the URL path", "error", err) return &http.Response{ Status: fmt.Sprintf("%d %s", http.StatusBadRequest, http.StatusText(http.StatusBadRequest)), StatusCode: http.StatusBadRequest, Body: http.NoBody, Request: req, }, nil } fac, dhcpData, err := h.getFacility(req.Context(), ha, h.Backend) if err != nil { log.Info("unable to get the hardware object", "error", err, "mac", ha) if apierrors.IsNotFound(err) { return &http.Response{ Status: fmt.Sprintf("%d %s", http.StatusNotFound, http.StatusText(http.StatusNotFound)), StatusCode: http.StatusNotFound, Body: http.NoBody, Request: req, }, nil } return &http.Response{ Status: fmt.Sprintf("%d %s", http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)), StatusCode: http.StatusInternalServerError, Body: http.NoBody, Request: req, }, nil } // The hardware object doesn't contain a dedicated field for consoles right now and // historically the facility is used as a way to define consoles on a per Hardware basis. var consoles string switch { case fac != "" && strings.Contains(fac, "console="): consoles = fmt.Sprintf("facility=%s", fac) case fac != "": consoles = fmt.Sprintf("facility=%s %s", fac, defaultConsoles) default: consoles = defaultConsoles } // The patch is added to the request context so that it can be used in the Copy method. req = req.WithContext(internal.WithPatch(req.Context(), []byte(h.constructPatch(consoles, ha.String(), dhcpData)))) // The internal.NewSingleHostReverseProxy takes the incoming request url and adds the path to the target (h.SourceISO). // This function is more than a pass through proxy. The MAC address in the url path is required to do hardware lookups using the backend reader // and is not used when making http calls to the target (h.SourceISO). All valid requests are passed through to the target. req.URL.Path = h.parsedURL.Path log = log.WithValues("outboundURL", req.URL.String()) // RoundTripper needs a Transport to execute a HTTP transaction // For our use case the default transport will suffice. resp, err := http.DefaultTransport.RoundTrip(req) if err != nil { log.Error(err, "issue getting the source ISO", "sourceIso", h.SourceISO) return nil, err } // by setting this header we are telling the logging middleware to not log its default log message. // we do this because there are a lot of partial content requests and it allow this handler to take care of logging. resp.Header.Set("X-Global-Logging", "false") if resp.StatusCode == http.StatusPartialContent { // 0.002% of the time we log a 206 request message. // In testing, it was observed that about 3000 HTTP 206 requests are made per ISO mount. // 0.002% gives us about 5 - 10, log messages per ISO mount. // We're optimizing for showing "enough" log messages so that progress can be observed. if p := randomPercentage(100000); p < 0.002 { log.Info("206 status code response", "sourceIso", h.SourceISO, "status", resp.Status) } } else { log.Info("response received", "sourceIso", h.SourceISO, "status", resp.Status) } log.V(1).Info("roundtrip complete") return resp, nil } func (h *Handler) constructPatch(console, mac string, d *data.DHCP) string { syslogHost := fmt.Sprintf("syslog_host=%s", h.Syslog) grpcAuthority := fmt.Sprintf("grpc_authority=%s", h.TinkServerGRPCAddr) tinkerbellTLS := fmt.Sprintf("tinkerbell_tls=%v", h.TinkServerTLS) workerID := fmt.Sprintf("worker_id=%s", mac) vlanID := func() string { if d != nil && d.VLANID != "" { return fmt.Sprintf("vlan_id=%s", d.VLANID) } return "" }() hwAddr := fmt.Sprintf("hw_addr=%s", mac) all := []string{strings.Join(h.ExtraKernelParams, " "), console, vlanID, hwAddr, syslogHost, grpcAuthority, tinkerbellTLS, workerID} if h.StaticIPAMEnabled { all = append(all, parseIPAM(d)) } return strings.Join(all, " ") } func getMAC(urlPath string) (net.HardwareAddr, error) { mac := path.Base(path.Dir(urlPath)) hw, err := net.ParseMAC(mac) if err != nil { return nil, fmt.Errorf("failed to parse URL path: %s , the second to last element in the URL path must be a valid mac address, err: %w", urlPath, err) } return hw, nil } func (h *Handler) getFacility(ctx context.Context, mac net.HardwareAddr, br BackendReader) (string, *data.DHCP, error) { if br == nil { return "", nil, errors.New("backend is nil") } d, n, err := br.GetByMac(ctx, mac) if err != nil { return "", nil, err } return n.Facility, d, nil } func randomPercentage(precision int64) float64 { random, err := rand.Int(rand.Reader, big.NewInt(precision)) if err != nil { return 0 } return float64(random.Int64()) / float64(precision) } ================================================ FILE: internal/iso/iso_test.go ================================================ package iso import ( "bytes" "context" "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" "os" "testing" diskfs "github.com/diskfs/go-diskfs" "github.com/diskfs/go-diskfs/disk" "github.com/diskfs/go-diskfs/filesystem" "github.com/diskfs/go-diskfs/filesystem/iso9660" "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "github.com/tinkerbell/smee/internal/dhcp/data" ) const magicString = `464vn90e7rbj08xbwdjejmdf4it17c5zfzjyfhthbh19eij201hjgit021bmpdb9ctrc87x2ymc8e7icu4ffi15x1hah9iyaiz38ckyap8hwx2vt5rm44ixv4hau8iw718q5yd019um5dt2xpqqa2rjtdypzr5v1gun8un110hhwp8cex7pqrh2ivh0ynpm4zkkwc8wcn367zyethzy7q8hzudyeyzx3cgmxqbkh825gcak7kxzjbgjajwizryv7ec1xm2h0hh7pz29qmvtgfjj1vphpgq1zcbiiehv52wrjy9yq473d9t1rvryy6929nk435hfx55du3ih05kn5tju3vijreru1p6knc988d4gfdz28eragvryq5x8aibe5trxd0t6t7jwxkde34v6pj1khmp50k6qqj3nzgcfzabtgqkmeqhdedbvwf3byfdma4nkv3rcxugaj2d0ru30pa2fqadjqrtjnv8bu52xzxv7irbhyvygygxu1nt5z4fh9w1vwbdcmagep26d298zknykf2e88kumt59ab7nq79d8amnhhvbexgh48e8qc61vq2e9qkihzt1twk1ijfgw70nwizai15iqyted2dt9gfmf2gg7amzufre79hwqkddc1cd935ywacnkrnak6r7xzcz7zbmq3kt04u2hg1iuupid8rt4nyrju51e6uejb2ruu36g9aibmz3hnmvazptu8x5tyxk820g2cdpxjdij766bt2n3djur7v623a2v44juyfgz80ekgfb9hkibpxh3zgknw8a34t4jifhf116x15cei9hwch0fye3xyq0acuym8uhitu5evc4rag3ui0fny3qg4kju7zkfyy8hwh537urd5uixkzwu5bdvafz4jmv7imypj543xg5em8jk8cgk7c4504xdd5e4e71ihaumt6u5u2t1w7um92fepzae8p0vq93wdrd1756npu1pziiur1payc7kmdwyxg3hj5n4phxbc29x0tcddamjrwt260b0w` func TestReqPathInvalid(t *testing.T) { tests := map[string]struct { isoURL string statusCode int }{ "invalid URL prefix": {isoURL: "invalid", statusCode: http.StatusNotFound}, "invalid URL": {isoURL: "http://invalid.:123/hook.iso", statusCode: http.StatusBadRequest}, "no script or url": {isoURL: "http://10.10.10.10:8080/aa:aa:aa:aa:aa:aa/invalid.iso", statusCode: http.StatusInternalServerError}, } for name, tt := range tests { u, _ := url.Parse(tt.isoURL) t.Run(name, func(t *testing.T) { h := &Handler{ parsedURL: u, } req := http.Request{ Method: http.MethodGet, URL: u, } got, err := h.RoundTrip(&req) got.Body.Close() if err != nil { t.Fatal(err) } if got.StatusCode != tt.statusCode { t.Fatalf("got response status code: %d, want status code: %d", got.StatusCode, tt.statusCode) } }) } } func TestCreateISO(t *testing.T) { t.Skip("Unskip this test to create a new ISO file") grubCfg := `set timeout=0 set gfxpayload=text menuentry 'LinuxKit ISO Image' { linuxefi /kernel 464vn90e7rbj08xbwdjejmdf4it17c5zfzjyfhthbh19eij201hjgit021bmpdb9ctrc87x2ymc8e7icu4ffi15x1hah9iyaiz38ckyap8hwx2vt5rm44ixv4hau8iw718q5yd019um5dt2xpqqa2rjtdypzr5v1gun8un110hhwp8cex7pqrh2ivh0ynpm4zkkwc8wcn367zyethzy7q8hzudyeyzx3cgmxqbkh825gcak7kxzjbgjajwizryv7ec1xm2h0hh7pz29qmvtgfjj1vphpgq1zcbiiehv52wrjy9yq473d9t1rvryy6929nk435hfx55du3ih05kn5tju3vijreru1p6knc988d4gfdz28eragvryq5x8aibe5trxd0t6t7jwxkde34v6pj1khmp50k6qqj3nzgcfzabtgqkmeqhdedbvwf3byfdma4nkv3rcxugaj2d0ru30pa2fqadjqrtjnv8bu52xzxv7irbhyvygygxu1nt5z4fh9w1vwbdcmagep26d298zknykf2e88kumt59ab7nq79d8amnhhvbexgh48e8qc61vq2e9qkihzt1twk1ijfgw70nwizai15iqyted2dt9gfmf2gg7amzufre79hwqkddc1cd935ywacnkrnak6r7xzcz7zbmq3kt04u2hg1iuupid8rt4nyrju51e6uejb2ruu36g9aibmz3hnmvazptu8x5tyxk820g2cdpxjdij766bt2n3djur7v623a2v44juyfgz80ekgfb9hkibpxh3zgknw8a34t4jifhf116x15cei9hwch0fye3xyq0acuym8uhitu5evc4rag3ui0fny3qg4kju7zkfyy8hwh537urd5uixkzwu5bdvafz4jmv7imypj543xg5em8jk8cgk7c4504xdd5e4e71ihaumt6u5u2t1w7um92fepzae8p0vq93wdrd1756npu1pziiur1payc7kmdwyxg3hj5n4phxbc29x0tcddamjrwt260b0w text initrdefi /initrd.img } ` if err := os.Remove("testdata/output.iso"); err != nil && !os.IsNotExist(err) { t.Fatal(err) } var diskSize int64 = 51200 // 50Kb mydisk, err := diskfs.Create("./testdata/output.iso", diskSize, diskfs.SectorSizeDefault) if err != nil { t.Fatal(err) } defer mydisk.Close() // the following line is required for an ISO, which may have logical block sizes // only of 2048, 4096, 8192 mydisk.LogicalBlocksize = 2048 fspec := disk.FilesystemSpec{Partition: 0, FSType: filesystem.TypeISO9660, VolumeLabel: "label"} fs, err := mydisk.CreateFilesystem(fspec) if err != nil { t.Fatal(err) } if err := fs.Mkdir("EFI/BOOT"); err != nil { t.Fatal(err) } rw, err := fs.OpenFile("EFI/BOOT/grub.cfg", os.O_CREATE|os.O_RDWR) if err != nil { t.Fatal(err) } content := []byte(grubCfg) _, err = rw.Write(content) if err != nil { t.Fatal(err) } iso, ok := fs.(*iso9660.FileSystem) if !ok { t.Fatal(fmt.Errorf("not an iso9660 filesystem")) } err = iso.Finalize(iso9660.FinalizeOptions{}) if err != nil { t.Fatal(err) } } func TestPatching(t *testing.T) { // create a small ISO file with the magic string // serve ISO with a http server // patch the ISO file // mount the ISO file and check if the magic string was patched // If anything changes here the space padding will be different. Be sure to update it accordingly. wantGrubCfg := `set timeout=0 set gfxpayload=text menuentry 'LinuxKit ISO Image' { linuxefi /kernel facility=test console=ttyAMA0 console=ttyS0 console=tty0 console=tty1 console=ttyS1 hw_addr=de:ed:be:ef:fe:ed syslog_host=127.0.0.1:514 grpc_authority=127.0.0.1:42113 tinkerbell_tls=false worker_id=de:ed:be:ef:fe:ed text initrdefi /initrd.img }` // This expects that testdata/output.iso exists. Run the TestCreateISO test to create it. // serve it with a http server hs := httptest.NewServer(http.FileServer(http.Dir("./testdata"))) defer hs.Close() // patch the ISO file u := hs.URL + "/output.iso" parsedURL, err := url.Parse(u) if err != nil { t.Fatal(err) } h := &Handler{ Logger: logr.Discard(), Backend: &mockBackend{}, SourceISO: u, ExtraKernelParams: []string{}, Syslog: "127.0.0.1:514", TinkServerTLS: false, TinkServerGRPCAddr: "127.0.0.1:42113", parsedURL: parsedURL, MagicString: magicString, } h.magicStrPadding = bytes.Repeat([]byte{' '}, len(h.MagicString)) // for debugging enable a logger // h.Logger = logr.FromSlogHandler(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})) hf, err := h.HandlerFunc() if err != nil { t.Fatal(err) } w := httptest.NewRecorder() hf.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/iso/de:ed:be:ef:fe:ed/output.iso", nil)) res := w.Result() defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("got status code: %d, want status code: %d", res.StatusCode, http.StatusOK) } isoContents, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } idx := bytes.Index(isoContents, []byte(`set timeout=0`)) if idx == -1 { t.Fatalf("could not find the expected grub.cfg contents in the ISO") } contents := isoContents[idx : idx+len(wantGrubCfg)] if diff := cmp.Diff(wantGrubCfg, string(contents)); diff != "" { t.Fatalf("patched grub.cfg contents don't match expected: %v", diff) } } type mockBackend struct{} func (m *mockBackend) GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { d := &data.DHCP{} n := &data.Netboot{ Facility: "test", } return d, n, nil } func (m *mockBackend) GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) { d := &data.DHCP{} n := &data.Netboot{ Facility: "test", } return d, n, nil } ================================================ FILE: internal/metric/metric.go ================================================ package metric import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) var ( DHCPTotal *prometheus.CounterVec DiscoverDuration prometheus.ObserverVec HardwareDiscovers *prometheus.CounterVec DiscoversInProgress *prometheus.GaugeVec JobDuration prometheus.ObserverVec JobsTotal *prometheus.CounterVec JobsInProgress *prometheus.GaugeVec ) func Init() { DHCPTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "dhcp_total", Help: "Number of DHCP Requests handled.", }, []string{"op", "type", "giaddr"}) labelValues := []prometheus.Labels{ {"op": "recv", "type": "DHCPACK", "giaddr": "0.0.0.0"}, {"op": "recv", "type": "DHCPDECLINE", "giaddr": "0.0.0.0"}, {"op": "recv", "type": "DHCPDISCOVER", "giaddr": "0.0.0.0"}, {"op": "recv", "type": "DHCPINFORM", "giaddr": "0.0.0.0"}, {"op": "recv", "type": "DHCPNAK", "giaddr": "0.0.0.0"}, {"op": "recv", "type": "DHCPOFFER", "giaddr": "0.0.0.0"}, {"op": "recv", "type": "DHCPRELEASE", "giaddr": "0.0.0.0"}, {"op": "recv", "type": "DHCPREQUEST", "giaddr": "0.0.0.0"}, {"op": "send", "type": "DHCPOFFER", "giaddr": "0.0.0.0"}, } initCounterLabels(DHCPTotal, labelValues) labelValues = []prometheus.Labels{ {"from": "dhcp"}, {"from": "ip"}, } DiscoverDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "discover_duration_seconds", Help: "Duration taken to get a response for a newly discovered request.", Buckets: prometheus.LinearBuckets(.01, .05, 10), }, []string{"from"}) HardwareDiscovers = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "discover_total", Help: "Number of discover requests requested.", }, []string{"from"}) DiscoversInProgress = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "discover_in_progress", Help: "Number of discover requests that have yet to receive a response.", }, []string{"from"}) initObserverLabels(DiscoverDuration, labelValues) initCounterLabels(HardwareDiscovers, labelValues) initGaugeLabels(DiscoversInProgress, labelValues) JobDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "jobs_duration_seconds", Help: "Duration taken for a job to complete.", Buckets: prometheus.LinearBuckets(.01, .05, 10), }, []string{"from", "op"}) JobsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "jobs_total", Help: "Number of jobs.", }, []string{"from", "op"}) JobsInProgress = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "jobs_in_progress", Help: "Number of jobs waiting to complete.", }, []string{"from", "op"}) labelValues = []prometheus.Labels{ {"from": "dhcp", "op": "DHCPACK"}, {"from": "dhcp", "op": "DHCPDECLINE"}, {"from": "dhcp", "op": "DHCPDISCOVER"}, {"from": "dhcp", "op": "DHCPINFORM"}, {"from": "dhcp", "op": "DHCPNAK"}, {"from": "dhcp", "op": "DHCPOFFER"}, {"from": "dhcp", "op": "DHCPRELEASE"}, {"from": "dhcp", "op": "DHCPREQUEST"}, {"from": "http", "op": "file"}, {"from": "http", "op": "hardware-components"}, {"from": "http", "op": "phone-home"}, {"from": "http", "op": "problem"}, {"from": "http", "op": "event"}, {"from": "tftp", "op": "read"}, } initObserverLabels(JobDuration, labelValues) initCounterLabels(JobsTotal, labelValues) initGaugeLabels(JobsInProgress, labelValues) } func initCounterLabels(m *prometheus.CounterVec, l []prometheus.Labels) { for _, labels := range l { m.With(labels) } } func initGaugeLabels(m *prometheus.GaugeVec, l []prometheus.Labels) { for _, labels := range l { m.With(labels) } } func initObserverLabels(m prometheus.ObserverVec, l []prometheus.Labels) { for _, labels := range l { m.With(labels) } } ================================================ FILE: internal/otel/otel.go ================================================ /* https://github.com/equinix-labs/otel-init-go Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package otel import ( "context" "fmt" "os" "time" "github.com/go-logr/logr" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.4.0" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) // SimpleCarrier is an abstraction for handling traceparent propagation // that needs a type that implements the propagation.TextMapCarrier(). // This is the simplest possible implementation that is a little fragile // but since we're not doing anything else with it, it's fine for this. type SimpleCarrier map[string]string // Get implements the otel interface for propagation. func (otp SimpleCarrier) Get(key string) string { return otp[key] } // Set implements the otel interface for propagation. func (otp SimpleCarrier) Set(key, value string) { otp[key] = value } // Keys implements the otel interface for propagation. func (otp SimpleCarrier) Keys() []string { out := []string{} for k := range otp { out = append(out, k) } return out } // Clear implements the otel interface for propagation. func (otp SimpleCarrier) Clear() { for k := range otp { delete(otp, k) } } // TraceparentStringFromContext gets the current trace from the context and // returns a W3C traceparent string. Depends on global OTel TextMapPropagator. func TraceparentStringFromContext(ctx context.Context) string { carrier := SimpleCarrier{} prop := otel.GetTextMapPropagator() prop.Inject(ctx, carrier) return carrier.Get("traceparent") } // ContextWithEnvTraceparent is a helper that looks for the the TRACEPARENT // environment variable and if it's set, it grabs the traceparent and // adds it to the context it returns. When there is no envvar or it's // empty, the original context is returned unmodified. // Depends on global OTel TextMapPropagator. func ContextWithEnvTraceparent(ctx context.Context) context.Context { traceparent := os.Getenv("TRACEPARENT") if traceparent != "" { return ContextWithTraceparentString(ctx, traceparent) } return ctx } // ContextWithTraceparentString takes a W3C traceparent string, uses the otel // carrier code to get it into a context it returns ready to go. // Depends on global OTel TextMapPropagator. func ContextWithTraceparentString(ctx context.Context, traceparent string) context.Context { carrier := SimpleCarrier{} carrier.Set("traceparent", traceparent) prop := otel.GetTextMapPropagator() return prop.Extract(ctx, carrier) } // Config holds the typed values of configuration read from the environment. // It is public mainly to make testing easier and most users should never // use it directly. type Config struct { Servicename string `json:"service_name"` Endpoint string `json:"endpoint"` Insecure bool `json:"insecure"` Logger logr.Logger } // Init sets up the OpenTelemetry plumbing so it's ready to use. // It requires a context.Context and returns context and a func() that encapuslates clean shutdown. func Init(ctx context.Context, c Config) (context.Context, context.CancelFunc, error) { if c.Endpoint != "" { return c.initTracing(ctx) } // no configuration, nothing to do, the calling code is inert // config is available in the returned context (for test/debug) return ctx, func() {}, nil } func (c Config) initTracing(ctx context.Context) (context.Context, context.CancelFunc, error) { // set the service name that will show up in tracing UIs resAttrs := resource.WithAttributes(semconv.ServiceNameKey.String(c.Servicename)) res, err := resource.New(ctx, resAttrs) if err != nil { return ctx, nil, fmt.Errorf("failed to create OpenTelemetry service name resource: %w", err) } retryPolicy := `{ "methodConfig": [{ "retryPolicy": { "MaxAttempts": 1000, "InitialBackoff": ".01s", "MaxBackoff": ".01s", "BackoffMultiplier": 1.0, "RetryableStatusCodes": [ "UNAVAILABLE" ] } }] }` grpcOpts := []otlptracegrpc.Option{ otlptracegrpc.WithEndpoint(c.Endpoint), otlptracegrpc.WithDialOption(grpc.WithDefaultServiceConfig(retryPolicy)), otlptracegrpc.WithRetry(otlptracegrpc.RetryConfig{ Enabled: true, InitialInterval: time.Second * 5, MaxInterval: time.Second * 30, MaxElapsedTime: time.Minute * 5, }), } if c.Insecure { grpcOpts = append(grpcOpts, otlptracegrpc.WithInsecure()) } else { creds := credentials.NewClientTLSFromCert(nil, "") grpcOpts = append(grpcOpts, otlptracegrpc.WithTLSCredentials(creds)) } // TODO: add TLS client cert auth exporter, err := otlptracegrpc.New(context.Background(), grpcOpts...) if err != nil { return ctx, nil, fmt.Errorf("failed to configure OTLP exporter: %w", err) } // TODO: more configuration opportunities here bsp := sdktrace.NewBatchSpanProcessor(exporter) tracerProvider := sdktrace.NewTracerProvider( sdktrace.WithResource(res), sdktrace.WithSpanProcessor(bsp), ) // set global propagator to tracecontext (the default is no-op). prop := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) otel.SetTextMapPropagator(prop) // inject the tracer into the otel globals, start background goroutines otel.SetTracerProvider(tracerProvider) // logger otel.SetLogger(c.Logger) // set a custom error handler so that we can use our own logger otel.SetErrorHandler(c) // the public function will wrap this in its own shutdown function return ctx, func() { ctx1, done := context.WithTimeout(context.Background(), 5*time.Second) err = tracerProvider.Shutdown(ctx1) if err != nil { c.Logger.Info("shutdown of OpenTelemetry tracerProvider failed: %s", err) } done() ctx2, done := context.WithTimeout(context.Background(), 5*time.Second) err = exporter.Shutdown(ctx2) if err != nil { c.Logger.Info("shutdown of OpenTelemetry OTLP exporter failed: %s", err) } done() }, nil } func (c Config) Handle(err error) { if err != nil { c.Logger.Info("OpenTelemetry error", "err", err) } } ================================================ FILE: internal/syslog/facility_string.go ================================================ // Code generated by "stringer -type=facility -output=facility_string.go"; DO NOT EDIT. package syslog import "strconv" func _() { // An "invalid array index" compiler error signifies that the constant values have changed. // Re-run the stringer command to generate them again. var x [1]struct{} _ = x[kern-0] _ = x[user-1] _ = x[mail-2] _ = x[daemon-3] _ = x[auth-4] _ = x[syslog-5] _ = x[lpr-6] _ = x[news-7] _ = x[uucp-8] _ = x[clock-9] _ = x[authpriv-10] _ = x[ftp-11] _ = x[ntp-12] _ = x[audit-13] _ = x[alert-14] _ = x[cron-15] _ = x[local0-16] _ = x[local1-17] _ = x[local2-18] _ = x[local3-19] _ = x[local4-20] _ = x[local5-21] _ = x[local6-22] _ = x[local7-23] } const _facility_name = "kernusermaildaemonauthsysloglprnewsuucpclockauthprivftpntpauditalertcronlocal0local1local2local3local4local5local6local7" var _facility_index = [...]uint8{0, 4, 8, 12, 18, 22, 28, 31, 35, 39, 44, 52, 55, 58, 63, 68, 72, 78, 84, 90, 96, 102, 108, 114, 120} func (i facility) String() string { if i >= facility(len(_facility_index)-1) { return "facility(" + strconv.FormatInt(int64(i), 10) + ")" } return _facility_name[_facility_index[i]:_facility_index[i+1]] } ================================================ FILE: internal/syslog/message.go ================================================ package syslog import ( "bytes" "fmt" "net" "strings" "time" ) //go:generate go run golang.org/x/tools/cmd/stringer@latest -type=facility -output=facility_string.go type facility byte const ( kern facility = iota user mail daemon auth syslog lpr news uucp clock authpriv ftp ntp audit alert cron local0 local1 local2 local3 local4 local5 local6 local7 ) //go:generate go run golang.org/x/tools/cmd/stringer@latest -type=severity -output=severity_string.go type severity byte const ( EMERG severity = iota ALERT CRIT ERR WARNING NOTICE INFO DEBUG ) type message struct { buf [2048]byte size int time time.Time host net.IP // parsed fields priority byte hostname []byte app []byte procid []byte msgid []byte msg []byte } func (m *message) Facility() facility { return facility(m.priority / 8) } func (m *message) Host() string { return m.host.String() } func (m *message) Severity() severity { return severity(m.priority % 8) } var msgCleanup = strings.NewReplacer([]string{"\b", ""}...) func (m *message) String() string { if m.msg == nil { return fmt.Sprintf("host=%s syslog=%q", m.host, m.buf[:m.size]) } fields := make([]string, 0, 7) // fields = append(fields, fmt.Sprintf("ptr=%p", m)) // fields = append(fields, "time=" + m.time.Format(time.RFC3339)) if m.hostname != nil { fields = append(fields, fmt.Sprintf("host=%s", m.hostname)) } else { fields = append(fields, "host="+m.host.String()) } fields = append(fields, "facility="+m.Facility().String()) fields = append(fields, "severity="+m.Severity().String()) if m.app != nil { fields = append(fields, fmt.Sprintf("app-name=%s", m.app)) } if m.procid != nil { fields = append(fields, fmt.Sprintf("procid=%s", m.procid)) } if m.msgid != nil { fields = append(fields, fmt.Sprintf("msgid=%s", m.msgid)) } fields = append(fields, fmt.Sprintf("msg=%q", msgCleanup.Replace(string(m.msg)))) return strings.Join(fields, " ") } func (m *message) Timestamp() time.Time { return m.time } func (m *message) correctLegacyTime(t time.Time) { t = t.AddDate(m.time.Year(), 0, 0) offset := m.time.Sub(t) //nolint:ifshort // erroneous warning. offset is used below if offset < 0 { offset = -offset } if hoursOff := (offset - (offset % time.Hour)) / time.Hour; hoursOff > 1 { t = t.Add(hoursOff) } m.time = t } func (m *message) parse() bool { if !m.parsePriority() { return false } if !m.parseVersion() { return m.parseLegacyHeader() } if !m.parseHeader() { return false } return m.parseStructuredData() } func (m *message) parseHeader() bool { // TIMESTAMP HOSTNAME APP-NAME PROCID MSGID MSG parts := bytes.SplitN(m.msg, []byte{' '}, 6) if len(parts) != 6 || !m.parseTimestamp(parts[0]) { return false } m.hostname = ignoreNil(parts[1]) m.app = ignoreNil(parts[2]) m.procid = ignoreNil(parts[3]) m.msgid = ignoreNil(parts[4]) m.msg = parts[5] return true } func (m *message) parseStructuredData() bool { if len(m.msg) >= 2 && m.msg[0] == '-' && m.msg[1] == ' ' { m.msg = m.msg[2:] return true } return false } func (m *message) parseLegacyHeader() bool { const ( layout = time.Stamp timeLen = len(layout) ) if len(m.msg) <= timeLen || m.msg[timeLen] != ' ' { goto parseHostname // too short or missing expected space after timestamp } if t, err := time.Parse(layout, string(m.msg[:timeLen])); err != nil { goto parseHostname // doesn't match the expected layout } else if !t.IsZero() { // if zero, ignore and use the current time m.correctLegacyTime(t) } m.msg = m.msg[timeLen+1:] parseHostname: m.hostname = nil m.parseLegacyTag() m.trimSeverityPrefix() m.trimTimePrefix() m.trimCarriageReturns() return true } func (m *message) parseLegacyTag() { b := m.msg for i, c := range b { if c >= '0' && c <= '9' { continue } if c >= 'a' && c <= 'z' { continue } if c >= 'A' && c <= 'Z' { continue } if c == '-' || c == '_' || c == '/' || c == '.' { continue } if c == '[' { m.app, b = b[:i], b[i:] goto parsePid } m.app, b = b[:i], b[i:] goto trimColon } m.app = nil m.procid = nil return parsePid: if i := bytes.IndexByte(b[1:], ']'); i != -1 { m.procid = b[1 : 1+i] b = b[1+i+1:] } else { m.procid = nil } trimColon: m.msg = bytes.TrimPrefix(b, []byte{':', ' '}) } func (m *message) parsePriority() bool { if m.size < 3 || m.buf[0] != '<' { return false } var pri byte for i, c := range m.buf[1:5] { if c == '>' { m.priority = pri m.msg = m.buf[1+i+1 : m.size] return true } if c < '0' || c > '9' { return false } pri = pri*10 + c - '0' } return false } func (m *message) parseTimestamp(b []byte) bool { if ignoreNil(b) == nil { return true // NILVALUE } const ( layout = "2006-01-02T15:04:05.999999Z07:00" timeLen = len(layout) ) if len(b) > timeLen { return false // too long } t, err := time.Parse(layout, string(b)) if err != nil { return false } m.time = t return true } func (m *message) parseVersion() bool { if len(m.msg) < 2 { return false // too short } if m.msg[1] != ' ' { return false // missing space after version } if m.msg[0] != '1' { return false // we only support version 1 } m.msg = m.msg[2:] return true } func (m *message) reset() { m.priority = 0 m.hostname = nil m.app = nil m.procid = nil m.msgid = nil m.msg = nil } func (m *message) trimSeverityPrefix() { prefix := []byte(m.Severity().String() + ": ") m.msg = bytes.TrimPrefix(m.msg, prefix) } func (m *message) trimTimePrefix() { m.msg = bytes.TrimPrefix(m.msg, []byte(m.time.Format("2006-01-02 15:04:05 "))) } func (m *message) trimCarriageReturns() { if len(m.msg) > 0 && m.msg[0] == '\r' { m.msg = m.msg[1:] } // m.msg = bytes.Replace(m.msg, "\r", "(CR)", -1) } func ignoreNil(b []byte) []byte { if len(b) == 1 && b[0] == '-' { return nil } return b } ================================================ FILE: internal/syslog/receiver.go ================================================ package syslog import ( "context" "encoding/json" "errors" "fmt" "net" "strings" "sync" "time" "github.com/go-logr/logr" ) var syslogMessagePool = sync.Pool{ New: func() interface{} { return new(message) }, } type Receiver struct { c *net.UDPConn parse chan *message done chan struct{} err error Logger logr.Logger } func StartReceiver(ctx context.Context, logger logr.Logger, laddr string, parsers int) error { if parsers < 1 { parsers = 1 } addr, err := net.ResolveUDPAddr("udp4", laddr) if err != nil { return fmt.Errorf("resolve syslog udp listen address: %w", err) } c, err := net.ListenUDP("udp4", addr) if err != nil { return fmt.Errorf("listen on syslog udp address: %w", err) } s := &Receiver{ c: c, parse: make(chan *message, parsers), done: make(chan struct{}), Logger: logger, } for i := 0; i < parsers; i++ { go s.runParser() } go s.run(ctx) return nil } func (r *Receiver) Done() <-chan struct{} { return r.done } func (r *Receiver) Err() error { return r.err } func (r *Receiver) cleanup() { r.c.Close() close(r.parse) close(r.done) } func (r *Receiver) run(ctx context.Context) { var msg *message defer func() { if msg != nil { syslogMessagePool.Put(msg) } }() go func() { <-ctx.Done() r.cleanup() }() for { if msg == nil { var ok bool msg, ok = syslogMessagePool.Get().(*message) if !ok { r.Logger.Error(errors.New("error type asserting pool item into message"), "error type asserting pool item into message") continue } } n, from, err := r.c.ReadFromUDP(msg.buf[:]) if err != nil { err = fmt.Errorf("error reading udp message: %w", err) if _, ok := err.(net.Error); ok { r.Logger.Error(err, "error reading udp message") continue } r.err = err return } msg.time = time.Now().UTC() msg.host = from.IP msg.size = n r.parse <- msg msg = nil } } func parse(m *message) map[string]interface{} { structured := make(map[string]interface{}) if m.Facility().String() != "" { structured["facility"] = m.Facility().String() } if m.Severity().String() != "" { structured["severity"] = m.Severity().String() } if string(m.hostname) != "" { structured["hostname"] = string(m.hostname) } if string(m.app) != "" { structured["app-name"] = string(m.app) } if string(m.procid) != "" { structured["procid"] = string(m.procid) } if string(m.msgid) != "" { structured["msgid"] = string(m.msgid) } if string(m.msg) != "" { if strings.HasPrefix(string(m.msg), "{") { var j map[string]interface{} if err := json.Unmarshal(m.msg, &j); err == nil { structured["msg"] = j } } else { structured["msg"] = string(m.msg) } } structured["host"] = m.host.String() return structured } func (r *Receiver) runParser() { for m := range r.parse { if m.parse() { structured := parse(m) sl := r.Logger.WithValues("msg", structured) if m.Severity() == DEBUG { sl.V(1).Info("msg") } else { sl.Info("msg") } } else { r.Logger.V(1).Info("msg", "msg", m) } m.reset() syslogMessagePool.Put(m) } } ================================================ FILE: internal/syslog/severity_string.go ================================================ // Code generated by "stringer -type=severity -output=severity_string.go"; DO NOT EDIT. package syslog import "strconv" func _() { // An "invalid array index" compiler error signifies that the constant values have changed. // Re-run the stringer command to generate them again. var x [1]struct{} _ = x[EMERG-0] _ = x[ALERT-1] _ = x[CRIT-2] _ = x[ERR-3] _ = x[WARNING-4] _ = x[NOTICE-5] _ = x[INFO-6] _ = x[DEBUG-7] } const _severity_name = "EMERGALERTCRITERRWARNINGNOTICEINFODEBUG" var _severity_index = [...]uint8{0, 5, 10, 14, 17, 24, 30, 34, 39} func (i severity) String() string { if i >= severity(len(_severity_index)-1) { return "severity(" + strconv.FormatInt(int64(i), 10) + ")" } return _severity_name[_severity_index[i]:_severity_index[i+1]] } ================================================ FILE: lint.mk ================================================ # BEGIN: lint-install github.com/tinkerbell/smee # http://github.com/tinkerbell/lint-install .PHONY: lint lint: _lint ## Run linting LINT_ARCH := $(shell uname -m) LINT_OS := $(shell uname) LINT_OS_LOWER := $(shell echo $(LINT_OS) | tr '[:upper:]' '[:lower:]') LINT_ROOT := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) # shellcheck and hadolint lack arm64 native binaries: rely on x86-64 emulation ifeq ($(LINT_OS),Darwin) ifeq ($(LINT_ARCH),arm64) LINT_ARCH=x86_64 endif endif LINTERS := FIXERS := GOLANGCI_LINT_CONFIG := $(LINT_ROOT)/.golangci.yml GOLANGCI_LINT_VERSION ?= v2.2.1 GOLANGCI_LINT_BIN := $(LINT_ROOT)/out/linters/golangci-lint-$(GOLANGCI_LINT_VERSION)-$(LINT_ARCH) $(GOLANGCI_LINT_BIN): mkdir -p $(LINT_ROOT)/out/linters rm -rf $(LINT_ROOT)/out/linters/golangci-lint-* curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LINT_ROOT)/out/linters $(GOLANGCI_LINT_VERSION) mv $(LINT_ROOT)/out/linters/golangci-lint $@ LINTERS += golangci-lint-lint golangci-lint-lint: $(GOLANGCI_LINT_BIN) find . -name go.mod -execdir sh -c '"$(GOLANGCI_LINT_BIN)" run -c "$(GOLANGCI_LINT_CONFIG)"' '{}' '+' FIXERS += golangci-lint-fix golangci-lint-fix: $(GOLANGCI_LINT_BIN) find . -name go.mod -execdir "$(GOLANGCI_LINT_BIN)" run -c "$(GOLANGCI_LINT_CONFIG)" --fix \; .PHONY: _lint $(LINTERS) _lint: $(LINTERS) .PHONY: fix $(FIXERS) fix: $(FIXERS) # END: lint-install github.com/tinkerbell/smee ================================================ FILE: rules.mk ================================================ # Only use the recipes defined in these makefiles MAKEFLAGS += --no-builtin-rules .SUFFIXES: # Delete target files if there's an error # This avoids a failure to then skip building on next run if the output is created by shell redirection for example # Not really necessary for now, but just good to have already if it becomes necessary later. .DELETE_ON_ERROR: # Treat the whole recipe as a one shell script/invocation instead of one-per-line .ONESHELL: # Use bash instead of plain sh SHELL := bash .SHELLFLAGS := -o pipefail -euc # Runnable tools GO ?= go GOIMPORTS := $(GO) run golang.org/x/tools/cmd/goimports@latest .PHONY: all smee crosscompile dc image gen run test CGO_ENABLED := 0 export CGO_ENABLED GitRev := $(shell git rev-parse --short HEAD) crossbinaries := cmd/smee/smee-linux-amd64 cmd/smee/smee-linux-arm64 cmd/smee/smee-linux-amd64: FLAGS=GOARCH=amd64 cmd/smee/smee-linux-arm64: FLAGS=GOARCH=arm64 cmd/smee/smee-linux-amd64 cmd/smee/smee-linux-arm64: smee ${FLAGS} GOOS=linux go build -ldflags="-X main.GitRev=${GitRev}" -o $@ ./cmd/smee/ generated_go_files := \ internal/syslog/facility_string.go \ internal/syslog/severity_string.go \ # go generate go_generate: $(generated_go_files) $(filter %_string.go,$(generated_go_files)): internal/syslog/facility_string.go: internal/syslog/message.go internal/syslog/severity_string.go: internal/syslog/message.go $(generated_go_files): go generate -run="$(@F)" ./... $(GOIMPORTS) -w $@ cmd/smee/smee: internal/syslog/facility_string.go internal/syslog/severity_string.go cleanup go build -v -ldflags="-X main.GitRev=${GitRev}" -o $@ ./cmd/smee/ cleanup: rm -f cmd/smee/smee cmd/smee/smee-linux-amd64 cmd/smee/smee-linux-arm64 ================================================ FILE: test/Dockerfile ================================================ FROM alpine:3.14 EXPOSE 67 69 RUN apk add --update --upgrade --no-cache net-tools busybox tftp-hpa curl tcpdump COPY busybox-udhcpc-script.sh /busybox-udhcpc-script.sh COPY extract-traceparent-from-opt43.sh /extract-traceparent-from-opt43.sh COPY test-smee.sh /test-smee.sh ENTRYPOINT /test-smee.sh ================================================ FILE: test/busybox-udhcpc-script.sh ================================================ #!/bin/sh # instead of messing with the actual interface configuration # this just dumps the environment variables to a file and stdout env | grep -v '^[A-Z]' | sort | tee /tmp/dhcpoffer-vars.sh ================================================ FILE: test/extract-traceparent-from-opt43.sh ================================================ #!/bin/sh # shellcheck shell=dash # extract_traceparent_from_opt43 takes a hex string from busybox udhcpc's opt43 # and extracts sub-option 69 which is where we stuff the traceparent in binary, # which busybox helpfully gives us in a hex string as $opt43 # # PXE_DISCOVERY_CONTROL is 060108 (option 6, 1 byte long, value 8) # traceparent is 451a (type 69, 26 bytes, value is tp) # # The DHCP spec says nothing about ordering and smee can be observed to serve # the types in a different order on different runs, so the option has to be # fully parsed to get the right data. # # this would be way easier in perl/python but this needs to work in dash # and with busybox shell tools # # takes 1 argument, usually $opt43 # sets $opt43x69 to the hex traceparent # exports $TRACEPARENT to the W3C-formatted traceparent string extract_traceparent_from_opt43() { local hexdata strlen offset hexdata=$1 shift opt43x69="" # in case the global is still set, empty it strlen=$(echo -n "$hexdata" | wc -c) offset=1 # cut(1) uses offsets starting at 1 while [ "$offset" -lt "$strlen" ]; do # extract the type number, 1 byte local type_end htype type type_end=$((offset + 1)) htype=$(echo -n "$hexdata" | cut -c "${offset}-${type_end}") type=$(printf '%d' "0x$htype") # extract the value length, 1 byte local len_start len_end hlen len len_start=$((offset + 2)) len_end=$((offset + 3)) hlen=$(echo -n "$hexdata" | cut -c "${len_start}-${len_end}") len=$(printf '%d' "0x$hlen") # calculate value offsets local bov eov bov=$((offset + 4)) # beginning of value eov=$((bov + len * 2 - 1)) # end of value if [ "$type" -eq 69 ]; then # set global to the full tp hex data opt43x69=$(echo -n "$hexdata" | cut -c "${bov}-${eov}") # break out the sections of the traceparent to make a proper W3C tp string local ver trace_id span_id flags ver=$(echo -n "$opt43x69" | cut -c "1-2") # 1 byte trace_id=$(echo -n "$opt43x69" | cut -c "3-34") # 16 bytes span_id=$(echo -n "$opt43x69" | cut -c "35-50") # 8 bytes flags=$(echo -n "$opt43x69" | cut -c "51-53") # 1 byte # set TRACEPARENT to the W3C-formatted string export TRACEPARENT="${ver}-${trace_id}-${span_id}-${flags}" fi # add to the offset: # 4 characters for type and len e.g. 0601 (type 6, length 1) # len (is bytes) * 2 (bc hex) = chars of offset e.g. 08 (value is 8, 2 chars in hex) offset=$((4 + offset + len * 2)) local next next=$(echo -n "$hexdata" | cut -c "${offset}-$((offset + 1))") # opt43 always ends with 0xff so if the next byte is ff it's the end for sure if [ "$next" = "ff" ]; then break fi done } ================================================ FILE: test/hardware.yaml ================================================ --- 02:00:00:00:00:ff: ipAddress: "192.168.99.43" subnetMask: "255.255.255.0" defaultGateway: "192.168.99.1" nameServers: - "8.8.8.8" hostname: "smee-test-client" domainName: "example.com" broadcastAddress: "192.168.2.255" ntpServers: - "132.163.96.2" leaseTime: 86400 domainSearch: - "example.com" netboot: allowPxe: true ================================================ FILE: test/otel-collector.yaml ================================================ # opentelemetry-collector is a proxy for telemetry events. # # This configuration is set up for use in smee development. # With collector in debug mode every trace is printed to the console # so you can see traces without any complex tooling. There are also # examples below for how to send to Lightstep and Honeycomb. receivers: otlp: protocols: grpc: endpoint: "0.0.0.0:4317" processors: batch: exporters: # set to debug and your traces will get printed to the console spammily logging: logLevel: debug # Lightstep: set & export LIGHTSTEP_TOKEN and enable below otlp/1: endpoint: "ingest.lightstep.com:443" headers: "lightstep-access-token": "${LIGHTSTEP_TOKEN}" # Honeycomb: set & export HONEYCOMB_TEAM to the auth token, and set/export # HONEYCOMB_DATASET to the dataset name you want to use, then enable below otlp/2: endpoint: "api.honeycomb.io:443" headers: "x-honeycomb-team": "${HONEYCOMB_TEAM}" "x-honeycomb-dataset": "${HONEYCOMB_DATASET}" service: pipelines: traces: receivers: [otlp] processors: [batch] # only enable logging by default exporters: [logging] # Lightstep: # exporters: [logging, otlp/1] # Honeycomb: # exporters: [logging, otlp/2] ================================================ FILE: test/start-smee.sh ================================================ #!/bin/sh # the docker-compose overrides the smee container's ENTRYPOINT # with this script so it's a little easier to debug things # # configuration environment variables are provided by docker-compose # for example, to see the DHCP packets coming from the DHCP client # container, uncomment these. # apk update && apk add --no-cache tcpdump # tcpdump -nvvei eth0 port 67 or port 68 & # or just apk add tcpdump then run this in another terminal: # docker exec -ti smee_smee_1 tcpdump -nvvei eth0 port 67 or port 68 # start smee and explicitly bind DHCP to broadcast address otherwise # smee will start up fine but not see the DHCP requests # TODO: probably move smee to just use the envvars for otel /usr/bin/smee & sleep 100000 ================================================ FILE: test/test-smee.sh ================================================ #!/bin/sh # shellcheck shell=dash disable=SC1091,SC2154 # useful for debugging sometimes # tcpdump -ni eth0 & # alternatively, only show DHCP and pretty print the packets # tcpdump -nvvei eth0 port 67 or port 68 & sleep_at_start=3 echo "starting DHCP in $sleep_at_start seconds" sleep $sleep_at_start # busybox udhcpc will happily set arbitrary DHCP options and is easy # to configure with a custom setup script to call on DHCPOFFER # # dummy setup script for -s is copied in by Dockerfile # -q tells udhcpc to exit after getting a lease, otherwise it will keep generating new traces # opt60 (-V PXEClient) pretend to be an Intel PXE client. required to be noticed by smee # opt93 (-x 0x5d) set to 0 for "Intel x86PC" platform, required by smee # opt94 (-x 0x5e) set to 0 for "UNDI" firmware type, required by smee # opt97 (-x 0x61) sets the client guid (https://datatracker.ietf.org/doc/html/rfc4578#section-2.3) # first 8 octets should be zeroes to make smee happy (Intel PXE does this) # ID: 4a525bd43517df7f8b4799c18d (randomly generated and hard-coded here) busybox udhcpc \ -q \ -s /busybox-udhcpc-script.sh \ -V PXEClient \ -x 0x5d:0000 \ -x 0x5e:0000 \ -x 0x61:000000004a525bd43517df7f8b4799c18d # set boot_file variable ahead of sourcing dhcpoffer-vars.sh to please the linter boot_file="" # the busybox script writes the DHCP variables to /tmp/dhcpoffer-vars.sh # shellcheck disable=SC1091 . /tmp/dhcpoffer-vars.sh # smee sets 2 values in option 43, check out dhcp/pxe.go # these can come in out of order so we have to look for the traceparent's # id and length which is always 0x451a # busybox udhcpc helpfully returns options in hex # option43 ordering is not guaranteed, at least not in this implementation . extract-traceparent-from-opt43.sh # load a function to do the parsing extract_traceparent_from_opt43 "$opt43" # parse the value, exports TRACEPARENT echo "got traceparent $TRACEPARENT from opt43 value $opt43" # write it to the shell profile.d for easy loading echo "export TRACEPARENT=$TRACEPARENT" >/etc/profile.d/smee-traceparent.sh # fetch / from the server with the traceparent set tp_header="Traceparent: $TRACEPARENT" curl -H "$tp_header" http://192.168.99.42/auto.ipxe # TODO: test opportunity here: validate the returned traceparent matches the one in boot_file # boot_file is set by the DHCP envvars # OTEL in Smee is enabled by default. tftp 192.168.99.42 -c get "${boot_file}" # sleep a long time so you can enter the container with # docker exec -ti smee_client_1 /bin/sh sleep 30000