├── .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 ├── boots-flow.png ├── images │ ├── BYO_DHCP.png │ └── 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 /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !cmd/smee/smee-*-* 3 | !cmd/smee/smee 4 | !test/ 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | /.github/settings.yml @chrisdoherty4 @jacobweinstock 2 | /.github/CODEOWNERS @chrisdoherty4 @jacobweinstock 3 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage: 3 | precision: 0 # xx% 4 | round: down # round down 5 | range: 30..40 # red < yellow (this range) < green 6 | 7 | status: 8 | project: 9 | default: 10 | target: auto # automatically calculate coverage target - should increase 11 | threshold: 2% # allow for 2% reduction without failing 12 | patch: 13 | default: 14 | target: auto 15 | changes: false 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "monday" 8 | time: "04:39" 9 | timezone: "America/New_York" 10 | reviewers: 11 | - chrisdoherty4 12 | - jacobweinstock 13 | open-pull-requests-limit: 10 14 | 15 | - package-ecosystem: "gomod" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | day: "monday" 20 | time: "03:52" 21 | timezone: "America/New_York" 22 | reviewers: 23 | - chrisdoherty4 24 | - jacobweinstock 25 | open-pull-requests-limit: 10 26 | 27 | - package-ecosystem: "gomod" 28 | directory: "/" 29 | schedule: 30 | interval: "weekly" 31 | day: "thursday" 32 | time: "03:52" 33 | timezone: "America/New_York" 34 | reviewers: 35 | - chrisdoherty4 36 | - jacobweinstock 37 | open-pull-requests-limit: 10 38 | 39 | - package-ecosystem: "docker" 40 | directory: "/" 41 | schedule: 42 | interval: "weekly" 43 | day: "monday" 44 | time: "04:22" 45 | timezone: "America/New_York" 46 | reviewers: 47 | - chrisdoherty4 48 | - jacobweinstock 49 | open-pull-requests-limit: 10 50 | -------------------------------------------------------------------------------- /.github/mergify.yml: -------------------------------------------------------------------------------- 1 | queue_rules: 2 | - name: default 3 | queue_conditions: 4 | - base=main 5 | - "#approved-reviews-by>=1" 6 | - "#changes-requested-reviews-by=0" 7 | - "#review-requested=0" 8 | - check-success=DCO 9 | - check-success=validation 10 | - label!=do-not-merge 11 | - label=ready-to-merge 12 | merge_conditions: 13 | # Conditions to get out of the queue (= merged) 14 | - check-success=DCO 15 | - check-success=validation 16 | merge_method: merge 17 | commit_message_template: | 18 | {{ title }} (#{{ number }}) 19 | 20 | {{ body }} 21 | 22 | pull_request_rules: 23 | - name: refactored queue action rule 24 | conditions: [] 25 | actions: 26 | queue: 27 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # Collaborators: give specific users access to this repository. 2 | # See https://docs.github.com/en/rest/reference/repos#add-a-repository-collaborator for available options 3 | collaborators: 4 | # Maintainers, should also be added to the .github/CODEOWNERS file as owners of this settings.yml file. 5 | - username: jacobweinstock 6 | permission: maintain 7 | - username: chrisdoherty4 8 | permission: maintain 9 | # Approvers 10 | # Reviewers 11 | 12 | # Note: `permission` is only valid on organization-owned repositories. 13 | # The permission to grant the collaborator. Can be one of: 14 | # * `pull` - can pull, but not push to or administer this repository. 15 | # * `push` - can pull and push, but not administer this repository. 16 | # * `admin` - can pull, push and administer this repository. 17 | # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. 18 | # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. 19 | -------------------------------------------------------------------------------- /.github/workflows/ci-checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | failed=0 6 | 7 | if [[ -n $(go run golang.org/x/tools/cmd/goimports@latest -d -e -l .) ]]; then 8 | go run golang.org/x/tools/cmd/goimports@latest -w . 9 | failed=1 10 | fi 11 | 12 | if ! go mod tidy; then 13 | failed=true 14 | fi 15 | 16 | if ! git diff | (! grep .); then 17 | failed=1 18 | fi 19 | 20 | exit "$failed" 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: For each commit and PR 2 | on: 3 | push: 4 | branches: 5 | - "*" 6 | tags-ignore: 7 | - "v*" 8 | pull_request: 9 | 10 | env: 11 | REGISTRY: quay.io 12 | IMAGE: quay.io/${{ github.repository }} 13 | CGO_ENABLED: 0 14 | GO_VERSION: "1.24" 15 | 16 | jobs: 17 | validation: 18 | runs-on: ubuntu-latest 19 | env: 20 | CGO_ENABLED: 0 21 | steps: 22 | - name: Setup Dynamic Env 23 | run: | 24 | echo "MAKEFLAGS=-j$(nproc)" | tee $GITHUB_ENV 25 | 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 5 30 | 31 | - name: Setup Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: "${{ env.GO_VERSION }}" 35 | cache: true 36 | 37 | - name: Fetch Deps 38 | run: | 39 | # fixes "write /run/user/1001/355792648: no space left on device" error 40 | sudo mount -o remount,size=3G /run/user/1001 || true 41 | go get -t ./... && go mod tidy 42 | 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v3 45 | 46 | - name: Generate all files 47 | run: make -j1 gen 48 | 49 | - name: Run all the tests 50 | run: make ci 51 | 52 | - name: upload codecov 53 | uses: codecov/codecov-action@v5 54 | env: 55 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 56 | 57 | - name: compile binaries 58 | run: make crosscompile 59 | 60 | - name: Figure out Docker Tags 61 | id: docker-image-tag 62 | run: | 63 | echo ::set-output name=tags::${{ env.IMAGE }}:latest,${{ env.IMAGE }}:sha-${GITHUB_SHA::8} 64 | 65 | - name: Login to quay.io 66 | uses: docker/login-action@v3 67 | if: ${{ startsWith(github.ref, 'refs/heads/main') }} 68 | with: 69 | registry: ${{ env.REGISTRY }} 70 | username: ${{ secrets.QUAY_USERNAME }} 71 | password: ${{ secrets.QUAY_PASSWORD }} 72 | 73 | - name: Build Docker Images 74 | uses: docker/build-push-action@v6 75 | with: 76 | context: ./ 77 | file: ./Dockerfile 78 | cache-from: type=registry,ref=${{ env.IMAGE }}:latest 79 | platforms: linux/amd64,linux/arm64 80 | tags: ${{ steps.docker-image-tag.outputs.tags }} 81 | 82 | # looks just like Build Docker Images except with push:true and this will only run for builds for main 83 | - name: Push Docker Images 84 | uses: docker/build-push-action@v6 85 | if: ${{ startsWith(github.ref, 'refs/heads/main') }} 86 | with: 87 | context: ./ 88 | file: ./Dockerfile 89 | cache-from: type=registry,ref=${{ env.IMAGE }}:latest 90 | platforms: linux/amd64,linux/arm64 91 | push: true 92 | tags: ${{ steps.docker-image-tag.outputs.tags }} 93 | -------------------------------------------------------------------------------- /.github/workflows/tags.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*" 5 | name: Create release 6 | env: 7 | REGISTRY: quay.io 8 | IMAGE_NAME: ${{ github.repository }} 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | - name: Generate Release Notes 16 | run: | 17 | release_notes=$(gh api repos/{owner}/{repo}/releases/generate-notes -F tag_name=${{ github.ref }} --jq .body) 18 | echo 'RELEASE_NOTES<> $GITHUB_ENV 19 | echo "${release_notes}" >> $GITHUB_ENV 20 | echo 'EOF' >> $GITHUB_ENV 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | OWNER: ${{ github.repository_owner }} 24 | REPO: ${{ github.event.repository.name }} 25 | 26 | - name: Docker manager metadata 27 | id: meta 28 | uses: docker/metadata-action@v5 29 | with: 30 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 31 | flavor: latest=false 32 | tags: type=ref,event=tag 33 | 34 | - name: Set the from image tag 35 | run: echo "FROM_TAG=sha-${GITHUB_SHA::8}" >> $GITHUB_ENV 36 | 37 | - name: Copy the image using skopeo 38 | run: skopeo copy --all --dest-creds="${DST_REG_USER}":"${DST_REG_PASS}" docker://"${SRC_IMAGE}" docker://"${DST_IMAGE}" 39 | env: 40 | SRC_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.FROM_TAG }} 41 | DST_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} 42 | DST_REG_USER: ${{ secrets.QUAY_USERNAME }} 43 | DST_REG_PASS: ${{ secrets.QUAY_PASSWORD }} 44 | 45 | - name: Create Release 46 | id: create_release 47 | uses: actions/create-release@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | tag_name: ${{ github.ref }} 52 | release_name: ${{ github.ref }} 53 | body: ${{ env.RELEASE_NOTES }} 54 | draft: false 55 | prerelease: true 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.orig 3 | *.test 4 | .idea*/** 5 | /bin/ 6 | /cmd/smee/smee 7 | /cmd/smee/smee-*-* 8 | coverage.txt 9 | .vscode 10 | 11 | # added by lint-install 12 | out/ 13 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | # The default runtime timeout is 1m, which doesn't work well on Github Actions. 4 | timeout: 4m 5 | linters: 6 | default: none 7 | enable: 8 | - asciicheck 9 | - bodyclose 10 | - copyloopvar 11 | - cyclop 12 | - dogsled 13 | - dupl 14 | - durationcheck 15 | - errcheck 16 | - errname 17 | - errorlint 18 | - exhaustive 19 | - forcetypeassert 20 | - gocognit 21 | - goconst 22 | - gocritic 23 | - godot 24 | - goheader 25 | - goprintffuncname 26 | - gosec 27 | - govet 28 | - importas 29 | - ineffassign 30 | - makezero 31 | - misspell 32 | - nakedret 33 | - nestif 34 | - nilerr 35 | - noctx 36 | - nolintlint 37 | - predeclared 38 | - revive 39 | - rowserrcheck 40 | - sqlclosecheck 41 | - staticcheck 42 | - thelper 43 | - tparallel 44 | - unconvert 45 | - unparam 46 | - unused 47 | - wastedassign 48 | - whitespace 49 | settings: 50 | cyclop: 51 | max-complexity: 37 52 | package-average: 34 53 | dupl: 54 | threshold: 200 55 | errorlint: 56 | # Forcing %w in error wrapping forces authors to make errors part of their package APIs. The decision to make 57 | # an error part of a package API should be a conscious decision by the author. 58 | # Also see Hyrums Law. 59 | errorf: false 60 | asserts: false 61 | exhaustive: 62 | default-signifies-exhaustive: true 63 | gocognit: 64 | min-complexity: 98 65 | goconst: 66 | min-len: 4 67 | min-occurrences: 5 68 | gosec: 69 | excludes: 70 | - G107 # Potential HTTP request made with variable url 71 | - G204 # Subprocess launched with function call as argument or cmd arguments 72 | - G404 # Use of weak random number generator (math/rand instead of crypto/rand 73 | nestif: 74 | min-complexity: 8 75 | nolintlint: 76 | require-explanation: true 77 | require-specific: true 78 | allow-unused: false 79 | revive: 80 | severity: warning 81 | rules: 82 | - name: atomic 83 | - name: blank-imports 84 | - name: bool-literal-in-expr 85 | - name: confusing-naming 86 | - name: constant-logical-expr 87 | - name: context-as-argument 88 | - name: context-keys-type 89 | - name: deep-exit 90 | - name: defer 91 | - name: range-val-in-closure 92 | - name: range-val-address 93 | - name: dot-imports 94 | - name: error-naming 95 | - name: error-return 96 | - name: error-strings 97 | - name: errorf 98 | - name: exported 99 | - name: identical-branches 100 | - name: if-return 101 | - name: import-shadowing 102 | - name: increment-decrement 103 | - name: indent-error-flow 104 | - name: indent-error-flow 105 | - name: package-comments 106 | - name: range 107 | - name: receiver-naming 108 | - name: redefines-builtin-id 109 | - name: superfluous-else 110 | - name: struct-tag 111 | - name: time-naming 112 | - name: unexported-naming 113 | - name: unexported-return 114 | - name: unnecessary-stmt 115 | - name: unreachable-code 116 | - name: unused-parameter 117 | - name: var-declaration 118 | - name: var-naming 119 | - name: unconditional-recursion 120 | - name: waitgroup-by-value 121 | # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#struct-tag 122 | - name: struct-tag 123 | arguments: 124 | - json,inline 125 | - yaml,omitzero 126 | - protobuf,casttype 127 | exclusions: 128 | generated: lax 129 | presets: 130 | - comments 131 | - common-false-positives 132 | - legacy 133 | - std-error-handling 134 | rules: 135 | - linters: 136 | - dupl 137 | - errcheck 138 | - forcetypeassert 139 | - gocyclo 140 | - gosec 141 | - noctx 142 | path: _test\.go 143 | - linters: 144 | # This check is of questionable value 145 | - tparallel 146 | text: call t.Parallel on the top level as well as its subtests 147 | - linters: 148 | - cyclop 149 | - goconst 150 | path: (.+)_test\.go 151 | paths: 152 | - third_party$ 153 | - builtin$ 154 | - examples$ 155 | - internal/iso/internal/reverseproxy.go 156 | - internal/iso/internal/reverseproxy_test.go 157 | - internal/iso/internal/acsii.go 158 | - internal/iso/internal/acsii_test.go 159 | issues: 160 | max-issues-per-linter: 0 161 | max-same-issues: 0 162 | formatters: 163 | enable: 164 | - gofmt 165 | - gofumpt 166 | - goimports 167 | exclusions: 168 | generated: lax 169 | paths: 170 | - internal/iso/internal/reverseproxy.go 171 | - internal/iso/internal/reverseproxy_test.go 172 | - internal/iso/internal/acsii.go 173 | - internal/iso/internal/acsii_test.go 174 | - third_party$ 175 | - builtin$ 176 | - examples$ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Guide 2 | 3 | Welcome to Smee! 4 | We are really excited to have you. 5 | Please use the following guide on your contributing journey. 6 | Thanks for contributing! 7 | 8 | ## Table of Contents 9 | 10 | - [Context](#Context) 11 | - [Architecture](#Architecture) 12 | - [Design Docs](#Design-Docs) 13 | - [Code Structure](#Code-Structure) 14 | - [Prerequisites](#Prerequisites) 15 | - [DCO Sign Off](#DCO-Sign-Off) 16 | - [Code of Conduct](#Code-of-Conduct) 17 | - [Setting up your development environment](#Setting-up-your-development-environment) 18 | - [Development](#Development) 19 | - [Building](#Building) 20 | - [Unit testing](#Unit-testing) 21 | - [Linting](#Linting) 22 | - [Functional testing](#Functional-testing) 23 | - [Running Smee locally](#Running-Smee-locally) 24 | - [Pull Requests](#Pull-Requests) 25 | - [Branching strategy](#Branching-strategy) 26 | - [Quality](#Quality) 27 | - [CI](#CI) 28 | - [Code coverage](#Code-coverage) 29 | - [Pre PR Checklist](#Pre-PR-Checklist) 30 | 31 | --- 32 | 33 | ## Context 34 | 35 | Smee is a DHCP and PXE (TFTP & HTTP) service. 36 | It is part of the [Tinkerbell stack](https://tinkerbell.org) and provides the first interaction for any machines being provisioned through Tinkerbell. 37 | 38 | ## Architecture 39 | 40 | ### Design Docs 41 | 42 | Details and diagrams for Smee are found [here](docs/DESIGN.md). 43 | 44 | ### Code Structure 45 | 46 | Details on Smee's code structure is found [here](docs/CODE_STRUCTURE.md) (WIP) 47 | 48 | ## Prerequisites 49 | 50 | ### DCO Sign Off 51 | 52 | Please read and understand the DCO found [here](docs/DCO.md). 53 | 54 | ### Code of Conduct 55 | 56 | Please read and understand the code of conduct found [here](https://github.com/tinkerbell/.github/blob/main/CODE_OF_CONDUCT.md). 57 | 58 | ### Setting up your development environment 59 | 60 | --- 61 | 62 | ### Dependencies 63 | 64 | #### Build time dependencies 65 | 66 | #### Runtime dependencies 67 | 68 | At runtime Smee needs to communicate with a Tink server. 69 | Follow this [guide](https://tinkerbell.org/docs/setup/getting_started/) for running Tink server. 70 | 71 | ## Development 72 | 73 | ### Building 74 | 75 | > At the moment, these instructions are only stable on Linux environments 76 | 77 | To build Smee, run: 78 | 79 | ```bash 80 | # build all ipxe files, embed them, and build the Go binary 81 | # Built binary can be found in the top level directory. 82 | make build 83 | 84 | ``` 85 | 86 | To build the amd64 Smee container image, run: 87 | 88 | ```bash 89 | # make the amd64 container image 90 | # Built image will be named smee:latest 91 | make image 92 | 93 | ``` 94 | 95 | To build the IPXE binaries and embed them into Go, run: 96 | 97 | ```bash 98 | # Note, this will not build the Smee binary 99 | make bindata 100 | ``` 101 | 102 | To build Smee binaries for all distro 103 | 104 | ### Unit testing 105 | 106 | To execute the unit tests, run: 107 | 108 | ```bash 109 | make test 110 | 111 | # to get code coverage numbers, run: 112 | make coverage 113 | ``` 114 | 115 | ### Linting 116 | 117 | To execute linting, run: 118 | 119 | ```bash 120 | # runs golangci-lint 121 | make lint 122 | 123 | # runs goimports 124 | make goimports 125 | 126 | # runs go vet 127 | make vet 128 | ``` 129 | 130 | ## Linting of Non Go files 131 | 132 | ```bash 133 | # lints non Go files like shell scripts, markdown files, etc 134 | # this script is used in CI run, so be sure it passes before submitting a PR 135 | ./.github/workflows/ci-non-go.sh 136 | ``` 137 | 138 | ### Functional testing 139 | 140 | 1. Create a hardware record in Tink server - follow the guide [here](https://tinkerbell.org/docs/concepts/hardware/) 141 | 2. boot the machine 142 | 143 | ### Running Smee 144 | 145 | 1. Be sure all documented runtime dependencies are satisfied. 146 | 2. Define all environment variables. 147 | 148 | ```bash 149 | # MIRROR_HOST is for downloading kernel, initrd 150 | export MIRROR_HOST=192.168.2.3 151 | # PUBLIC_FQDN is for phone home endpoint 152 | export PUBLIC_FQDN=192.168.2.4 153 | # DOCKER_REGISTRY, REGISTRY_USERNAME, REGISTRY_PASSWORD, TINKERBELL_GRPC_AUTHORITY, TINKERBELL_CERT_URL are needed for auto.ipxe file generation 154 | # TINKERBELL_GRPC_AUTHORITY, TINKERBELL_CERT_URL are needed for getting hardware data 155 | export DOCKER_REGISTRY=192.168.2.1:5000 156 | export REGISTRY_USERNAME=admin 157 | export REGISTRY_PASSWORD=secret 158 | export TINKERBELL_GRPC_AUTHORITY=tinkerbell.tinkerbell:42113 159 | export TINKERBELL_CERT_URL=http://tinkerbell.tinkerbell:42114/cert 160 | # FACILITY_CODE is needed for ? 161 | export FACILITY_CODE=onprem 162 | export DATA_MODEL_VERSION=1 163 | # API_AUTH_TOKEN, API_CONSUMER_TOKEN are needed to by pass panicking in main.go main func 164 | export API_AUTH_TOKEN=none 165 | export API_CONSUMER_TOKEN=none 166 | ``` 167 | 168 | 3. Run Smee 169 | 170 | ```bash 171 | # Run the compiled smee 172 | sudo ./smee -http-addr 192.168.2.225:80 -tftp-addr 192.168.2.225:69 -dhcp-addr 192.168.2.225:67 173 | ``` 174 | 175 | 4. Faster iterating via `go run` 176 | 177 | ```bash 178 | # after the ipxe binaries have been compiled you can use `go run` to iterate a little more quickly than building the binary every time 179 | 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 180 | ``` 181 | 182 | ## Pull Requests 183 | 184 | ### Branching strategy 185 | 186 | Smee uses a fork and pull request model. 187 | See this [doc](https://guides.github.com/activities/forking/) for more details. 188 | 189 | ### Quality 190 | 191 | #### CI 192 | 193 | Smee uses GitHub Actions for CI. 194 | The workflow is found in [.github/workflows/ci.yaml](.github/workflows/ci.yaml). 195 | It is run for each commit and PR. 196 | 197 | #### Code coverage 198 | 199 | Smee does run code coverage with each PR. 200 | Coverage thresholds are not currently enforced. 201 | It is always nice and very welcomed to add tests and keep or increase the code coverage percentage. 202 | 203 | ### Pre PR Checklist 204 | 205 | This checklist is a helper to make sure there's no gotchas that come up when you submit a PR. 206 | 207 | - [ ] You've reviewed the [code of conduct](#Code-of-Conduct) 208 | - [ ] All commits are DCO signed off 209 | - [ ] Code is [formatted and linted](#Linting) 210 | - [ ] Code [builds](#Building) successfully 211 | - [ ] All tests are [passing](#Unit-testing) 212 | - [ ] Code coverage [percentage](#Code-coverage). (main line is the base with which to compare) 213 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # run `make image` to build the binary + container 2 | # if you're using `make build` this Dockerfile will not find the binary 3 | # and you probably want `make smee-linux-amd64` 4 | FROM alpine:3.22 5 | 6 | ARG TARGETARCH 7 | ARG TARGETVARIANT 8 | 9 | ENTRYPOINT ["/usr/bin/smee"] 10 | 11 | RUN apk add --update --upgrade --no-cache ca-certificates 12 | COPY cmd/smee/smee-linux-${TARGETARCH:-amd64}${TARGETVARIANT} /usr/bin/smee 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: help 2 | 3 | -include lint.mk 4 | -include rules.mk 5 | 6 | build: cmd/smee/smee ## Compile smee for host OS and Architecture 7 | 8 | crosscompile: $(crossbinaries) ## Compile smee for all architectures 9 | 10 | gen: $(generated_go_files) ## Generate go generate'd files 11 | 12 | IMAGE_TAG ?= smee:latest 13 | image: cmd/smee/smee-linux-amd64 ## Build docker image 14 | docker build -t $(IMAGE_TAG) . 15 | 16 | test: gen ## Run go test 17 | CGO_ENABLED=1 go test -race -coverprofile=coverage.txt -covermode=atomic -v ${TEST_ARGS} ./... 18 | 19 | coverage: test ## Show test coverage 20 | go tool cover -func=coverage.txt 21 | 22 | vet: ## Run go vet 23 | go vet ./... 24 | 25 | goimports: gen ## Run goimports 26 | $(GOIMPORTS) -w . 27 | 28 | ci-checks: .github/workflows/ci-checks.sh gen 29 | ./.github/workflows/ci-checks.sh 30 | 31 | ci: ci-checks coverage goimports lint vet ## Runs all the same validations and tests that run in CI 32 | 33 | help: ## Print this help 34 | @grep --no-filename -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sed 's/:.*##/·/' | sort | column -ts '·' -c 120 35 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | ## Process 4 | 5 | For version v0.x.y: 6 | 7 | 1. Create the annotated tag 8 | > NOTE: To use your GPG signature when pushing the tag, use `SIGN_TAG=1 ./contrib/tag-release.sh v0.x.y` instead) 9 | - `./contrib/tag-release.sh v0.x.y` 10 | 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. 11 | > NOTE: `origin` should be the name of the remote pointing to `github.com/tinkerbell/smee` 12 | - `git push origin v0.x.y` 13 | 1. Review the release on GitHub. 14 | 15 | ### Permissions 16 | 17 | Releasing requires a particular set of permissions. 18 | 19 | - Tag push access to the GitHub repository 20 | -------------------------------------------------------------------------------- /Tiltfile: -------------------------------------------------------------------------------- 1 | load('ext://restart_process', 'docker_build_with_restart') 2 | load('ext://local_output', 'local_output') 3 | load('ext://helm_resource', 'helm_resource') 4 | 5 | local_resource('compile smee', 6 | cmd='make cmd/smee/smee-linux-amd64', 7 | deps=["go.mod", "go.sum", "internal", "Dockerfile", "cmd/smee/main.go", "cmd/smee/flag.go", "cmd/smee/backend.go"], 8 | ) 9 | 10 | docker_build_with_restart( 11 | 'quay.io/tinkerbell/smee', 12 | '.', 13 | dockerfile='Dockerfile', 14 | entrypoint=['/usr/bin/smee'], 15 | live_update=[ 16 | sync('cmd/smee/smee-linux-amd64', '/usr/bin/smee'), 17 | ], 18 | ) 19 | default_registry('ttl.sh/meohmy-dghentld') 20 | 21 | default_trusted_proxies = local_output("kubectl get nodes -o jsonpath='{.items[*].spec.podCIDR}' | tr ' ' ','") 22 | trusted_proxies = os.getenv('TRUSTED_PROXIES', default_trusted_proxies) 23 | lb_ip = os.getenv('LB_IP', '') 24 | stack_version = os.getenv('STACK_CHART_VERSION', '0.5.0') 25 | stack_location = os.getenv('STACK_LOCATION', 'oci://ghcr.io/tinkerbell/charts/stack') # or a local path like '/home/tink/repos/tinkerbell/charts/tinkerbell/stack' 26 | namespace = 'tink' 27 | 28 | if lb_ip == '': 29 | fail('Please set the LB_IP environment variable. This is required to deploy the stack.') 30 | 31 | # to use a KinD cluster, add a macvlan interface into the KinD docker container. for example: `docker network connect macvlan kind-control-plane` 32 | # Then uncomment the 2 interface lines below. 33 | helm_resource('stack', 34 | chart=stack_location, 35 | namespace=namespace, 36 | image_deps=['quay.io/tinkerbell/smee'], 37 | image_keys=[('smee.image')], 38 | flags=[ 39 | '--create-namespace', 40 | '--version=%s' % stack_version, 41 | '--set=global.trustedProxies={%s}' % trusted_proxies, 42 | '--set=global.publicIP=%s' % lb_ip, 43 | #'--set=stack.kubevip.interface=eth1', 44 | #'--set=stack.relay.sourceInterface=eth1', 45 | ], 46 | release_name='stack' 47 | ) 48 | -------------------------------------------------------------------------------- /cmd/smee/backend.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-logr/logr" 7 | "github.com/tinkerbell/smee/internal/backend/file" 8 | "github.com/tinkerbell/smee/internal/backend/kube" 9 | "github.com/tinkerbell/smee/internal/backend/noop" 10 | "github.com/tinkerbell/smee/internal/dhcp/handler" 11 | "github.com/tinkerbell/tink/api/v1alpha1" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | "k8s.io/client-go/rest" 14 | "k8s.io/client-go/scale/scheme" 15 | "k8s.io/client-go/tools/clientcmd" 16 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api" 17 | "sigs.k8s.io/controller-runtime/pkg/cache" 18 | "sigs.k8s.io/controller-runtime/pkg/cluster" 19 | ) 20 | 21 | type Kube struct { 22 | // ConfigFilePath is the path to a kubernetes config file (kubeconfig). 23 | ConfigFilePath string 24 | // APIURL is the Kubernetes API URL. 25 | APIURL string 26 | // Namespace is an override for the Namespace the kubernetes client will watch. 27 | // The default is the Namespace the pod is running in. 28 | Namespace string 29 | Enabled bool 30 | } 31 | type File struct { 32 | // FilePath is the path to a JSON FilePath containing hardware data. 33 | FilePath string 34 | Enabled bool 35 | } 36 | 37 | type Noop struct { 38 | Enabled bool 39 | } 40 | 41 | func (n *Noop) backend() handler.BackendReader { 42 | return &noop.Backend{} 43 | } 44 | 45 | func (k *Kube) getClient() (*rest.Config, error) { 46 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 47 | loadingRules.ExplicitPath = k.ConfigFilePath 48 | 49 | overrides := &clientcmd.ConfigOverrides{ 50 | ClusterInfo: clientcmdapi.Cluster{ 51 | Server: k.APIURL, 52 | }, 53 | Context: clientcmdapi.Context{ 54 | Namespace: k.Namespace, 55 | }, 56 | } 57 | loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) 58 | 59 | return loader.ClientConfig() 60 | } 61 | 62 | func (k *Kube) backend(ctx context.Context) (handler.BackendReader, error) { 63 | config, err := k.getClient() 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | rs := runtime.NewScheme() 69 | 70 | if err := scheme.AddToScheme(rs); err != nil { 71 | return nil, err 72 | } 73 | 74 | if err := v1alpha1.AddToScheme(rs); err != nil { 75 | return nil, err 76 | } 77 | 78 | conf := func(opts *cluster.Options) { 79 | opts.Scheme = rs 80 | if k.Namespace != "" { 81 | opts.Cache.DefaultNamespaces = map[string]cache.Config{k.Namespace: {}} 82 | } 83 | } 84 | 85 | kb, err := kube.NewBackend(config, conf) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | go func() { 91 | err = kb.Start(ctx) 92 | if err != nil { 93 | panic(err) 94 | } 95 | }() 96 | 97 | return kb, nil 98 | } 99 | 100 | func (s *File) backend(ctx context.Context, logger logr.Logger) (handler.BackendReader, error) { 101 | f, err := file.NewWatcher(logger, s.FilePath) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | go f.Start(ctx) 107 | 108 | return f, nil 109 | } 110 | -------------------------------------------------------------------------------- /cmd/smee/flag_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | ) 11 | 12 | func TestParser(t *testing.T) { 13 | want := config{ 14 | syslog: syslogConfig{ 15 | enabled: true, 16 | bindAddr: "192.168.2.4", 17 | bindPort: 514, 18 | }, 19 | tftp: tftp{ 20 | blockSize: 512, 21 | enabled: true, 22 | timeout: 5 * time.Second, 23 | bindAddr: "192.168.2.4", 24 | bindPort: 69, 25 | }, 26 | ipxeHTTPBinary: ipxeHTTPBinary{ 27 | enabled: true, 28 | }, 29 | ipxeHTTPScript: ipxeHTTPScript{ 30 | enabled: true, 31 | bindAddr: "192.168.2.4", 32 | bindPort: 8080, 33 | retryDelay: 2, 34 | }, 35 | dhcp: dhcpConfig{ 36 | enabled: true, 37 | mode: "reservation", 38 | bindAddr: "0.0.0.0:67", 39 | ipForPacket: "192.168.2.4", 40 | syslogIP: "192.168.2.4", 41 | tftpIP: "192.168.2.4", 42 | tftpPort: 69, 43 | httpIpxeBinaryURL: urlBuilder{ 44 | Scheme: "http", 45 | Host: "192.168.2.4", 46 | Port: 8080, 47 | Path: "/ipxe/", 48 | }, 49 | httpIpxeScript: httpIpxeScript{ 50 | urlBuilder: urlBuilder{ 51 | Scheme: "http", 52 | Host: "192.168.2.4", 53 | Port: 8080, 54 | Path: "/auto.ipxe", 55 | }, 56 | injectMacAddress: true, 57 | }, 58 | }, 59 | iso: isoConfig{ 60 | enabled: true, 61 | url: "http://10.10.10.10:8787/hook.iso", 62 | magicString: magicString, 63 | }, 64 | logLevel: "info", 65 | backends: dhcpBackends{ 66 | file: File{}, 67 | kubernetes: Kube{Enabled: true}, 68 | }, 69 | otel: otelConfig{ 70 | insecure: true, 71 | }, 72 | } 73 | got := config{} 74 | fs := flag.NewFlagSet(name, flag.ContinueOnError) 75 | args := []string{ 76 | "-log-level", "info", 77 | "-syslog-addr", "192.168.2.4", 78 | "-tftp-addr", "192.168.2.4", 79 | "-http-addr", "192.168.2.4", 80 | "-dhcp-ip-for-packet", "192.168.2.4", 81 | "-dhcp-syslog-ip", "192.168.2.4", 82 | "-dhcp-tftp-ip", "192.168.2.4", 83 | "-dhcp-http-ipxe-binary-host", "192.168.2.4", 84 | "-dhcp-http-ipxe-script-host", "192.168.2.4", 85 | "-iso-enabled=true", 86 | "-iso-magic-string", magicString, 87 | "-iso-url", "http://10.10.10.10:8787/hook.iso", 88 | } 89 | cli := newCLI(&got, fs) 90 | cli.Parse(args) 91 | opts := cmp.Options{ 92 | cmp.AllowUnexported(config{}), 93 | cmp.AllowUnexported(syslogConfig{}), 94 | cmp.AllowUnexported(tftp{}), 95 | cmp.AllowUnexported(ipxeHTTPBinary{}), 96 | cmp.AllowUnexported(ipxeHTTPScript{}), 97 | cmp.AllowUnexported(dhcpConfig{}), 98 | cmp.AllowUnexported(dhcpBackends{}), 99 | cmp.AllowUnexported(httpIpxeScript{}), 100 | cmp.AllowUnexported(isoConfig{}), 101 | cmp.AllowUnexported(otelConfig{}), 102 | cmp.AllowUnexported(urlBuilder{}), 103 | } 104 | 105 | if diff := cmp.Diff(want, got, opts); diff != "" { 106 | t.Fatal(diff) 107 | } 108 | } 109 | 110 | func TestCustomUsageFunc(t *testing.T) { 111 | defaultIP := detectPublicIPv4() 112 | want := fmt.Sprintf(`Smee is the DHCP and Network boot service for use in the Tinkerbell stack. 113 | 114 | USAGE 115 | smee [flags] 116 | 117 | FLAGS 118 | -log-level log level (debug, info) (default "info") 119 | -backend-file-enabled [backend] enable the file backend for DHCP and the HTTP iPXE script (default "false") 120 | -backend-file-path [backend] the hardware yaml file path for the file backend 121 | -backend-kube-api [backend] the Kubernetes API URL, used for in-cluster client construction, kube backend only 122 | -backend-kube-config [backend] the Kubernetes config file location, kube backend only 123 | -backend-kube-enabled [backend] enable the kubernetes backend for DHCP and the HTTP iPXE script (default "true") 124 | -backend-kube-namespace [backend] an optional Kubernetes namespace override to query hardware data from, kube backend only 125 | -backend-noop-enabled [backend] enable the noop backend for DHCP and the HTTP iPXE script (default "false") 126 | -dhcp-addr [dhcp] local IP:Port to listen on for DHCP requests (default "0.0.0.0:67") 127 | -dhcp-enabled [dhcp] enable DHCP server (default "true") 128 | -dhcp-http-ipxe-binary-host [dhcp] HTTP iPXE binaries host or IP to use in DHCP packets (default "%[1]v") 129 | -dhcp-http-ipxe-binary-path [dhcp] HTTP iPXE binaries path to use in DHCP packets (default "/ipxe/") 130 | -dhcp-http-ipxe-binary-port [dhcp] HTTP iPXE binaries port to use in DHCP packets (default "8080") 131 | -dhcp-http-ipxe-binary-scheme [dhcp] HTTP iPXE binaries scheme to use in DHCP packets (default "http") 132 | -dhcp-http-ipxe-script-host [dhcp] HTTP iPXE script host or IP to use in DHCP packets (default "%[1]v") 133 | -dhcp-http-ipxe-script-path [dhcp] HTTP iPXE script path to use in DHCP packets (default "/auto.ipxe") 134 | -dhcp-http-ipxe-script-port [dhcp] HTTP iPXE script port to use in DHCP packets (default "8080") 135 | -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") 136 | -dhcp-http-ipxe-script-scheme [dhcp] HTTP iPXE script scheme to use in DHCP packets (default "http") 137 | -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} 138 | -dhcp-iface [dhcp] interface to bind to for DHCP requests 139 | -dhcp-ip-for-packet [dhcp] IP address to use in DHCP packets (opt 54, etc) (default "%[1]v") 140 | -dhcp-mode [dhcp] DHCP mode (reservation, proxy, auto-proxy) (default "reservation") 141 | -dhcp-syslog-ip [dhcp] Syslog server IP address to use in DHCP packets (opt 7) (default "%[1]v") 142 | -dhcp-tftp-ip [dhcp] TFTP server IP address to use in DHCP packets (opt 66, etc) (default "%[1]v") 143 | -dhcp-tftp-port [dhcp] TFTP server port to use in DHCP packets (opt 66, etc) (default "69") 144 | -extra-kernel-args [http] extra set of kernel args (k=v k=v) that are appended to the kernel cmdline iPXE script 145 | -http-addr [http] local IP to listen on for iPXE HTTP script requests (default "%[1]v") 146 | -http-ipxe-binary-enabled [http] enable iPXE HTTP binary server (default "true") 147 | -http-ipxe-script-enabled [http] enable iPXE HTTP script server (default "true") 148 | -http-port [http] local port to listen on for iPXE HTTP script requests (default "8080") 149 | -ipxe-script-retries [http] number of retries to attempt when fetching kernel and initrd files in the iPXE script (default "0") 150 | -ipxe-script-retry-delay [http] delay (in seconds) between retries when fetching kernel and initrd files in the iPXE script (default "2") 151 | -osie-url [http] URL where OSIE (HookOS) images are located 152 | -tink-server [http] IP:Port for the Tink server 153 | -tink-server-insecure-tls [http] use insecure TLS for Tink server (default "false") 154 | -tink-server-tls [http] use TLS for Tink server (default "false") 155 | -trusted-proxies [http] comma separated list of trusted proxies in CIDR notation 156 | -iso-enabled [iso] enable patching an OSIE ISO (default "false") 157 | -iso-magic-string [iso] the string pattern to match for in the source ISO, defaults to the one defined in HookOS 158 | -iso-static-ipam-enabled [iso] enable static IPAM for HookOS (default "false") 159 | -iso-url [iso] an ISO source URL target for patching 160 | -otel-endpoint [otel] OpenTelemetry collector endpoint 161 | -otel-insecure [otel] OpenTelemetry collector insecure (default "true") 162 | -syslog-addr [syslog] local IP to listen on for Syslog messages (default "%[1]v") 163 | -syslog-enabled [syslog] enable Syslog server(receiver) (default "true") 164 | -syslog-port [syslog] local port to listen on for Syslog messages (default "514") 165 | -ipxe-script-patch [tftp/http] iPXE script fragment to patch into served iPXE binaries served via TFTP or HTTP 166 | -tftp-addr [tftp] local IP to listen on for iPXE TFTP binary requests (default "%[1]v") 167 | -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") 168 | -tftp-enabled [tftp] enable iPXE TFTP binary server) (default "true") 169 | -tftp-port [tftp] local port to listen on for iPXE TFTP binary requests (default "69") 170 | -tftp-timeout [tftp] iPXE TFTP binary server requests timeout (default "5s") 171 | `, defaultIP) 172 | 173 | c := &config{} 174 | fs := flag.NewFlagSet(name, flag.ContinueOnError) 175 | cli := newCLI(c, fs) 176 | got := customUsageFunc(cli) 177 | if diff := cmp.Diff(want, got); diff != "" { 178 | t.Fatal(diff) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /contrib/tag-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit -o nounset -o pipefail 4 | 5 | if [ -z "${1-}" ]; then 6 | echo "Must specify new tag" 7 | exit 1 8 | fi 9 | 10 | new_tag=${1-} 11 | [[ $new_tag =~ ^v[0-9]*\.[0-9]*\.[0-9]*$ ]] || ( 12 | echo "Tag must be in the form of vX.Y.Z" 13 | exit 1 14 | ) 15 | 16 | if [[ $(git symbolic-ref HEAD) != refs/heads/main ]] && [[ -z ${ALLOW_NON_MAIN:-} ]]; then 17 | echo "Must be on main branch" >&2 18 | exit 1 19 | fi 20 | if [[ $(git describe --dirty) != $(git describe) ]]; then 21 | echo "Repo must be in a clean state" >&2 22 | exit 1 23 | fi 24 | 25 | git fetch --all 26 | 27 | last_tag=$(git describe --abbrev=0) 28 | last_tag_commit=$(git rev-list -n1 "$last_tag") 29 | last_specific_tag=$(git tag --contains="$last_tag_commit" | grep -E "^v[0-9]*\.[0-9]*\.[0-9]*$" | tail -n 1) 30 | last_specific_tag_commit=$(git rev-list -n1 "$last_specific_tag") 31 | if [[ $last_specific_tag_commit == $(git rev-list -n1 HEAD) ]]; then 32 | echo "No commits since last tag" >&2 33 | exit 1 34 | fi 35 | 36 | if [[ -n ${SIGN_TAG-} ]]; then 37 | git tag -s -m "${new_tag}" "${new_tag}" &>/dev/null && echo "created signed tag ${new_tag}" >&2 && exit 38 | else 39 | git tag -a -m "${new_tag}" "${new_tag}" &>/dev/null && echo "created annotated tag ${new_tag}" >&2 && exit 40 | fi 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Provides a docker-compose configuration for local fast iteration when 3 | # hacking on smee alone. 4 | # TODO: figure out if NET_ADMIN capability is really necessary 5 | 6 | version: "3.8" 7 | 8 | # use a custom network configuration to enable macvlan mode and set explicit 9 | # IPs and MACs as well as support mainstream DHCP clients for easier testing 10 | # standalone-hardware.json references these IPs and MACs so we can write 11 | # (simpler) assertions against behavior on the client side. 12 | networks: 13 | smee-test: 14 | # enables a more realistic L2 network for the containers 15 | driver: macvlan 16 | ipam: 17 | driver: default 18 | config: 19 | - subnet: 192.168.99.0/24 20 | gateway: 192.168.99.1 21 | 22 | services: 23 | smee: 24 | build: . 25 | # entrypoint: ["/usr/bin/smee", "--dhcp-addr", "0.0.0.0:67"] 26 | entrypoint: ["/start-smee.sh"] 27 | networks: 28 | smee-test: 29 | ipv4_address: 192.168.99.42 30 | mac_address: 02:00:00:00:00:01 31 | environment: 32 | SMEE_TINK_SERVER: tink-server:42113 33 | SMEE_BACKEND_KUBE_ENABLED: false 34 | SMEE_BACKEND_FILE_ENABLED: true 35 | SMEE_BACKEND_FILE_PATH: /hardware.yaml 36 | SMEE_OSIE_URL: "http://192.168.8.5/osie/artifacts/" 37 | OTEL_EXPORTER_OTLP_ENDPOINT: otel-collector:4317 38 | OTEL_EXPORTER_OTLP_INSECURE: "true" 39 | volumes: 40 | - ./test/hardware.yaml:/hardware.yaml 41 | - ./test/start-smee.sh:/start-smee.sh 42 | cap_add: 43 | - NET_ADMIN 44 | # eventually want to add more client containers, including one that smee will 45 | # not recognize so we can validate it won't serve content to IPs it's not 46 | # managing 47 | client: 48 | depends_on: 49 | - smee 50 | build: test 51 | networks: 52 | smee-test: 53 | ipv4_address: 192.168.99.43 54 | mac_address: 02:00:00:00:00:ff 55 | cap_add: 56 | - NET_ADMIN 57 | otel-collector: 58 | image: otel/opentelemetry-collector-contrib:0.38.0 59 | networks: 60 | smee-test: 61 | ipv4_address: 192.168.99.44 62 | volumes: 63 | - ./test/otel-collector.yaml:/etc/otel-collector.yaml 64 | command: --config /etc/otel-collector.yaml 65 | ports: 66 | - "4317:4317" 67 | -------------------------------------------------------------------------------- /docs/Backend-File.md: -------------------------------------------------------------------------------- 1 | # File Watcher Backend 2 | 3 | This document gives an overview of the file watcher backend. 4 | This backend will read in and watch a file on disk for changes. 5 | The data from this file will then be used for serving DHCP requests. 6 | 7 | ## Why 8 | 9 | This backend exists mainly for testing and development. 10 | 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). 11 | 12 | ## Usage 13 | 14 | ```bash 15 | # See the file example/main.go for details on how to select and use this backend in code. 16 | go run example/main.go 17 | ``` 18 | 19 | Below is an example of the format used for this file watcher backend. 20 | See this [example.yaml](../backend/file/testdata/example.yaml) for a full working example of the data model. 21 | 22 | ```yaml 23 | --- 24 | 08:00:27:29:4E:67: 25 | ipAddress: "192.168.2.153" 26 | subnetMask: "255.255.255.0" 27 | defaultGateway: "192.168.2.1" 28 | nameServers: 29 | - "8.8.8.8" 30 | - "1.1.1.1" 31 | hostname: "pxe-virtualbox" 32 | domainName: "example.com" 33 | broadcastAddress: "192.168.2.255" 34 | ntpServers: 35 | - "132.163.96.2" 36 | - "132.163.96.3" 37 | leaseTime: 86400 38 | domainSearch: 39 | - "example.com" 40 | netboot: 41 | allowPxe: true 42 | ipxeScriptUrl: "https://boot.netboot.xyz" 43 | 52:54:00:aa:88:2a: 44 | ipAddress: "192.168.2.15" 45 | subnetMask: "255.255.255.0" 46 | defaultGateway: "192.168.2.1" 47 | nameServers: 48 | - "8.8.8.8" 49 | - "1.1.1.1" 50 | hostname: "sandbox" 51 | domainName: "example.com" 52 | broadcastAddress: "192.168.2.255" 53 | ntpServers: 54 | - "132.163.96.2" 55 | - "132.163.96.3" 56 | leaseTime: 86400 57 | domainSearch: 58 | - "example.com" 59 | netboot: 60 | allowPxe: true 61 | ipxeScriptUrl: "https://boot.netboot.xyz" 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/Code-Structure.md: -------------------------------------------------------------------------------- 1 | # Code Structure 2 | 3 | ## Backend 4 | 5 | Responsible for communicating with an external persistence source and returning data from said source. 6 | Backends live in the `backend/` directory. 7 | 8 | ## Handler 9 | 10 | Responsible for reading a DHCP packet from a source, calling a backend, and responding to the source. 11 | All business logic for responding or reacting to DHCP messages lives here. 12 | Handlers live in the `handler/` directory. 13 | 14 | ## Listener 15 | 16 | Responsible for listening for UDP packets on the specified address and port. 17 | A default listener can be used. 18 | 19 | ## Server 20 | 21 | Responsible for filtering for DHCP packets received by the listener and calling the specified handler. 22 | 23 | ## Functional description 24 | 25 | Server(listener, handler(backend)) 26 | -------------------------------------------------------------------------------- /docs/DCO.md: -------------------------------------------------------------------------------- 1 | # DCO Sign Off 2 | 3 | All authors to the project retain copyright to their work. However, to ensure 4 | that they are only submitting work that they have rights to, we are requiring 5 | everyone to acknowledge this by signing their work. 6 | 7 | Since this signature indicates your rights to the contribution and 8 | certifies the statements below, it must contain your real name and 9 | email address. Various forms of noreply email address must not be used. 10 | 11 | Any copyright notices in this repository should specify the authors as "The 12 | project authors". 13 | 14 | To sign your work, just add a line like this at the end of your commit message: 15 | 16 | ```bash 17 | Signed-off-by: Jess Owens 18 | ``` 19 | 20 | This can easily be done with the `--signoff` option to `git commit`. 21 | 22 | By doing this you state that you can certify the following (from [https://developercertificate.org/][1]): 23 | 24 | ```text 25 | Developer Certificate of Origin 26 | Version 1.1 27 | 28 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 29 | 1 Letterman Drive 30 | Suite D4700 31 | San Francisco, CA, 94129 32 | 33 | Everyone is permitted to copy and distribute verbatim copies of this 34 | license document, but changing it is not allowed. 35 | 36 | 37 | Developer's Certificate of Origin 1.1 38 | 39 | By making a contribution to this project, I certify that: 40 | 41 | (a) The contribution was created in whole or in part by me and I 42 | have the right to submit it under the open source license 43 | indicated in the file; or 44 | 45 | (b) The contribution is based upon previous work that, to the best 46 | of my knowledge, is covered under an appropriate open source 47 | license and I have the right under that license to submit that 48 | work with modifications, whether created in whole or in part 49 | by me, under the same open source license (unless I am 50 | permitted to submit under a different license), as indicated 51 | in the file; or 52 | 53 | (c) The contribution was provided directly to me by some other 54 | person who certified (a), (b) or (c) and I have not modified 55 | it. 56 | 57 | (d) I understand and agree that this project and the contribution 58 | are public and that a record of the contribution (including all 59 | personal information I submit with it, including my sign-off) is 60 | maintained indefinitely and may be redistributed consistent with 61 | this project or the open source license(s) involved. 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/DESIGN.md: -------------------------------------------------------------------------------- 1 | # Smee Design Details 2 | 3 | ## Table of Contents 4 | 5 | - [Smee Flow](#Smee-Flow) 6 | - [Smee Installers](#Smee-Installers) 7 | - [IPXE](#IPXE) 8 | 9 | --- 10 | 11 | ## Smee Flow 12 | 13 | High-level traffic flow for Smee. 14 | 15 | ![smee-flow](smee-flow.png) 16 | 17 |
18 | Smee Flow Code 19 | 20 | Copy and paste the code below into [https://www.websequencediagrams.com](https://www.websequencediagrams.com) to modify 21 | 22 | ```flow 23 | title Smee Flow 24 | # DHCP 25 | note over Machine: DHCP start 26 | Machine->Smee: 1. DHCP Discover 27 | Smee->Tink: 2. Get Hardware data from MAC 28 | Tink->Smee: 3. Send Hardware data 29 | Smee->Machine: 4. DHCP Offer 30 | Machine->Smee: 5. DHCP Request 31 | Smee->Tink: 6. Get Hardware data from MAC 32 | Tink->Smee: 7. Send Hardware data 33 | Smee->Machine: 8. DHCP Ack 34 | note over Machine: DHCP end 35 | 36 | # TFTP 37 | note over Machine: TFTP start 38 | Machine->Smee: 9. TFTP Get ipxe binary 39 | Smee->Tink: 10. Get Hardware data from IP 40 | Tink->Smee: 11. Send Hardware data 41 | Smee->Machine: 12. Send ipxe binary 42 | note over Machine: TFTP end 43 | 44 | # DHCP 45 | note over Machine: DHCP start 46 | Machine->Smee: 13. DHCP Discover 47 | Smee->Tink: 14. Get Hardware data from MAC 48 | Tink->Smee: 15. Send Hardware data 49 | Smee->Machine: 16. DHCP Offer 50 | Machine->Smee: 17. DHCP Request 51 | Smee->Tink: 18. Get Hardware data from MAC 52 | Tink->Smee: 19. Send Hardware data 53 | Smee->Machine: 20. DHCP Ack 54 | note over Machine: DHCP end 55 | 56 | # HTTP 57 | note over Machine: HTTP start 58 | Machine->Smee: 21. HTTP Get ipxe script 59 | Smee->Tink: 22. Get Hardware data from IP 60 | Tink->Smee: 23. Send Hardware data 61 | Smee->Machine: 24. Send ipxe script 62 | note over Machine: HTTP start 63 | 64 | ``` 65 | 66 |
67 | 68 | ## Smee Installers 69 | 70 | A Smee Installer is a custom iPXE script. 71 | The code for each Installer lives in `installers/` 72 | The idea of iPXE Installers that live in-tree here is an idea that doesn't follow the existing template/workflow paradigm. 73 | Installers should eventually be deprecated. 74 | The deprecation process is forthcoming. 75 | 76 | ### How an Installers is requested 77 | 78 | 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). 79 | The contents of the auto.ipxe script is determined through the following steps: 80 | 81 | 1. A hardware record is retrieved based on the PXE-ing machines mac address. 82 | 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)) 83 | 1. If the `metadata.instance.operating_system.slug` matches a registered Installer, the iPXE script from that Installer is returned 84 | 2. If the `metadata.instance.operating_system.distro` matches a registered Installer, the iPXE script from that Installer 85 | 3. If neither of the first 2 is matched, then the default (OSIE) iPXE script is used 86 | 87 | ### Registering an Installer 88 | 89 | To register an Installer, at a minimum, the following is required 90 | 91 | 1. A [blank import](https://github.com/golang/go/wiki/CodeReviewComments#import-blank) for your Installer should be added to `main.go` 92 | 2. Your Installer pkg needs an `func init()` that calls `job.RegisterSlug("InstallerName", funcThatReturnsAnIPXEScript)` 93 | 94 | ### Testing Installers 95 | 96 | Unit tests should be created to validate that your registered func returns the iPXE script you're expecting. 97 | Functional tests would be great but depending on what is in your iPXE script might be difficult because of external dependencies. 98 | At a minimum try to create documentation that details these dependencies so that others can make them available for testing changes. 99 | 100 | ## IPXE 101 | 102 | Smee serves the upstream IPXE binaries built from [https://github.com/ipxe/ipxe](https://github.com/ipxe/ipxe). 103 | The IPXE binaries are built from source and then embedded into the Smee Go binary to be served via TFTP. 104 | 105 | ### Building the IPXE binary 106 | 107 | The IPXE binaries from [https://github.com/ipxe/ipxe](https://github.com/ipxe/ipxe) are built via a Make target. 108 | 109 | ```make 110 | make bindata 111 | ``` 112 | -------------------------------------------------------------------------------- /docs/DESIGNPHILOSOPHY.md: -------------------------------------------------------------------------------- 1 | # Design Philosophy 2 | 3 | This living document describes some Go design philosophies we endeavor to incorporate when working, building, or writing in Go. 4 | 5 | ## General 6 | 7 | 1. Prefer easy to understand over easy to do 8 | 2. First do it, then do it right, then do it better, then make it testable [14] 9 | 3. When you spawn goroutines, make it clear when - or whether - they exit. [2] 10 | 4. Packages that are imported only for their side effects should be avoided [4] 11 | 5. Package level and global variables should be avoided 12 | 6. magic is bad; global state is magic → no package level vars; no func init [13] 13 | 14 | ## Dependencies 15 | 16 | 1. External dependencies should be tried and fail fast or just keep trying 17 | - For example, external connections, port binding, environment variables, secrets, etc 18 | - Examples of "failing fast" 19 | - Try external connections immediately 20 | - Binding to ports immediately 21 | - Examples of "keep trying" 22 | - Block ingress traffic or calls until external connections are successful 23 | - Should be accompanied by some way to check health status of external connections 24 | 2. Make all dependencies explicit [11] 25 | 26 | ## Naming 27 | 28 | 1. Naming general rules [12] 29 | - Structs are plain nouns: API, Replica, Object 30 | - Interfaces are active nouns: Reader, Writer, JobProcessor 31 | - Functions and methods are verbs: Read, Process, Sync 32 | 2. Package names [15] 33 | - Short: no more than one word 34 | - No plural 35 | - Lower case 36 | - Informative about the service it provides 37 | - Avoid packages named utility/utilities or model/models 38 | 3. Avoid renaming imports except to avoid a name collision; good package names should not require renaming [3] 39 | 40 | ## Interfaces 41 | 42 | 1. Accept interfaces, return structs [5] 43 | 2. Small interfaces are better [6] 44 | 3. Define an interface when you actually need it, not when you foresee needing it [7] 45 | 4. Interfaces [15] 46 | - Use interfaces as function/method arguments & as field types 47 | - Small interfaces are better 48 | 49 | ## Functions/Methods 50 | 51 | 1. All top-level, exported names should have doc comments, as should non-trivial unexported type or function declarations. [1] 52 | 2. Methods/functions [15] 53 | - One function has one goal 54 | - Simple names 55 | - Reduce the number of nesting levels 56 | 3. Only func main has the right to decide which flags, env variables, config files are available to the user [10a],[10b] 57 | 4. `context.Context` should, in most cases, be the first argument of all functions or methods 58 | 5. Prefer synchronous functions - functions which return their results directly or finish any callbacks or channel ops before returning - over asynchronous ones. [8] 59 | 60 | ## Errors 61 | 62 | 1. Error Handling [15] 63 | - Func `main` should normally be the only one calling fatal errors or `os.Exit` 64 | 65 | ## Source files 66 | 67 | 1. One file should be named like the package [9] 68 | 2. One file = One responsibility [9] 69 | 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 70 | 71 | --- 72 | 73 | [1]: https://github.com/golang/go/wiki/CodeReviewComments#doc-comments 74 | [2]: https://github.com/golang/go/wiki/CodeReviewComments#goroutine-lifetimes 75 | [3]: https://github.com/golang/go/wiki/CodeReviewComments#imports 76 | [4]: https://github.com/golang/go/wiki/CodeReviewComments#import-blank 77 | [5]: https://medium.com/@cep21/what-accept-interfaces-return-structs-means-in-go-2fe879e25ee8 78 | [6]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#use-interfaces 79 | [7]: http://c2.com/xp/YouArentGonnaNeedIt.html 80 | [8]: https://github.com/golang/go/wiki/CodeReviewComments#synchronous-functions 81 | [9]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#source-files 82 | [10a]: https://thoughtbot.com/blog/where-to-define-command-line-flags-in-go 83 | [10b]: https://peter.bourgon.org/go-best-practices-2016/#configuration 84 | [11]: https://peter.bourgon.org/go-best-practices-2016/#top-tip-9 85 | [12]: https://twitter.com/peterbourgon/status/1121023995107782656 86 | [13]: https://peter.bourgon.org/blog/2017/06/09/theory-of-modern-go.html 87 | [14]: https://code.tutsplus.com/articles/master-developers-addy-osmani--net-31661 88 | [15]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#key-takeaways 89 | -------------------------------------------------------------------------------- /docs/DHCP.md: -------------------------------------------------------------------------------- 1 | # Use an existing DHCP service 2 | 3 | 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. 4 | 5 | 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. 6 | 7 | > Note: Currently, Smee is responsible for more than just DHCP. So generally speaking, Smee can't be entirely avoided in the provisioning process. 8 | 9 | ## Additional Services in Smee 10 | 11 | - HTTP and TFTP servers for iPXE binaries 12 | - HTTP server for iPXE script 13 | - Syslog server (receiver) 14 | 15 | ## Process 16 | 17 | 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. 18 | 19 | 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. 20 | 21 | - **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. 22 | 23 | - **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. 24 | 25 | > 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. 26 | 27 | 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. 28 | 29 | ![process](images/BYO_DHCP.png) 30 | 31 | ## Configuration 32 | 33 | 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. 34 | 35 | ### DHCP option: `next server` 36 | 37 | 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/) 38 | 39 | ### Code snippets 40 | 41 | 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. 42 | 43 | [dnsmasq](https://linux.die.net/man/8/dnsmasq) 44 | 45 | `dnsmasq.conf` 46 | 47 | ```text 48 | dhcp-match=tinkerbell, option:user-class, Tinkerbell 49 | dhcp-boot=tag:!tinkerbell,ipxe.efi,none,192.168.2.112 50 | dhcp-boot=tag:tinkerbell,http://192.168.2.112/auto.ipxe 51 | ``` 52 | 53 | [Kea DHCP](https://www.isc.org/kea/) 54 | 55 | `kea.json` 56 | 57 | ```json 58 | { 59 | "Dhcp4": { 60 | "client-classes": [ 61 | { 62 | "name": "tinkerbell", 63 | "test": "substring(option[77].hex,0,10) == 'Tinkerbell'", 64 | "boot-file-name": "http://192.168.2.112/auto.ipxe" 65 | }, 66 | { 67 | "name": "default", 68 | "test": "not(substring(option[77].hex,0,10) == 'Tinkerbell')", 69 | "boot-file-name": "ipxe.efi" 70 | } 71 | ], 72 | "subnet4": [ 73 | { 74 | "next-server": "192.168.2.112" 75 | } 76 | ] 77 | } 78 | } 79 | ``` 80 | 81 | [ISC DHCP](https://ipxe.org/howto/dhcpd) 82 | 83 | `dhcpd.conf` 84 | 85 | ```text 86 | if exists user-class and option user-class = "Tinkerbell" { 87 | filename "http://192.168.2.112/auto.ipxe"; 88 | } else { 89 | filename "ipxe.efi"; 90 | } 91 | next-server "192.168.1.112"; 92 | ``` 93 | 94 | [Microsoft DHCP server](https://learn.microsoft.com/en-us/windows-server/networking/technologies/dhcp/dhcp-top) 95 | 96 | Please follow the ipxe.org [guide](https://ipxe.org/howto/msdhcp) on how to configure Microsoft DHCP server. 97 | -------------------------------------------------------------------------------- /docs/Design-Philosophy.md: -------------------------------------------------------------------------------- 1 | # Design Philosophy 2 | 3 | This living document describes some Go design philosophies we endeavor to incorporate when working, building, or writing in Go. 4 | 5 | ## General 6 | 7 | 1. Prefer easy to understand over easy to do 8 | 2. First do it, then do it right, then do it better, then make it testable [14] 9 | 3. When you spawn goroutines, make it clear when - or whether - they exit. [2] 10 | 4. Packages that are imported only for their side effects should be avoided [4] 11 | 5. Package level and global variables should be avoided 12 | 6. magic is bad; global state is magic → no package level vars; no func init [13] 13 | 14 | ## Dependencies 15 | 16 | 1. External dependencies should be tried and fail fast or just keep trying 17 | - For example, external connections, port binding, environment variables, secrets, etc 18 | - Examples of "failing fast" 19 | - Try external connections immediately 20 | - Binding to ports immediately 21 | - Examples of "keep trying" 22 | - Block ingress traffic or calls until external connections are successful 23 | - Should be accompanied by some way to check health status of external connections 24 | 2. Make all dependencies explicit [11] 25 | 26 | ## Naming 27 | 28 | 1. Naming general rules [12] 29 | - Structs are plain nouns: API, Replica, Object 30 | - Interfaces are active nouns: Reader, Writer, JobProcessor 31 | - Functions and methods are verbs: Read, Process, Sync 32 | 2. Package names [15] 33 | - Short: no more than one word 34 | - No plural 35 | - Lower case 36 | - Informative about the service it provides 37 | - Avoid packages named utility/utilities or model/models 38 | 3. Avoid renaming imports except to avoid a name collision; good package names should not require renaming [3] 39 | 40 | ## Interfaces 41 | 42 | 1. Accept interfaces, return structs [5] 43 | 2. Small interfaces are better [6] 44 | 3. Define an interface when you actually need it, not when you foresee needing it [7] 45 | 4. Interfaces [15] 46 | - Use interfaces as function/method arguments & as field types 47 | - Small interfaces are better 48 | 49 | ## Functions/Methods 50 | 51 | 1. All top-level, exported names should have doc comments, as should non-trivial unexported type or function declarations. [1] 52 | 2. Methods/functions [15] 53 | - One function has one goal 54 | - Simple names 55 | - Reduce the number of nesting levels 56 | 3. Only func main has the right to decide which flags, env variables, config files are available to the user [10a],[10b] 57 | 4. `context.Context` should, in most cases, be the first argument of all functions or methods 58 | 5. Prefer synchronous functions - functions which return their results directly or finish any callbacks or channel ops before returning - over asynchronous ones. [8] 59 | 60 | ## Errors 61 | 62 | 1. Error Handling [15] 63 | - Func `main` should normally be the only one calling fatal errors or `os.Exit` 64 | 65 | ## Source files 66 | 67 | 1. One file should be named like the package [9] 68 | 2. One file = One responsibility [9] 69 | 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 70 | 71 | --- 72 | 73 | [1]: https://github.com/golang/go/wiki/CodeReviewComments#doc-comments 74 | [2]: https://github.com/golang/go/wiki/CodeReviewComments#goroutine-lifetimes 75 | [3]: https://github.com/golang/go/wiki/CodeReviewComments#imports 76 | [4]: https://github.com/golang/go/wiki/CodeReviewComments#import-blank 77 | [5]: https://medium.com/@cep21/what-accept-interfaces-return-structs-means-in-go-2fe879e25ee8 78 | [6]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#use-interfaces 79 | [7]: http://c2.com/xp/YouArentGonnaNeedIt.html 80 | [8]: https://github.com/golang/go/wiki/CodeReviewComments#synchronous-functions 81 | [9]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#source-files 82 | [10a]: https://thoughtbot.com/blog/where-to-define-command-line-flags-in-go 83 | [10b]: https://peter.bourgon.org/go-best-practices-2016/#configuration 84 | [11]: https://peter.bourgon.org/go-best-practices-2016/#top-tip-9 85 | [12]: https://twitter.com/peterbourgon/status/1121023995107782656 86 | [13]: https://peter.bourgon.org/blog/2017/06/09/theory-of-modern-go.html 87 | [14]: https://code.tutsplus.com/articles/master-developers-addy-osmani--net-31661 88 | [15]: https://www.practical-go-lessons.com/chap-40-design-recommendations?s=03#key-takeaways 89 | -------------------------------------------------------------------------------- /docs/ISO-Static-IPAM.md: -------------------------------------------------------------------------------- 1 | # Static IP Address Management in the OSIE ISO 2 | 3 | OSIE stands for operating system installation environment. In Tinkerbell we currently have just one, [HookOS](https://github.com/tinkerbell/hook). 4 | 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`. 5 | This document defines the specification/data format for passing this info to the HookOS ISO. 6 | 7 | ## Specification/Data format 8 | 9 | This is the spec/ data format for passing the static IP address management information to the HookOS ISO. 10 | 11 | ```ipam=::::::::``` 12 | 13 | Example: 14 | 15 | ```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``` 16 | 17 | ### Fields 18 | 19 | Some fields are required so that basic network communication can function properly. 20 | 21 | | Field | Description | Required | Example | 22 | |-------|-------------|----------|---------| 23 | | mac-address | MAC address. Must be in dash notation. | Yes |`00-00-00-00-00-00` | 24 | | vlan-id | VLAN ID. Must be a string integer between 0 and 4096 or an empty string for no VLAN tagging. | No | `30` | 25 | | ip-address | IPv4 address. | Yes | `10.148.56.3` | 26 | | netmask | Netmask. | Yes | `255.255.240.0` | 27 | | gateway | IPv4 Gateway. | No | `10.148.56.1` | 28 | | hostname | Hostname for the system. Can be fully qualified or not. | No | `hookos` or `hookos.example.com` | 29 | | dns | Comma separated list of IPv4 DNS nameservers. Must be IPv4 addresses, not hostnames. | Yes | `1.1.1.1,8.8.8.8` | 30 | | search-domains | Comma separated list of search domains. | No | `example.com,example.org` | 31 | | ntp | Comma separated list of IPv4 NTP servers. Must be IPv4 addresses, not hostnames. | No | `132.163.97.1,132.163.96.1` | 32 | 33 | ## Implementation details 34 | 35 | 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. 36 | -------------------------------------------------------------------------------- /docs/boots-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinkerbell/smee/5f31a4ab8025193a15b5071f1b778048836f65b3/docs/boots-flow.png -------------------------------------------------------------------------------- /docs/images/BYO_DHCP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinkerbell/smee/5f31a4ab8025193a15b5071f1b778048836f65b3/docs/images/BYO_DHCP.png -------------------------------------------------------------------------------- /docs/images/BYO_DHCP.uml: -------------------------------------------------------------------------------- 1 | title Bring your own DHCP service 2 | 3 | participant Machine 4 | participant DHCP 5 | participant Smee 6 | 7 | rbox over Machine,DHCP: 192.168.5.5 represents the IP from which the Smee service is available 8 | 9 | group #2f2e7b In firmware iPXE #white 10 | autonumber 1 11 | Machine->DHCP: DHCP discover 12 | 13 | DHCP->Machine: DHCP OFFER\nnext server: 192.168.2.5.5\nboot file: ipxe.efi 14 | 15 | Machine->DHCP: DHCP REQUEST 16 | 17 | DHCP->Machine: DHCP ACK\nnext server: 192.168.5.5\nboot file: ipxe.efi 18 | 19 | Machine->Smee: Download and boot **ipxe.efi** (TFTP or HTTP) 20 | end 21 | 22 | group #2f2e7b In Tinkerbell iPXE #white 23 | Machine->DHCP: DHCP DISCOVER 24 | 25 | DHCP->Machine: DHCP OFFER\nnext server: 192.168.5.5\nboot file: http://192.168.5.5/auto.ipxe 26 | 27 | Machine->DHCP: DHCP REQUEST 28 | 29 | DHCP->Machine: DHCP ACK\nnext server: 192.168.5.5\nboot file: http://192.168.5.5/auto.ipxe 30 | 31 | Machine->Smee: Download and execute **auto.ipxe** iPXE script (HTTP) 32 | 33 | destroysilent Machine 34 | destroysilent DHCP 35 | destroysilent Smee 36 | end 37 | -------------------------------------------------------------------------------- /docs/manifests/README.md: -------------------------------------------------------------------------------- 1 | # Deploying Smee 2 | 3 | This directory contains the manifests for deploying Smee to various environments. This document will describe how to use the different Smee deployment options. 4 | 5 | ## Variables 6 | 7 | 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. 8 | 9 | | Variable | Description | 10 | | --------------------------- | --------------------------------------------------------------------------------------------------- | 11 | | `TINKERBELL_GRPC_AUTHORITY` | This is the IP:Port that a Tink worker will use for communicated with the Tink server | 12 | | `MIRROR_BASE_URL` | The URL from where the "OSIE" or Hook kernel(s) and initrd(s) will be downloaded by netboot clients | 13 | | `PUBLIC_IP` | This is the IP that netboot clients and/or DHCP relay's will use to reach Smee | 14 | | `PUBLIC_SYSLOG_FQDN` | This is the IP that syslog clients will use to send messages | 15 | 16 | ## Deployment Options 17 | 18 | - [Kind](kind.md) 19 | - [Kubernetes](kubernetes.md) 20 | - [K3D](k3d.md) 21 | - [Tilt](tilt.md) 22 | -------------------------------------------------------------------------------- /docs/manifests/k3d.md: -------------------------------------------------------------------------------- 1 | # K3D (K3S in Docker) 2 | 3 | This describes deploying Smee into a K3S in Docker (K3D) cluster. 4 | 5 | ## Prerequisites 6 | 7 | - [K3D >= v5.4.1](https://k3d.io/v5.4.1/#installation) 8 | - [Kubectl >= v1.23.4](https://www.downloadkubernetes.com/) 9 | - Supported platforms: Linux 10 | 11 | ### Steps 12 | 13 | 1. Create K3D cluster 14 | 15 | ```bash 16 | # Create the K3D cluster 17 | k3d cluster create --network host --no-lb --k3s-arg "--disable=traefik" 18 | ``` 19 | 20 | 2. Deploy Smee 21 | 22 | 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. 23 | 24 | ```bash 25 | # Deploy Smee to K3D 26 | kubectl kustomize manifests/kustomize/overlays/k3d | kubectl apply -f - 27 | ``` 28 | 29 | 3. Watch the logs 30 | 31 | ```bash 32 | kubectl -n tinkerbell logs -f -l app=tinkerbell-smee 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/manifests/kind.md: -------------------------------------------------------------------------------- 1 | # KinD (Kubernetes in Docker) 2 | 3 | This describes deploying Smee into a Kubernetes in Docker (KinD) cluster. 4 | 5 | ## Prerequisites 6 | 7 | - [KinD >= v0.12.0](https://kind.sigs.k8s.io/docs/user/quick-start#installation) 8 | - [Kubectl >= v1.23.4](https://www.downloadkubernetes.com/) 9 | 10 | ## Steps 11 | 12 | 1. Create KinD cluster 13 | 14 | ```bash 15 | # Create the KinD cluster 16 | kind create cluster --config ./manifests/kind/config.yaml 17 | ``` 18 | 19 | 2. Deploy Smee 20 | 21 | 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. 22 | 23 | ```bash 24 | # Deploy Smee to KinD 25 | kubectl kustomize manifests/kustomize/overlays/kind | kubectl apply -f - 26 | ``` 27 | 28 | 3. Watch the logs 29 | 30 | ```bash 31 | kubectl -n tinkerbell logs -f -l app=tinkerbell-smee 32 | ``` 33 | 34 | > **Note:** KinD will not be able to listen for DHCP broadcast traffic. Using a DHCP relay is recommended. 35 | > 36 | > ```bash 37 | > # Linux direct 38 | > ipaddr=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' kind-control-plane) 39 | > sudo -E dhcrelay -id -iu $(ip -o route get ${ipaddr} | cut -d" " -f3) -d ${ipaddr} 40 | > 41 | > # Linux Container 42 | > ipaddr=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' kind-control-plane) 43 | > docker run -d --network host --name dhcrelay modem7/dhcprelay:latest -id -iu $(ip -o route get ${ipaddr} | cut -d" " -f3) -d ${ipaddr} 44 | > 45 | > # MacOS TBD 46 | > ``` 47 | -------------------------------------------------------------------------------- /docs/manifests/kubernetes.md: -------------------------------------------------------------------------------- 1 | # Kubernetes 2 | 3 | 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. 4 | 5 | ## Prerequisites 6 | 7 | TBD 8 | 9 | ## Steps 10 | 11 | 1. Deploy Smee 12 | 13 | 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. 14 | 15 | ```bash 16 | # Deploy Smee to Kubernetes 17 | kubectl kustomize manifests/kustomize/overlays/dev | kubectl apply -f - 18 | ``` 19 | 20 | 2. Watch the logs 21 | 22 | ```bash 23 | kubectl -n tinkerbell logs -f -l app=tinkerbell-smee 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/manifests/tilt.md: -------------------------------------------------------------------------------- 1 | # Tilt 2 | 3 | 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. 4 | 5 | ## Prerequisites 6 | 7 | - [Tilt >= v0.28.1](https://docs.tilt.dev/install.html) 8 | - Go >= 1.18 9 | - [Kubectl >= v1.23.4](https://www.downloadkubernetes.com/) 10 | - KinD cluster 11 | 12 | ## Steps 13 | 14 | 1. Deploy Smee 15 | 16 | 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. 17 | This deployment method uses the kustomize kind overlay (`manifests/kustomize/overlays/kind`). See the `Tiltfile` modify this. 18 | 19 | ```bash 20 | # Deploy Smee with Tilt 21 | tilt up --stream 22 | ``` 23 | 24 | 2. Watch the logs 25 | 26 | ```bash 27 | kubectl -n tinkerbell logs -f -l app=tinkerbell-smee 28 | ``` 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tinkerbell/smee 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/ccoveille/go-safecast v1.6.1 9 | github.com/diskfs/go-diskfs v1.6.0 10 | github.com/fsnotify/fsnotify v1.9.0 11 | github.com/ghodss/yaml v1.0.0 12 | github.com/go-logr/logr v1.4.3 13 | github.com/go-logr/stdr v1.2.2 14 | github.com/google/go-cmp v0.7.0 15 | github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f 16 | github.com/peterbourgon/ff/v3 v3.4.0 17 | github.com/prometheus/client_golang v1.22.0 18 | github.com/stretchr/testify v1.10.0 19 | github.com/tinkerbell/ipxedust v0.0.0-20250129162407-3c29a914f8be 20 | github.com/tinkerbell/tink v0.12.2 21 | github.com/vishvananda/netlink v1.3.1 22 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 23 | go.opentelemetry.io/otel v1.37.0 24 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 25 | go.opentelemetry.io/otel/sdk v1.37.0 26 | go.opentelemetry.io/otel/trace v1.37.0 27 | golang.org/x/net v0.41.0 28 | golang.org/x/sync v0.15.0 29 | golang.org/x/sys v0.33.0 30 | google.golang.org/grpc v1.73.0 31 | k8s.io/apimachinery v0.33.2 32 | k8s.io/client-go v0.33.2 33 | sigs.k8s.io/controller-runtime v0.21.0 34 | ) 35 | 36 | require ( 37 | dario.cat/mergo v1.0.1 // indirect 38 | github.com/beorn7/perks v1.0.1 // indirect 39 | github.com/cenkalti/backoff/v5 v5.0.2 // indirect 40 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 41 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 42 | github.com/djherbis/times v1.6.0 // indirect 43 | github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab // indirect 44 | github.com/emicklei/go-restful/v3 v3.12.1 // indirect 45 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 46 | github.com/felixge/httpsnoop v1.0.4 // indirect 47 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 48 | github.com/gabriel-vasile/mimetype v1.4.6 // indirect 49 | github.com/go-logr/zerologr v1.2.3 // indirect 50 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 51 | github.com/go-openapi/jsonreference v0.21.0 // indirect 52 | github.com/go-openapi/swag v0.23.0 // indirect 53 | github.com/go-playground/locales v0.14.1 // indirect 54 | github.com/go-playground/universal-translator v0.18.1 // indirect 55 | github.com/go-playground/validator/v10 v10.22.1 // indirect 56 | github.com/gogo/protobuf v1.3.2 // indirect 57 | github.com/google/gnostic-models v0.6.9 // indirect 58 | github.com/google/uuid v1.6.0 // indirect 59 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect 60 | github.com/josharian/intern v1.0.0 // indirect 61 | github.com/josharian/native v1.1.0 // indirect 62 | github.com/json-iterator/go v1.1.12 // indirect 63 | github.com/klauspost/compress v1.18.0 // indirect 64 | github.com/leodido/go-urn v1.4.0 // indirect 65 | github.com/mailru/easyjson v0.7.7 // indirect 66 | github.com/mattn/go-colorable v0.1.13 // indirect 67 | github.com/mattn/go-isatty v0.0.20 // indirect 68 | github.com/mdlayher/packet v1.1.2 // indirect 69 | github.com/mdlayher/socket v0.4.1 // indirect 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 71 | github.com/modern-go/reflect2 v1.0.2 // indirect 72 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 73 | github.com/pierrec/lz4/v4 v4.1.19 // indirect 74 | github.com/pin/tftp/v3 v3.1.0 // indirect 75 | github.com/pkg/errors v0.9.1 // indirect 76 | github.com/pkg/xattr v0.4.9 // indirect 77 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 78 | github.com/prometheus/client_model v0.6.1 // indirect 79 | github.com/prometheus/common v0.62.0 // indirect 80 | github.com/prometheus/procfs v0.15.1 // indirect 81 | github.com/rs/zerolog v1.33.0 // indirect 82 | github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect 83 | github.com/spf13/pflag v1.0.5 // indirect 84 | github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect 85 | github.com/ulikunitz/xz v0.5.11 // indirect 86 | github.com/vishvananda/netns v0.0.5 // indirect 87 | github.com/x448/float16 v0.8.4 // indirect 88 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 89 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect 90 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 91 | go.opentelemetry.io/proto/otlp v1.7.0 // indirect 92 | golang.org/x/crypto v0.39.0 // indirect 93 | golang.org/x/oauth2 v0.30.0 // indirect 94 | golang.org/x/term v0.32.0 // indirect 95 | golang.org/x/text v0.26.0 // indirect 96 | golang.org/x/time v0.9.0 // indirect 97 | google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 98 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 99 | google.golang.org/protobuf v1.36.6 // indirect 100 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 101 | gopkg.in/inf.v0 v0.9.1 // indirect 102 | gopkg.in/yaml.v2 v2.4.0 // indirect 103 | gopkg.in/yaml.v3 v3.0.1 // indirect 104 | k8s.io/api v0.33.2 // indirect 105 | k8s.io/klog/v2 v2.130.1 // indirect 106 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 107 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 108 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 109 | sigs.k8s.io/randfill v1.0.0 // indirect 110 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 111 | sigs.k8s.io/yaml v1.4.0 // indirect 112 | ) 113 | -------------------------------------------------------------------------------- /internal/backend/file/testdata/example.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 08:00:27:29:4E:67: 3 | ipAddress: "192.168.2.153" 4 | subnetMask: "255.255.255.0" 5 | defaultGateway: "192.168.2.1" 6 | nameServers: 7 | - "8.8.8.8" 8 | - "1.1.1.1" 9 | hostname: "pxe-virtualbox" 10 | domainName: "example.com" 11 | broadcastAddress: "192.168.2.255" 12 | ntpServers: 13 | - "132.163.96.2" 14 | - "132.163.96.3" 15 | leaseTime: 86400 16 | domainSearch: 17 | - "example.com" 18 | netboot: 19 | allowPxe: true 20 | ipxeScriptUrl: "https://boot.netboot.xyz" 21 | 52:54:00:aa:88:2a: 22 | ipAddress: "192.168.2.15" 23 | subnetMask: "255.255.255.0" 24 | defaultGateway: "192.168.2.1" 25 | nameServers: 26 | - "8.8.8.8" 27 | - "1.1.1.1" 28 | hostname: "sandbox" 29 | domainName: "example.com" 30 | broadcastAddress: "192.168.2.255" 31 | ntpServers: 32 | - "132.163.96.2" 33 | - "132.163.96.3" 34 | leaseTime: 86400 35 | domainSearch: 36 | - "example.com" 37 | netboot: 38 | allowPxe: true 39 | ipxeScriptUrl: "https://boot.netboot.xyz" 40 | 86:96:b0:6e:ca:36: 41 | ipAddress: "192.168.2.158" 42 | subnetMask: "255.255.255.0" 43 | defaultGateway: "192.168.2.1" 44 | nameServers: 45 | - "8.8.8.8" 46 | - "1.1.1.1" 47 | hostname: "pxe-proxmox" 48 | domainName: "example.com" 49 | broadcastAddress: "192.168.2.255" 50 | ntpServers: 51 | - "132.163.96.2" 52 | - "132.163.96.3" 53 | leaseTime: 86400 54 | domainSearch: 55 | - "example.com" 56 | netboot: 57 | allowPxe: true 58 | ipxeScriptUrl: "http://boot.netboot.xyz" 59 | b4:96:91:6f:33:d0: 60 | ipAddress: "192.168.56.15" 61 | subnetMask: "255.255.255.0" 62 | defaultGateway: "192.168.56.4" 63 | nameServers: 64 | - "8.8.8.8" 65 | - "1.1.1.1" 66 | hostname: "dhcp-testing" 67 | domainName: "example.com" 68 | broadcastAddress: "192.168.56.255" 69 | ntpServers: 70 | - "132.163.96.2" 71 | - "132.163.96.3" 72 | leaseTime: 86400 73 | domainSearch: 74 | - "example.com" 75 | netboot: 76 | allowPxe: true 77 | ipxeScriptUrl: "https://boot.netboot.xyz" 78 | 08:00:27:29:4E:68: # bad data 79 | ipAddress: "3" 80 | subnetMask: "255.255.255.0" 81 | -------------------------------------------------------------------------------- /internal/backend/kube/error.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "net/http" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | ) 8 | 9 | type hardwareNotFoundError struct{} 10 | 11 | func (hardwareNotFoundError) NotFound() bool { return true } 12 | 13 | func (hardwareNotFoundError) Error() string { return "hardware not found" } 14 | 15 | // Status() implements the APIStatus interface from apimachinery/pkg/api/errors 16 | // so that IsNotFound function could be used against this error type. 17 | func (hardwareNotFoundError) Status() metav1.Status { 18 | return metav1.Status{ 19 | Reason: metav1.StatusReasonNotFound, 20 | Code: http.StatusNotFound, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/backend/kube/index.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "github.com/tinkerbell/tink/api/v1alpha1" 5 | "sigs.k8s.io/controller-runtime/pkg/client" 6 | ) 7 | 8 | // MACAddrIndex is an index used with a controller-runtime client to lookup hardware by MAC. 9 | const MACAddrIndex = ".Spec.Interfaces.MAC" 10 | 11 | // MACAddrs returns a list of MAC addresses for a Hardware object. 12 | func MACAddrs(obj client.Object) []string { 13 | hw, ok := obj.(*v1alpha1.Hardware) 14 | if !ok { 15 | return nil 16 | } 17 | return GetMACs(hw) 18 | } 19 | 20 | // GetMACs retrieves all MACs associated with h. 21 | func GetMACs(h *v1alpha1.Hardware) []string { 22 | var macs []string 23 | for _, i := range h.Spec.Interfaces { 24 | if i.DHCP != nil && i.DHCP.MAC != "" { 25 | macs = append(macs, i.DHCP.MAC) 26 | } 27 | } 28 | 29 | return macs 30 | } 31 | 32 | // IPAddrIndex is an index used with a controller-runtime client to lookup hardware by IP. 33 | const IPAddrIndex = ".Spec.Interfaces.DHCP.IP" 34 | 35 | // IPAddrs returns a list of IP addresses for a Hardware object. 36 | func IPAddrs(obj client.Object) []string { 37 | hw, ok := obj.(*v1alpha1.Hardware) 38 | if !ok { 39 | return nil 40 | } 41 | return GetIPs(hw) 42 | } 43 | 44 | // GetIPs retrieves all IP addresses. 45 | func GetIPs(h *v1alpha1.Hardware) []string { 46 | var ips []string 47 | for _, i := range h.Spec.Interfaces { 48 | if i.DHCP != nil && i.DHCP.IP != nil && i.DHCP.IP.Address != "" { 49 | ips = append(ips, i.DHCP.IP.Address) 50 | } 51 | } 52 | return ips 53 | } 54 | -------------------------------------------------------------------------------- /internal/backend/kube/index_test.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/tinkerbell/tink/api/v1alpha1" 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | ) 10 | 11 | func TestMACAddrs(t *testing.T) { 12 | tests := map[string]struct { 13 | hw client.Object 14 | want []string 15 | }{ 16 | "not a v1alpha1.Hardware object": {hw: &v1alpha1.Workflow{}, want: nil}, 17 | "2 MACs": {hw: &v1alpha1.Hardware{ 18 | Spec: v1alpha1.HardwareSpec{ 19 | Interfaces: []v1alpha1.Interface{ 20 | { 21 | DHCP: &v1alpha1.DHCP{ 22 | MAC: "00:00:00:00:00:00", 23 | }, 24 | }, 25 | { 26 | DHCP: &v1alpha1.DHCP{ 27 | MAC: "00:00:00:00:00:01", 28 | }, 29 | }, 30 | { 31 | DHCP: &v1alpha1.DHCP{}, 32 | }, 33 | }, 34 | }, 35 | }, want: []string{"00:00:00:00:00:00", "00:00:00:00:00:01"}}, 36 | "no interfaces": {hw: &v1alpha1.Hardware{}, want: nil}, 37 | } 38 | for name, tc := range tests { 39 | t.Run(name, func(t *testing.T) { 40 | macs := MACAddrs(tc.hw) 41 | if diff := cmp.Diff(macs, tc.want); diff != "" { 42 | t.Errorf("unexpected MACs (+want -got):\n%s", diff) 43 | } 44 | }) 45 | } 46 | } 47 | 48 | func TestIPAddrs(t *testing.T) { 49 | tests := map[string]struct { 50 | hw client.Object 51 | want []string 52 | }{ 53 | "not a v1alpha1.Hardware object": {hw: &v1alpha1.Workflow{}, want: nil}, 54 | "2 IPs": {hw: &v1alpha1.Hardware{ 55 | Spec: v1alpha1.HardwareSpec{ 56 | Interfaces: []v1alpha1.Interface{ 57 | { 58 | DHCP: &v1alpha1.DHCP{ 59 | IP: &v1alpha1.IP{ 60 | Address: "192.168.2.1", 61 | }, 62 | }, 63 | }, 64 | { 65 | DHCP: &v1alpha1.DHCP{ 66 | IP: &v1alpha1.IP{ 67 | Address: "192.168.2.2", 68 | }, 69 | }, 70 | }, 71 | { 72 | DHCP: &v1alpha1.DHCP{}, 73 | }, 74 | { 75 | DHCP: &v1alpha1.DHCP{ 76 | IP: &v1alpha1.IP{}, 77 | }, 78 | }, 79 | }, 80 | }, 81 | }, want: []string{"192.168.2.1", "192.168.2.2"}}, 82 | "no interfaces": {hw: &v1alpha1.Hardware{}, want: nil}, 83 | } 84 | for name, tc := range tests { 85 | t.Run(name, func(t *testing.T) { 86 | got := IPAddrs(tc.hw) 87 | if diff := cmp.Diff(tc.want, got); diff != "" { 88 | t.Errorf("unexpected IPs (-want +got):\n%s", diff) 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internal/backend/kube/kube.go: -------------------------------------------------------------------------------- 1 | // Package kube is a backend implementation that uses the Tinkerbell CRDs to get DHCP data. 2 | package kube 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "net/netip" 10 | "net/url" 11 | 12 | "github.com/ccoveille/go-safecast" 13 | "github.com/tinkerbell/smee/internal/dhcp/data" 14 | "github.com/tinkerbell/tink/api/v1alpha1" 15 | "go.opentelemetry.io/otel" 16 | "go.opentelemetry.io/otel/codes" 17 | "k8s.io/client-go/rest" 18 | "sigs.k8s.io/controller-runtime/pkg/client" 19 | "sigs.k8s.io/controller-runtime/pkg/cluster" 20 | ) 21 | 22 | const tracerName = "github.com/tinkerbell/smee/dhcp" 23 | 24 | // Backend is a backend implementation that uses the Tinkerbell CRDs to get DHCP data. 25 | type Backend struct { 26 | cluster cluster.Cluster 27 | } 28 | 29 | // NewBackend returns a controller-runtime cluster.Cluster with the Tinkerbell runtime 30 | // scheme registered, and indexers for: 31 | // * Hardware by MAC address 32 | // * Hardware by IP address 33 | // 34 | // Callers must instantiate the client-side cache by calling Start() before use. 35 | func NewBackend(conf *rest.Config, opts ...cluster.Option) (*Backend, error) { 36 | c, err := cluster.New(conf, opts...) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to create new cluster config: %w", err) 39 | } 40 | 41 | if err := c.GetFieldIndexer().IndexField(context.Background(), &v1alpha1.Hardware{}, MACAddrIndex, MACAddrs); err != nil { 42 | return nil, fmt.Errorf("failed to setup indexer: %w", err) 43 | } 44 | 45 | if err := c.GetFieldIndexer().IndexField(context.Background(), &v1alpha1.Hardware{}, IPAddrIndex, IPAddrs); err != nil { 46 | return nil, fmt.Errorf("failed to setup indexer(.spec.interfaces.dhcp.ip.address): %w", err) 47 | } 48 | 49 | return &Backend{cluster: c}, nil 50 | } 51 | 52 | // Start starts the client-side cache. 53 | func (b *Backend) Start(ctx context.Context) error { 54 | return b.cluster.Start(ctx) 55 | } 56 | 57 | // GetByMac implements the handler.BackendReader interface and returns DHCP and netboot data based on a mac address. 58 | func (b *Backend) GetByMac(ctx context.Context, mac net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { 59 | tracer := otel.Tracer(tracerName) 60 | ctx, span := tracer.Start(ctx, "backend.kube.GetByMac") 61 | defer span.End() 62 | hardwareList := &v1alpha1.HardwareList{} 63 | 64 | if err := b.cluster.GetClient().List(ctx, hardwareList, &client.MatchingFields{MACAddrIndex: mac.String()}); err != nil { 65 | span.SetStatus(codes.Error, err.Error()) 66 | 67 | return nil, nil, fmt.Errorf("failed listing hardware for (%v): %w", mac, err) 68 | } 69 | 70 | if len(hardwareList.Items) == 0 { 71 | err := hardwareNotFoundError{} 72 | span.SetStatus(codes.Error, err.Error()) 73 | 74 | return nil, nil, err 75 | } 76 | 77 | if len(hardwareList.Items) > 1 { 78 | err := fmt.Errorf("got %d hardware objects for mac %s, expected only 1", len(hardwareList.Items), mac) 79 | span.SetStatus(codes.Error, err.Error()) 80 | 81 | return nil, nil, err 82 | } 83 | 84 | i := v1alpha1.Interface{} 85 | for _, iface := range hardwareList.Items[0].Spec.Interfaces { 86 | if iface.DHCP.MAC == mac.String() { 87 | i = iface 88 | break 89 | } 90 | } 91 | 92 | d, n, err := transform(i, hardwareList.Items[0].Spec.Metadata) 93 | if err != nil { 94 | span.SetStatus(codes.Error, err.Error()) 95 | 96 | return nil, nil, err 97 | } 98 | 99 | span.SetAttributes(d.EncodeToAttributes()...) 100 | span.SetAttributes(n.EncodeToAttributes()...) 101 | span.SetStatus(codes.Ok, "") 102 | 103 | return d, n, nil 104 | } 105 | 106 | // GetByIP implements the handler.BackendReader interface and returns DHCP and netboot data based on an IP address. 107 | func (b *Backend) GetByIP(ctx context.Context, ip net.IP) (*data.DHCP, *data.Netboot, error) { 108 | tracer := otel.Tracer(tracerName) 109 | ctx, span := tracer.Start(ctx, "backend.kube.GetByIP") 110 | defer span.End() 111 | hardwareList := &v1alpha1.HardwareList{} 112 | 113 | if err := b.cluster.GetClient().List(ctx, hardwareList, &client.MatchingFields{IPAddrIndex: ip.String()}); err != nil { 114 | span.SetStatus(codes.Error, err.Error()) 115 | 116 | return nil, nil, fmt.Errorf("failed listing hardware for (%v): %w", ip, err) 117 | } 118 | 119 | if len(hardwareList.Items) == 0 { 120 | err := hardwareNotFoundError{} 121 | span.SetStatus(codes.Error, err.Error()) 122 | 123 | return nil, nil, err 124 | } 125 | 126 | if len(hardwareList.Items) > 1 { 127 | err := fmt.Errorf("got %d hardware objects for ip: %s, expected only 1", len(hardwareList.Items), ip) 128 | span.SetStatus(codes.Error, err.Error()) 129 | 130 | return nil, nil, err 131 | } 132 | 133 | i := v1alpha1.Interface{} 134 | for _, iface := range hardwareList.Items[0].Spec.Interfaces { 135 | if iface.DHCP.IP.Address == ip.String() { 136 | i = iface 137 | break 138 | } 139 | } 140 | 141 | d, n, err := transform(i, hardwareList.Items[0].Spec.Metadata) 142 | if err != nil { 143 | span.SetStatus(codes.Error, err.Error()) 144 | 145 | return nil, nil, err 146 | } 147 | 148 | span.SetAttributes(d.EncodeToAttributes()...) 149 | span.SetAttributes(n.EncodeToAttributes()...) 150 | span.SetStatus(codes.Ok, "") 151 | 152 | return d, n, nil 153 | } 154 | 155 | // toDHCPData converts a v1alpha1.DHCP to a data.DHCP data structure. 156 | // if required fields are missing, an error is returned. 157 | // Required fields: v1alpha1.Interface.DHCP.MAC, v1alpha1.Interface.DHCP.IP.Address, v1alpha1.Interface.DHCP.IP.Netmask. 158 | func toDHCPData(h *v1alpha1.DHCP) (*data.DHCP, error) { 159 | if h == nil { 160 | return nil, errors.New("no DHCP data") 161 | } 162 | d := new(data.DHCP) 163 | 164 | var err error 165 | // MACAddress is required 166 | if d.MACAddress, err = net.ParseMAC(h.MAC); err != nil { 167 | return nil, err 168 | } 169 | 170 | if h.IP != nil { 171 | // IPAddress is required 172 | if d.IPAddress, err = netip.ParseAddr(h.IP.Address); err != nil { 173 | return nil, err 174 | } 175 | // Netmask is required 176 | sm := net.ParseIP(h.IP.Netmask) 177 | if sm == nil { 178 | return nil, errors.New("no netmask") 179 | } 180 | d.SubnetMask = net.IPMask(sm.To4()) 181 | } else { 182 | return nil, errors.New("no IP data") 183 | } 184 | 185 | // Gateway is optional, but should be a valid IP address if present 186 | if h.IP.Gateway != "" { 187 | if d.DefaultGateway, err = netip.ParseAddr(h.IP.Gateway); err != nil { 188 | return nil, err 189 | } 190 | } 191 | 192 | // name servers, optional 193 | for _, s := range h.NameServers { 194 | ip := net.ParseIP(s) 195 | if ip == nil { 196 | break 197 | } 198 | d.NameServers = append(d.NameServers, ip) 199 | } 200 | 201 | // timeservers, optional 202 | for _, s := range h.TimeServers { 203 | ip := net.ParseIP(s) 204 | if ip == nil { 205 | break 206 | } 207 | d.NTPServers = append(d.NTPServers, ip) 208 | } 209 | 210 | // hostname, optional 211 | d.Hostname = h.Hostname 212 | 213 | // lease time required 214 | // Default to one week 215 | d.LeaseTime = 604800 216 | if v, err := safecast.ToUint32(h.LeaseTime); err == nil { 217 | d.LeaseTime = v 218 | } 219 | 220 | // arch 221 | d.Arch = h.Arch 222 | 223 | // vlanid 224 | d.VLANID = h.VLANID 225 | 226 | return d, nil 227 | } 228 | 229 | // toNetbootData converts a hardware interface to a data.Netboot data structure. 230 | func toNetbootData(i *v1alpha1.Netboot, facility string) (*data.Netboot, error) { 231 | if i == nil { 232 | return nil, errors.New("no netboot data") 233 | } 234 | n := new(data.Netboot) 235 | 236 | // allow machine to netboot 237 | if i.AllowPXE != nil { 238 | n.AllowNetboot = *i.AllowPXE 239 | } 240 | 241 | // ipxe script url is optional but if provided, it must be a valid url 242 | if i.IPXE != nil { 243 | if i.IPXE.URL != "" { 244 | u, err := url.ParseRequestURI(i.IPXE.URL) 245 | if err != nil { 246 | return nil, err 247 | } 248 | n.IPXEScriptURL = u 249 | } 250 | } 251 | 252 | // ipxescript 253 | if i.IPXE != nil { 254 | n.IPXEScript = i.IPXE.Contents 255 | } 256 | 257 | // console 258 | n.Console = "" 259 | 260 | // facility 261 | n.Facility = facility 262 | 263 | // OSIE data 264 | n.OSIE = data.OSIE{} 265 | if i.OSIE != nil { 266 | if b, err := url.Parse(i.OSIE.BaseURL); err == nil { 267 | n.OSIE.BaseURL = b 268 | } 269 | n.OSIE.Kernel = i.OSIE.Kernel 270 | n.OSIE.Initrd = i.OSIE.Initrd 271 | } 272 | 273 | return n, nil 274 | } 275 | 276 | // transform returns data.DHCP and data.Netboot from part a v1alpha1.Interface and *v1alpha1.HardwareMetadata. 277 | func transform(i v1alpha1.Interface, m *v1alpha1.HardwareMetadata) (*data.DHCP, *data.Netboot, error) { 278 | d, err := toDHCPData(i.DHCP) 279 | if err != nil { 280 | return nil, nil, fmt.Errorf("failed to convert hardware to DHCP data: %w", err) 281 | } 282 | d.Disabled = i.DisableDHCP 283 | 284 | // Facility is used in the default HookOS iPXE script so we get it from the hardware metadata, if set. 285 | facility := "" 286 | if m != nil { 287 | if m.Facility != nil { 288 | facility = m.Facility.FacilityCode 289 | } 290 | } 291 | 292 | n, err := toNetbootData(i.Netboot, facility) 293 | if err != nil { 294 | return nil, nil, fmt.Errorf("failed to convert hardware to netboot data: %w", err) 295 | } 296 | 297 | return d, n, nil 298 | } 299 | -------------------------------------------------------------------------------- /internal/backend/noop/noop.go: -------------------------------------------------------------------------------- 1 | package noop 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | 8 | "github.com/tinkerbell/smee/internal/dhcp/data" 9 | ) 10 | 11 | var errAlways = errors.New("noop backend always returns an error") 12 | 13 | type Backend struct{} 14 | 15 | func (n Backend) GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { 16 | return nil, nil, errAlways 17 | } 18 | 19 | func (n Backend) GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) { 20 | return nil, nil, errAlways 21 | } 22 | -------------------------------------------------------------------------------- /internal/backend/noop/noop_test.go: -------------------------------------------------------------------------------- 1 | package noop 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | ) 8 | 9 | func TestBackend(t *testing.T) { 10 | b := Backend{} 11 | ctx := context.Background() 12 | _, _, err := b.GetByMac(ctx, nil) 13 | if err == nil { 14 | t.Error("expected error") 15 | } 16 | if !errors.Is(err, errAlways) { 17 | t.Error("expected errAlways") 18 | } 19 | _, _, err = b.GetByIP(ctx, nil) 20 | if err == nil { 21 | t.Error("expected error") 22 | } 23 | if !errors.Is(err, errAlways) { 24 | t.Error("expected errAlways") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/dhcp/data/data.go: -------------------------------------------------------------------------------- 1 | // Package data is an interface between DHCP backend implementations and the DHCP server. 2 | package data 3 | 4 | import ( 5 | "net" 6 | "net/netip" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/insomniacslk/dhcp/dhcpv4" 11 | "go.opentelemetry.io/otel/attribute" 12 | ) 13 | 14 | // Packet holds the data that is passed to a DHCP handler. 15 | type Packet struct { 16 | // Peer is the address of the client that sent the DHCP message. 17 | Peer net.Addr 18 | // Pkt is the DHCP message. 19 | Pkt *dhcpv4.DHCPv4 20 | // Md is the metadata that was passed to the DHCP server. 21 | Md *Metadata 22 | } 23 | 24 | // Metadata holds metadata about the DHCP packet that was received. 25 | type Metadata struct { 26 | // IfName is the name of the interface that the DHCP message was received on. 27 | IfName string 28 | // IfIndex is the index of the interface that the DHCP message was received on. 29 | IfIndex int 30 | } 31 | 32 | // DHCP holds the DHCP headers and options to be set in a DHCP handler response. 33 | // This is the API between a DHCP handler and a backend. 34 | type DHCP struct { 35 | MACAddress net.HardwareAddr // chaddr DHCP header. 36 | IPAddress netip.Addr // yiaddr DHCP header. 37 | SubnetMask net.IPMask // DHCP option 1. 38 | DefaultGateway netip.Addr // DHCP option 3. 39 | NameServers []net.IP // DHCP option 6. 40 | Hostname string // DHCP option 12. 41 | DomainName string // DHCP option 15. 42 | BroadcastAddress netip.Addr // DHCP option 28. 43 | NTPServers []net.IP // DHCP option 42. 44 | VLANID string // DHCP option 43.116. 45 | LeaseTime uint32 // DHCP option 51. 46 | Arch string // DHCP option 93. 47 | DomainSearch []string // DHCP option 119. 48 | Disabled bool // If true, no DHCP response should be sent. 49 | } 50 | 51 | // Netboot holds info used in netbooting a client. 52 | type Netboot struct { 53 | AllowNetboot bool // If true, the client will be provided netboot options in the DHCP offer/ack. 54 | IPXEScriptURL *url.URL // Overrides a default value that is passed into DHCP on startup. 55 | IPXEScript string // Overrides a default value that is passed into DHCP on startup. 56 | Console string 57 | Facility string 58 | OSIE OSIE 59 | } 60 | 61 | // OSIE or OS Installation Environment is the data about where the OSIE parts are located. 62 | type OSIE struct { 63 | // BaseURL is the URL where the OSIE parts are located. 64 | BaseURL *url.URL 65 | // Kernel is the name of the kernel file. 66 | Kernel string 67 | // Initrd is the name of the initrd file. 68 | Initrd string 69 | } 70 | 71 | // EncodeToAttributes returns a slice of opentelemetry attributes that can be used to set span.SetAttributes. 72 | func (d *DHCP) EncodeToAttributes() []attribute.KeyValue { 73 | var ns []string 74 | for _, e := range d.NameServers { 75 | ns = append(ns, e.String()) 76 | } 77 | 78 | var ntp []string 79 | for _, e := range d.NTPServers { 80 | ntp = append(ntp, e.String()) 81 | } 82 | 83 | var ip string 84 | if d.IPAddress.Compare(netip.Addr{}) != 0 { 85 | ip = d.IPAddress.String() 86 | } 87 | 88 | var sm string 89 | if d.SubnetMask != nil { 90 | sm = net.IP(d.SubnetMask).String() 91 | } 92 | 93 | var dfg string 94 | if d.DefaultGateway.Compare(netip.Addr{}) != 0 { 95 | dfg = d.DefaultGateway.String() 96 | } 97 | 98 | var ba string 99 | if d.BroadcastAddress.Compare(netip.Addr{}) != 0 { 100 | ba = d.BroadcastAddress.String() 101 | } 102 | 103 | return []attribute.KeyValue{ 104 | attribute.String("DHCP.MACAddress", d.MACAddress.String()), 105 | attribute.String("DHCP.IPAddress", ip), 106 | attribute.String("DHCP.SubnetMask", sm), 107 | attribute.String("DHCP.DefaultGateway", dfg), 108 | attribute.String("DHCP.NameServers", strings.Join(ns, ",")), 109 | attribute.String("DHCP.Hostname", d.Hostname), 110 | attribute.String("DHCP.DomainName", d.DomainName), 111 | attribute.String("DHCP.BroadcastAddress", ba), 112 | attribute.String("DHCP.NTPServers", strings.Join(ntp, ",")), 113 | attribute.Int64("DHCP.LeaseTime", int64(d.LeaseTime)), 114 | attribute.String("DHCP.DomainSearch", strings.Join(d.DomainSearch, ",")), 115 | } 116 | } 117 | 118 | // EncodeToAttributes returns a slice of opentelemetry attributes that can be used to set span.SetAttributes. 119 | func (n *Netboot) EncodeToAttributes() []attribute.KeyValue { 120 | var s string 121 | if n.IPXEScriptURL != nil { 122 | s = n.IPXEScriptURL.String() 123 | } 124 | return []attribute.KeyValue{ 125 | attribute.Bool("Netboot.AllowNetboot", n.AllowNetboot), 126 | attribute.String("Netboot.IPXEScriptURL", s), 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /internal/dhcp/data/data_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "net" 5 | "net/netip" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "go.opentelemetry.io/otel/attribute" 11 | ) 12 | 13 | func TestDHCPEncodeToAttributes(t *testing.T) { 14 | tests := map[string]struct { 15 | dhcp *DHCP 16 | want []attribute.KeyValue 17 | }{ 18 | "successful encode of zero value DHCP struct": { 19 | dhcp: &DHCP{}, 20 | want: []attribute.KeyValue{ 21 | attribute.String("DHCP.MACAddress", ""), 22 | attribute.String("DHCP.IPAddress", ""), 23 | attribute.String("DHCP.Hostname", ""), 24 | attribute.String("DHCP.SubnetMask", ""), 25 | attribute.String("DHCP.DefaultGateway", ""), 26 | attribute.String("DHCP.NameServers", ""), 27 | attribute.String("DHCP.DomainName", ""), 28 | attribute.String("DHCP.BroadcastAddress", ""), 29 | attribute.String("DHCP.NTPServers", ""), 30 | attribute.Int64("DHCP.LeaseTime", 0), 31 | attribute.String("DHCP.DomainSearch", ""), 32 | }, 33 | }, 34 | "successful encode of populated DHCP struct": { 35 | dhcp: &DHCP{ 36 | MACAddress: []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, 37 | IPAddress: netip.MustParseAddr("192.168.2.150"), 38 | SubnetMask: []byte{255, 255, 255, 0}, 39 | DefaultGateway: netip.MustParseAddr("192.168.2.1"), 40 | NameServers: []net.IP{{1, 1, 1, 1}, {8, 8, 8, 8}}, 41 | Hostname: "test", 42 | DomainName: "example.com", 43 | BroadcastAddress: netip.MustParseAddr("192.168.2.255"), 44 | NTPServers: []net.IP{{132, 163, 96, 2}}, 45 | LeaseTime: 86400, 46 | DomainSearch: []string{"example.com", "example.org"}, 47 | }, 48 | want: []attribute.KeyValue{ 49 | attribute.String("DHCP.MACAddress", "00:01:02:03:04:05"), 50 | attribute.String("DHCP.IPAddress", "192.168.2.150"), 51 | attribute.String("DHCP.Hostname", "test"), 52 | attribute.String("DHCP.SubnetMask", "255.255.255.0"), 53 | attribute.String("DHCP.DefaultGateway", "192.168.2.1"), 54 | attribute.String("DHCP.NameServers", "1.1.1.1,8.8.8.8"), 55 | attribute.String("DHCP.DomainName", "example.com"), 56 | attribute.String("DHCP.BroadcastAddress", "192.168.2.255"), 57 | attribute.String("DHCP.NTPServers", "132.163.96.2"), 58 | attribute.Int64("DHCP.LeaseTime", 86400), 59 | attribute.String("DHCP.DomainSearch", "example.com,example.org"), 60 | }, 61 | }, 62 | } 63 | for name, tt := range tests { 64 | t.Run(name, func(t *testing.T) { 65 | want := attribute.NewSet(tt.want...) 66 | got := attribute.NewSet(tt.dhcp.EncodeToAttributes()...) 67 | enc := attribute.DefaultEncoder() 68 | if diff := cmp.Diff(got.Encoded(enc), want.Encoded(enc)); diff != "" { 69 | t.Fatal(diff) 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestNetbootEncodeToAttributes(t *testing.T) { 76 | tests := map[string]struct { 77 | netboot *Netboot 78 | want []attribute.KeyValue 79 | }{ 80 | "successful encode of zero value Netboot struct": { 81 | netboot: &Netboot{}, 82 | want: []attribute.KeyValue{ 83 | attribute.Bool("Netboot.AllowNetboot", false), 84 | attribute.String("Netboot.IPXEScriptURL", ""), 85 | }, 86 | }, 87 | "successful encode of populated Netboot struct": { 88 | netboot: &Netboot{ 89 | AllowNetboot: true, 90 | IPXEScriptURL: &url.URL{Scheme: "http", Host: "example.com"}, 91 | }, 92 | want: []attribute.KeyValue{ 93 | attribute.Bool("Netboot.AllowNetboot", true), 94 | attribute.String("Netboot.IPXEScriptURL", "http://example.com"), 95 | }, 96 | }, 97 | } 98 | for name, tt := range tests { 99 | t.Run(name, func(t *testing.T) { 100 | want := attribute.NewSet(tt.want...) 101 | got := attribute.NewSet(tt.netboot.EncodeToAttributes()...) 102 | enc := attribute.DefaultEncoder() 103 | if diff := cmp.Diff(got.Encoded(enc), want.Encoded(enc)); diff != "" { 104 | t.Fatal(diff) 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /internal/dhcp/handler/handler.go: -------------------------------------------------------------------------------- 1 | // Package handler holds the interface that backends implement, handlers take in, and the top level dhcp package passes to handlers. 2 | package handler 3 | 4 | import ( 5 | "context" 6 | "net" 7 | 8 | "github.com/tinkerbell/smee/internal/dhcp/data" 9 | ) 10 | 11 | // BackendReader is the interface for getting data from a backend. 12 | // 13 | // Backends implement this interface to provide DHCP and Netboot data to the handlers. 14 | type BackendReader interface { 15 | // Read data (from a backend) based on a mac address 16 | // and return DHCP headers and options, including netboot info. 17 | GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) 18 | GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) 19 | } 20 | -------------------------------------------------------------------------------- /internal/dhcp/handler/reservation/handler.go: -------------------------------------------------------------------------------- 1 | package reservation 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | 9 | "github.com/go-logr/logr" 10 | "github.com/insomniacslk/dhcp/dhcpv4" 11 | "github.com/tinkerbell/smee/internal/dhcp" 12 | "github.com/tinkerbell/smee/internal/dhcp/data" 13 | oteldhcp "github.com/tinkerbell/smee/internal/dhcp/otel" 14 | "go.opentelemetry.io/otel" 15 | "go.opentelemetry.io/otel/attribute" 16 | "go.opentelemetry.io/otel/codes" 17 | "go.opentelemetry.io/otel/trace" 18 | "golang.org/x/net/ipv4" 19 | ) 20 | 21 | const tracerName = "github.com/tinkerbell/smee" 22 | 23 | // setDefaults will update the Handler struct to have default values so as 24 | // to avoid panic for nil pointers and such. 25 | func (h *Handler) setDefaults() { 26 | if h.Backend == nil { 27 | h.Backend = noop{} 28 | } 29 | if h.Log.GetSink() == nil { 30 | h.Log = logr.Discard() 31 | } 32 | } 33 | 34 | // Handle responds to DHCP messages with DHCP server options. 35 | func (h *Handler) Handle(ctx context.Context, conn *ipv4.PacketConn, p data.Packet) { 36 | h.setDefaults() 37 | if p.Pkt == nil { 38 | h.Log.Error(errors.New("incoming packet is nil"), "not able to respond when the incoming packet is nil") 39 | return 40 | } 41 | upeer, ok := p.Peer.(*net.UDPAddr) 42 | if !ok { 43 | h.Log.Error(errors.New("peer is not a UDP connection"), "not able to respond when the peer is not a UDP connection") 44 | return 45 | } 46 | if upeer == nil { 47 | h.Log.Error(errors.New("peer is nil"), "not able to respond when the peer is nil") 48 | return 49 | } 50 | if conn == nil { 51 | h.Log.Error(errors.New("connection is nil"), "not able to respond when the connection is nil") 52 | return 53 | } 54 | 55 | var ifName string 56 | if p.Md != nil { 57 | ifName = p.Md.IfName 58 | } 59 | log := h.Log.WithValues("mac", p.Pkt.ClientHWAddr.String(), "xid", p.Pkt.TransactionID.String(), "interface", ifName) 60 | tracer := otel.Tracer(tracerName) 61 | var span trace.Span 62 | ctx, span = tracer.Start( 63 | ctx, 64 | fmt.Sprintf("DHCP Packet Received: %v", p.Pkt.MessageType().String()), 65 | trace.WithAttributes(h.encodeToAttributes(p.Pkt, "request")...), 66 | trace.WithAttributes(attribute.String("DHCP.peer", p.Peer.String())), 67 | trace.WithAttributes(attribute.String("DHCP.server.ifname", ifName)), 68 | ) 69 | 70 | defer span.End() 71 | 72 | var reply *dhcpv4.DHCPv4 73 | switch mt := p.Pkt.MessageType(); mt { 74 | case dhcpv4.MessageTypeDiscover: 75 | d, n, err := h.readBackend(ctx, p.Pkt.ClientHWAddr) 76 | if err != nil { 77 | if hardwareNotFound(err) { 78 | span.SetStatus(codes.Ok, "no reservation found") 79 | return 80 | } 81 | log.Info("error reading from backend", "error", err) 82 | span.SetStatus(codes.Error, err.Error()) 83 | 84 | return 85 | } 86 | if d.Disabled { 87 | log.Info("DHCP is disabled for this MAC address, no response sent", "type", p.Pkt.MessageType().String()) 88 | span.SetStatus(codes.Ok, "disabled DHCP response") 89 | 90 | return 91 | } 92 | log.Info("received DHCP packet", "type", p.Pkt.MessageType().String()) 93 | reply = h.updateMsg(ctx, p.Pkt, d, n, dhcpv4.MessageTypeOffer) 94 | log = log.WithValues("type", dhcpv4.MessageTypeOffer.String()) 95 | case dhcpv4.MessageTypeRequest: 96 | d, n, err := h.readBackend(ctx, p.Pkt.ClientHWAddr) 97 | if err != nil { 98 | if hardwareNotFound(err) { 99 | span.SetStatus(codes.Ok, "no reservation found") 100 | return 101 | } 102 | log.Info("error reading from backend", "error", err) 103 | span.SetStatus(codes.Error, err.Error()) 104 | 105 | return 106 | } 107 | if d.Disabled { 108 | log.Info("DHCP is disabled for this MAC address, no response sent", "type", p.Pkt.MessageType().String()) 109 | span.SetStatus(codes.Ok, "disabled DHCP response") 110 | 111 | return 112 | } 113 | log.Info("received DHCP packet", "type", p.Pkt.MessageType().String()) 114 | reply = h.updateMsg(ctx, p.Pkt, d, n, dhcpv4.MessageTypeAck) 115 | log = log.WithValues("type", dhcpv4.MessageTypeAck.String()) 116 | case dhcpv4.MessageTypeRelease: 117 | // Since the design of this DHCP server is that all IP addresses are 118 | // Host reservations, when a client releases an address, the server 119 | // doesn't have anything to do. This case is included for clarity of this 120 | // design decision. 121 | log.Info("received DHCP release packet, no response required, all IPs are host reservations", "type", p.Pkt.MessageType().String()) 122 | span.SetStatus(codes.Ok, "received release, no response required") 123 | 124 | return 125 | default: 126 | log.Info("received unknown message type", "type", p.Pkt.MessageType().String()) 127 | span.SetStatus(codes.Error, "received unknown message type") 128 | 129 | return 130 | } 131 | 132 | if bf := reply.BootFileName; bf != "" { 133 | log = log.WithValues("bootFileName", bf) 134 | } 135 | if ns := reply.ServerIPAddr; ns != nil { 136 | log = log.WithValues("nextServer", ns.String()) 137 | } 138 | 139 | dst := replyDestination(p.Peer, p.Pkt.GatewayIPAddr) 140 | log = log.WithValues("ipAddress", reply.YourIPAddr.String(), "destination", dst.String()) 141 | cm := &ipv4.ControlMessage{} 142 | if p.Md != nil { 143 | cm.IfIndex = p.Md.IfIndex 144 | } 145 | 146 | if _, err := conn.WriteTo(reply.ToBytes(), cm, dst); err != nil { 147 | log.Error(err, "failed to send DHCP") 148 | span.SetStatus(codes.Error, err.Error()) 149 | 150 | return 151 | } 152 | 153 | log.Info("sent DHCP response") 154 | span.SetAttributes(h.encodeToAttributes(reply, "reply")...) 155 | span.SetStatus(codes.Ok, "sent DHCP response") 156 | } 157 | 158 | // replyDestination determines the destination address for the DHCP reply. 159 | // If the giaddr is set, then the reply should be sent to the giaddr. 160 | // Otherwise, the reply should be sent to the direct peer. 161 | // 162 | // From page 22 of https://www.ietf.org/rfc/rfc2131.txt: 163 | // "If the 'giaddr' field in a DHCP message from a client is non-zero, 164 | // the server sends any return messages to the 'DHCP server' port on 165 | // the BOOTP relay agent whose address appears in 'giaddr'.". 166 | func replyDestination(directPeer net.Addr, giaddr net.IP) net.Addr { 167 | if !giaddr.IsUnspecified() && giaddr != nil { 168 | return &net.UDPAddr{IP: giaddr, Port: dhcpv4.ServerPort} 169 | } 170 | 171 | return directPeer 172 | } 173 | 174 | // readBackend encapsulates the backend read and opentelemetry handling. 175 | func (h *Handler) readBackend(ctx context.Context, mac net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { 176 | h.setDefaults() 177 | 178 | tracer := otel.Tracer(tracerName) 179 | ctx, span := tracer.Start(ctx, "Hardware data get") 180 | defer span.End() 181 | 182 | d, n, err := h.Backend.GetByMac(ctx, mac) 183 | if err != nil { 184 | span.SetStatus(codes.Error, err.Error()) 185 | 186 | return nil, nil, err 187 | } 188 | 189 | span.SetAttributes(d.EncodeToAttributes()...) 190 | span.SetAttributes(n.EncodeToAttributes()...) 191 | span.SetStatus(codes.Ok, "done reading from backend") 192 | 193 | return d, n, nil 194 | } 195 | 196 | // updateMsg handles updating DHCP packets with the data from the backend. 197 | func (h *Handler) updateMsg(ctx context.Context, pkt *dhcpv4.DHCPv4, d *data.DHCP, n *data.Netboot, msgType dhcpv4.MessageType) *dhcpv4.DHCPv4 { 198 | h.setDefaults() 199 | mods := []dhcpv4.Modifier{ 200 | dhcpv4.WithMessageType(msgType), 201 | dhcpv4.WithGeneric(dhcpv4.OptionServerIdentifier, h.IPAddr.AsSlice()), 202 | dhcpv4.WithServerIP(h.IPAddr.AsSlice()), 203 | } 204 | mods = append(mods, h.setDHCPOpts(ctx, pkt, d)...) 205 | 206 | if h.Netboot.Enabled && dhcp.IsNetbootClient(pkt) == nil { 207 | mods = append(mods, h.setNetworkBootOpts(ctx, pkt, n)) 208 | } 209 | // We ignore the error here because: 210 | // 1. it's only non-nil if the generation of a transaction id (XID) fails. 211 | // 2. We always use the clients transaction id (XID) in responses. See dhcpv4.WithReply(). 212 | reply, _ := dhcpv4.NewReplyFromRequest(pkt, mods...) 213 | 214 | return reply 215 | } 216 | 217 | // encodeToAttributes takes a DHCP packet and returns opentelemetry key/value attributes. 218 | func (h *Handler) encodeToAttributes(d *dhcpv4.DHCPv4, namespace string) []attribute.KeyValue { 219 | h.setDefaults() 220 | a := &oteldhcp.Encoder{Log: h.Log} 221 | 222 | return a.Encode(d, namespace, oteldhcp.AllEncoders()...) 223 | } 224 | 225 | // hardwareNotFound returns true if the error is from a hardware record not being found. 226 | func hardwareNotFound(err error) bool { 227 | type hardwareNotFound interface { 228 | NotFound() bool 229 | } 230 | te, ok := err.(hardwareNotFound) 231 | return ok && te.NotFound() 232 | } 233 | -------------------------------------------------------------------------------- /internal/dhcp/handler/reservation/noop.go: -------------------------------------------------------------------------------- 1 | // Package noop is a backend handler that does nothing. 2 | package reservation 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "net" 8 | 9 | "github.com/tinkerbell/smee/internal/dhcp/data" 10 | ) 11 | 12 | // Handler is a noop backend. 13 | type noop struct{} 14 | 15 | // GetByMac returns an error. 16 | func (h noop) GetByMac(_ context.Context, _ net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { 17 | return nil, nil, errors.New("no backend specified, please specify a backend") 18 | } 19 | 20 | // GetByIP returns an error. 21 | func (h noop) GetByIP(_ context.Context, _ net.IP) (*data.DHCP, *data.Netboot, error) { 22 | return nil, nil, errors.New("no backend specified, please specify a backend") 23 | } 24 | -------------------------------------------------------------------------------- /internal/dhcp/handler/reservation/noop_test.go: -------------------------------------------------------------------------------- 1 | package reservation 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestNoop(t *testing.T) { 12 | want := errors.New("no backend specified, please specify a backend") 13 | _, _, got := noop{}.GetByMac(context.TODO(), nil) 14 | if diff := cmp.Diff(want.Error(), got.Error()); diff != "" { 15 | t.Fatal(diff) 16 | } 17 | _, _, got = noop{}.GetByIP(context.TODO(), nil) 18 | if diff := cmp.Diff(want.Error(), got.Error()); diff != "" { 19 | t.Fatal(diff) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/dhcp/handler/reservation/option.go: -------------------------------------------------------------------------------- 1 | package reservation 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/netip" 8 | "net/url" 9 | "strings" 10 | 11 | "github.com/insomniacslk/dhcp/dhcpv4" 12 | "github.com/tinkerbell/smee/internal/dhcp" 13 | "github.com/tinkerbell/smee/internal/dhcp/data" 14 | dhcpotel "github.com/tinkerbell/smee/internal/dhcp/otel" 15 | "github.com/tinkerbell/smee/internal/otel" 16 | ) 17 | 18 | // setDHCPOpts takes a client dhcp packet and data (typically from a backend) and creates a slice of DHCP packet modifiers. 19 | // m is the DHCP request from a client. d is the data to use to create the DHCP packet modifiers. 20 | // This is most likely the place where we would have any business logic for determining DHCP option setting. 21 | func (h *Handler) setDHCPOpts(_ context.Context, _ *dhcpv4.DHCPv4, d *data.DHCP) []dhcpv4.Modifier { 22 | mods := []dhcpv4.Modifier{ 23 | dhcpv4.WithLeaseTime(d.LeaseTime), 24 | dhcpv4.WithYourIP(d.IPAddress.AsSlice()), 25 | } 26 | if len(d.NameServers) > 0 { 27 | mods = append(mods, dhcpv4.WithDNS(d.NameServers...)) 28 | } 29 | if len(d.DomainSearch) > 0 { 30 | mods = append(mods, dhcpv4.WithDomainSearchList(d.DomainSearch...)) 31 | } 32 | if len(d.NTPServers) > 0 { 33 | mods = append(mods, dhcpv4.WithOption(dhcpv4.OptNTPServers(d.NTPServers...))) 34 | } 35 | if d.BroadcastAddress.Compare(netip.Addr{}) != 0 { 36 | mods = append(mods, dhcpv4.WithGeneric(dhcpv4.OptionBroadcastAddress, d.BroadcastAddress.AsSlice())) 37 | } 38 | if d.DomainName != "" { 39 | mods = append(mods, dhcpv4.WithGeneric(dhcpv4.OptionDomainName, []byte(d.DomainName))) 40 | } 41 | if d.Hostname != "" { 42 | mods = append(mods, dhcpv4.WithGeneric(dhcpv4.OptionHostName, []byte(d.Hostname))) 43 | } 44 | if len(d.SubnetMask) > 0 { 45 | mods = append(mods, dhcpv4.WithNetmask(d.SubnetMask)) 46 | } 47 | if d.DefaultGateway.Compare(netip.Addr{}) != 0 { 48 | mods = append(mods, dhcpv4.WithRouter(d.DefaultGateway.AsSlice())) 49 | } 50 | if h.SyslogAddr.Compare(netip.Addr{}) != 0 { 51 | mods = append(mods, dhcpv4.WithOption(dhcpv4.OptGeneric(dhcpv4.OptionLogServer, h.SyslogAddr.AsSlice()))) 52 | } 53 | 54 | return mods 55 | } 56 | 57 | // setNetworkBootOpts purpose is to sets 3 or 4 values. 2 DHCP headers, option 43 and optionally option (60). 58 | // These headers and options are returned as a dhcvp4.Modifier that can be used to modify a dhcp response. 59 | // github.com/insomniacslk/dhcp uses this method to simplify packet manipulation. 60 | // 61 | // DHCP Headers (https://datatracker.ietf.org/doc/html/rfc2131#section-2) 62 | // 'siaddr': IP address of next bootstrap server. represented below as `.ServerIPAddr`. 63 | // 'file': Client boot file name. represented below as `.BootFileName`. 64 | // 65 | // DHCP option 66 | // option 60: Class Identifier. https://www.rfc-editor.org/rfc/rfc2132.html#section-9.13 67 | // option 60 is set if the client's option 60 (Class Identifier) starts with HTTPClient. 68 | func (h *Handler) setNetworkBootOpts(ctx context.Context, m *dhcpv4.DHCPv4, n *data.Netboot) dhcpv4.Modifier { 69 | // m is a received DHCPv4 packet. 70 | // d is the reply packet we are building. 71 | withNetboot := func(d *dhcpv4.DHCPv4) { 72 | // if the client sends opt 60 with HTTPClient then we need to respond with opt 60 73 | // This is outside of the n.AllowNetboot check because we will be sending "/netboot-not-allowed" regardless. 74 | if val := m.Options.Get(dhcpv4.OptionClassIdentifier); val != nil { 75 | if strings.HasPrefix(string(val), dhcp.HTTPClient.String()) { 76 | d.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionClassIdentifier, []byte(dhcp.HTTPClient))) 77 | } 78 | } 79 | d.BootFileName = "/netboot-not-allowed" 80 | d.ServerIPAddr = net.IPv4(0, 0, 0, 0) 81 | if n.AllowNetboot { 82 | i := dhcp.NewInfo(m) 83 | if i.IPXEBinary == "" { 84 | return 85 | } 86 | var ipxeScript *url.URL 87 | // If the global IPXEScriptURL is set, use that. 88 | if h.Netboot.IPXEScriptURL != nil { 89 | ipxeScript = h.Netboot.IPXEScriptURL(m) 90 | } 91 | // If the IPXE script URL is set on the hardware record, use that. 92 | if n.IPXEScriptURL != nil { 93 | ipxeScript = n.IPXEScriptURL 94 | } 95 | d.BootFileName, d.ServerIPAddr = h.bootfileAndNextServer(ctx, m, h.Netboot.UserClass, h.Netboot.IPXEBinServerTFTP, h.Netboot.IPXEBinServerHTTP, ipxeScript) 96 | pxe := dhcpv4.Options{ // FYI, these are suboptions of option43. ref: https://datatracker.ietf.org/doc/html/rfc2132#section-8.4 97 | // PXE Boot Server Discovery Control - bypass, just boot from filename. 98 | 6: []byte{8}, 99 | 69: dhcpotel.TraceparentFromContext(ctx), 100 | } 101 | d.UpdateOption(dhcpv4.OptGeneric(dhcpv4.OptionVendorSpecificInformation, i.AddRPIOpt43(pxe))) 102 | } 103 | } 104 | 105 | return withNetboot 106 | } 107 | 108 | // bootfileAndNextServer returns the bootfile (string) and next server (net.IP). 109 | // 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. 110 | // It also helps us avoid having to validate a string in multiple ways. 111 | func (h *Handler) bootfileAndNextServer(ctx context.Context, pkt *dhcpv4.DHCPv4, customUC dhcp.UserClass, tftp netip.AddrPort, ipxe, iscript *url.URL) (string, net.IP) { 112 | var nextServer net.IP 113 | var bootfile string 114 | i := dhcp.NewInfo(pkt) 115 | if tp := otel.TraceparentStringFromContext(ctx); h.OTELEnabled && tp != "" { 116 | i.IPXEBinary = fmt.Sprintf("%s-%v", i.IPXEBinary, tp) 117 | } 118 | nextServer = i.NextServer(ipxe, tftp) 119 | bootfile = i.Bootfile(customUC, iscript, ipxe, tftp) 120 | 121 | return bootfile, nextServer 122 | } 123 | -------------------------------------------------------------------------------- /internal/dhcp/handler/reservation/reservation.go: -------------------------------------------------------------------------------- 1 | // Package reservation is the handler for responding to DHCPv4 messages with only host reservations. 2 | package reservation 3 | 4 | import ( 5 | "net/netip" 6 | "net/url" 7 | 8 | "github.com/go-logr/logr" 9 | "github.com/insomniacslk/dhcp/dhcpv4" 10 | "github.com/tinkerbell/smee/internal/dhcp" 11 | "github.com/tinkerbell/smee/internal/dhcp/handler" 12 | ) 13 | 14 | // Handler holds the configuration details for the running the DHCP server. 15 | type Handler struct { 16 | // Backend is the backend to use for getting DHCP data. 17 | Backend handler.BackendReader 18 | 19 | // IPAddr is the IP address to use in DHCP responses. 20 | // Option 54 and the sname DHCP header. 21 | // This could be a load balancer IP address or an ingress IP address or a local IP address. 22 | IPAddr netip.Addr 23 | 24 | // Log is used to log messages. 25 | // `logr.Discard()` can be used if no logging is desired. 26 | Log logr.Logger 27 | 28 | // Netboot configuration 29 | Netboot Netboot 30 | 31 | // OTELEnabled is used to determine if netboot options include otel naming. 32 | // When true, the netboot filename will be appended with otel information. 33 | // For example, the filename will be "snp.efi-00-23b1e307bb35484f535a1f772c06910e-d887dc3912240434-01". 34 | // -00--- 35 | OTELEnabled bool 36 | 37 | // SyslogAddr is the address to send syslog messages to. DHCP Option 7. 38 | SyslogAddr netip.Addr 39 | } 40 | 41 | // Netboot holds the netboot configuration details used in running a DHCP server. 42 | type Netboot struct { 43 | // iPXE binary server IP:Port serving via TFTP. 44 | IPXEBinServerTFTP netip.AddrPort 45 | 46 | // IPXEBinServerHTTP is the URL to the IPXE binary server serving via HTTP(s). 47 | IPXEBinServerHTTP *url.URL 48 | 49 | // IPXEScriptURL is the URL to the IPXE script to use. 50 | IPXEScriptURL func(*dhcpv4.DHCPv4) *url.URL 51 | 52 | // Enabled is whether to enable sending netboot DHCP options. 53 | Enabled bool 54 | 55 | // UserClass (for network booting) allows a custom DHCP option 77 to be used to break out of an iPXE loop. 56 | UserClass dhcp.UserClass 57 | } 58 | -------------------------------------------------------------------------------- /internal/dhcp/server/dhcp.go: -------------------------------------------------------------------------------- 1 | // Package dhcp providers UDP listening and serving functionality. 2 | package server 3 | 4 | import ( 5 | "context" 6 | "net" 7 | 8 | "github.com/go-logr/logr" 9 | "github.com/insomniacslk/dhcp/dhcpv4" 10 | "github.com/insomniacslk/dhcp/dhcpv4/server4" 11 | "github.com/tinkerbell/smee/internal/dhcp/data" 12 | "golang.org/x/net/ipv4" 13 | ) 14 | 15 | // Handler is a type that defines the handler function to be called every time a 16 | // valid DHCPv4 message is received 17 | // type Handler func(ctx context.Context, conn net.PacketConn, d data.Packet). 18 | type Handler interface { 19 | Handle(ctx context.Context, conn *ipv4.PacketConn, d data.Packet) 20 | } 21 | 22 | // DHCP represents a DHCPv4 server object. 23 | type DHCP struct { 24 | Conn net.PacketConn 25 | Handlers []Handler 26 | Logger logr.Logger 27 | } 28 | 29 | // Serve serves requests. 30 | func (s *DHCP) Serve(ctx context.Context) error { 31 | go func() { 32 | <-ctx.Done() 33 | _ = s.Close() 34 | }() 35 | s.Logger.Info("Server listening on", "addr", s.Conn.LocalAddr()) 36 | 37 | nConn := ipv4.NewPacketConn(s.Conn) 38 | if err := nConn.SetControlMessage(ipv4.FlagInterface, true); err != nil { 39 | s.Logger.Info("error setting control message", "err", err) 40 | return err 41 | } 42 | 43 | defer func() { 44 | _ = nConn.Close() 45 | }() 46 | for { 47 | // Max UDP packet size is 65535. Max DHCPv4 packet size is 576. An ethernet frame is 1500 bytes. 48 | // We use 4096 as a reasonable buffer size. dhcpv4.FromBytes will handle the rest. 49 | rbuf := make([]byte, 4096) 50 | n, cm, peer, err := nConn.ReadFrom(rbuf) 51 | if err != nil { 52 | select { 53 | case <-ctx.Done(): 54 | return nil 55 | default: 56 | } 57 | s.Logger.Info("error reading from packet conn", "err", err) 58 | return err 59 | } 60 | 61 | m, err := dhcpv4.FromBytes(rbuf[:n]) 62 | if err != nil { 63 | s.Logger.Info("error parsing DHCPv4 request", "err", err) 64 | continue 65 | } 66 | 67 | upeer, ok := peer.(*net.UDPAddr) 68 | if !ok { 69 | s.Logger.Info("not a UDP connection? Peer is", "peer", peer) 70 | continue 71 | } 72 | // Set peer to broadcast if the client did not have an IP. 73 | if upeer.IP == nil || upeer.IP.To4().Equal(net.IPv4zero) { 74 | upeer = &net.UDPAddr{ 75 | IP: net.IPv4bcast, 76 | Port: upeer.Port, 77 | } 78 | } 79 | 80 | var ifName string 81 | if n, err := net.InterfaceByIndex(cm.IfIndex); err == nil { 82 | ifName = n.Name 83 | } 84 | 85 | for _, handler := range s.Handlers { 86 | go handler.Handle(ctx, nConn, data.Packet{Peer: upeer, Pkt: m, Md: &data.Metadata{IfName: ifName, IfIndex: cm.IfIndex}}) 87 | } 88 | } 89 | } 90 | 91 | // Close sends a termination request to the server, and closes the UDP listener. 92 | func (s *DHCP) Close() error { 93 | return s.Conn.Close() 94 | } 95 | 96 | // NewServer initializes and returns a new Server object. 97 | func NewServer(ifname string, addr *net.UDPAddr, handler ...Handler) (*DHCP, error) { 98 | s := &DHCP{ 99 | Handlers: handler, 100 | Logger: logr.Discard(), 101 | } 102 | 103 | if s.Conn == nil { 104 | var err error 105 | conn, err := server4.NewIPv4UDPConn(ifname, addr) 106 | if err != nil { 107 | return nil, err 108 | } 109 | s.Conn = conn 110 | } 111 | return s, nil 112 | } 113 | -------------------------------------------------------------------------------- /internal/dhcp/server/dhcp_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/netip" 7 | "testing" 8 | 9 | "github.com/go-logr/logr" 10 | "github.com/insomniacslk/dhcp/dhcpv4" 11 | "github.com/insomniacslk/dhcp/dhcpv4/nclient4" 12 | "github.com/tinkerbell/smee/internal/dhcp/data" 13 | "golang.org/x/net/ipv4" 14 | "golang.org/x/net/nettest" 15 | ) 16 | 17 | type mock struct { 18 | Log logr.Logger 19 | ServerIP net.IP 20 | LeaseTime uint32 21 | YourIP net.IP 22 | NameServers []net.IP 23 | SubnetMask net.IPMask 24 | Router net.IP 25 | } 26 | 27 | func (m *mock) Handle(_ context.Context, conn *ipv4.PacketConn, d data.Packet) { 28 | if m.Log.GetSink() == nil { 29 | m.Log = logr.Discard() 30 | } 31 | 32 | mods := m.setOpts() 33 | switch mt := d.Pkt.MessageType(); mt { 34 | case dhcpv4.MessageTypeDiscover: 35 | mods = append(mods, dhcpv4.WithMessageType(dhcpv4.MessageTypeOffer)) 36 | case dhcpv4.MessageTypeRequest: 37 | mods = append(mods, dhcpv4.WithMessageType(dhcpv4.MessageTypeAck)) 38 | case dhcpv4.MessageTypeRelease: 39 | mods = append(mods, dhcpv4.WithMessageType(dhcpv4.MessageTypeAck)) 40 | default: 41 | m.Log.Info("unsupported message type", "type", mt.String()) 42 | return 43 | } 44 | reply, err := dhcpv4.NewReplyFromRequest(d.Pkt, mods...) 45 | if err != nil { 46 | m.Log.Error(err, "error creating reply") 47 | return 48 | } 49 | cm := &ipv4.ControlMessage{IfIndex: d.Md.IfIndex} 50 | if _, err := conn.WriteTo(reply.ToBytes(), cm, d.Peer); err != nil { 51 | m.Log.Error(err, "failed to send reply") 52 | return 53 | } 54 | m.Log.Info("sent reply") 55 | } 56 | 57 | func (m *mock) setOpts() []dhcpv4.Modifier { 58 | mods := []dhcpv4.Modifier{ 59 | dhcpv4.WithGeneric(dhcpv4.OptionServerIdentifier, m.ServerIP), 60 | dhcpv4.WithServerIP(m.ServerIP), 61 | dhcpv4.WithLeaseTime(m.LeaseTime), 62 | dhcpv4.WithYourIP(m.YourIP), 63 | dhcpv4.WithDNS(m.NameServers...), 64 | dhcpv4.WithNetmask(m.SubnetMask), 65 | dhcpv4.WithRouter(m.Router), 66 | } 67 | 68 | return mods 69 | } 70 | 71 | func dhcp(ctx context.Context) (*dhcpv4.DHCPv4, error) { 72 | rifs, err := nettest.RoutedInterface("ip", net.FlagUp|net.FlagBroadcast) 73 | if err != nil { 74 | return nil, err 75 | } 76 | c, err := nclient4.New(rifs.Name, 77 | nclient4.WithServerAddr(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 7676}), 78 | nclient4.WithUnicast(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 7677}), 79 | ) 80 | if err != nil { 81 | return nil, err 82 | } 83 | defer c.Close() 84 | 85 | return c.DiscoverOffer(ctx) 86 | } 87 | 88 | func TestServe(t *testing.T) { 89 | tests := map[string]struct { 90 | h Handler 91 | addr netip.AddrPort 92 | }{ 93 | "success": {addr: netip.MustParseAddrPort("127.0.0.1:7676"), h: &mock{}}, 94 | } 95 | for name, tt := range tests { 96 | t.Run(name, func(t *testing.T) { 97 | s, err := NewServer("lo", net.UDPAddrFromAddrPort(tt.addr), tt.h) 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | ctx, done := context.WithCancel(context.Background()) 102 | defer done() 103 | 104 | go s.Serve(ctx) 105 | 106 | // make client calls 107 | d, err := dhcp(ctx) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | t.Log(d) 112 | 113 | done() 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/ipxe/http/http.go: -------------------------------------------------------------------------------- 1 | // package bhttp is the http server for smee. 2 | package http 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "runtime" 11 | "time" 12 | 13 | "github.com/go-logr/logr" 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 16 | ) 17 | 18 | // Config is the configuration for the http server. 19 | type Config struct { 20 | GitRev string 21 | StartTime time.Time 22 | Logger logr.Logger 23 | TrustedProxies []string 24 | } 25 | 26 | // HandlerMapping is a map of routes to http.HandlerFuncs. 27 | type HandlerMapping map[string]http.HandlerFunc 28 | 29 | // ServeHTTP sets up all the HTTP routes using a stdlib mux and starts the http 30 | // server, which will block. App functionality is instrumented in Prometheus and OpenTelemetry. 31 | func (s *Config) ServeHTTP(ctx context.Context, addr string, handlers HandlerMapping) error { 32 | mux := http.NewServeMux() 33 | for pattern, handler := range handlers { 34 | mux.Handle(otelFuncWrapper(pattern, handler)) 35 | } 36 | 37 | mux.Handle("/metrics", promhttp.Handler()) 38 | mux.HandleFunc("/healthcheck", s.serveHealthchecker(s.GitRev, s.StartTime)) 39 | 40 | // wrap the mux with an OpenTelemetry interceptor 41 | otelHandler := otelhttp.NewHandler(mux, "smee-http") 42 | 43 | // add X-Forwarded-For support if trusted proxies are configured 44 | var xffHandler http.Handler 45 | if len(s.TrustedProxies) > 0 { 46 | xffmw, err := newXFF(xffOptions{ 47 | AllowedSubnets: s.TrustedProxies, 48 | }) 49 | if err != nil { 50 | s.Logger.Error(err, "failed to create new xff object") 51 | panic(fmt.Errorf("failed to create new xff object: %v", err)) 52 | } 53 | 54 | xffHandler = xffmw.Handler(&loggingMiddleware{ 55 | handler: otelHandler, 56 | log: s.Logger, 57 | }) 58 | } else { 59 | xffHandler = &loggingMiddleware{ 60 | handler: otelHandler, 61 | log: s.Logger, 62 | } 63 | } 64 | 65 | server := http.Server{ 66 | Addr: addr, 67 | Handler: xffHandler, 68 | 69 | // Mitigate Slowloris attacks. 30 seconds is based on Apache's recommended 20-40 70 | // recommendation. Smee doesn't really have many headers so 20s should be plenty of time. 71 | // https://en.wikipedia.org/wiki/Slowloris_(computer_security) 72 | ReadHeaderTimeout: 20 * time.Second, 73 | } 74 | 75 | go func() { 76 | <-ctx.Done() 77 | s.Logger.Info("shutting down http server") 78 | _ = server.Shutdown(ctx) 79 | }() 80 | if err := server.ListenAndServe(); err != nil { 81 | if errors.Is(err, http.ErrServerClosed) { 82 | return nil 83 | } 84 | s.Logger.Error(err, "listen and serve http") 85 | return err 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (s *Config) serveHealthchecker(rev string, start time.Time) http.HandlerFunc { 92 | return func(w http.ResponseWriter, _ *http.Request) { 93 | w.Header().Set("Content-Type", "application/json") 94 | res := struct { 95 | GitRev string `json:"git_rev"` 96 | Uptime float64 `json:"uptime"` 97 | Goroutines int `json:"goroutines"` 98 | }{ 99 | GitRev: rev, 100 | Uptime: time.Since(start).Seconds(), 101 | Goroutines: runtime.NumGoroutine(), 102 | } 103 | if err := json.NewEncoder(w).Encode(&res); err != nil { 104 | w.WriteHeader(http.StatusInternalServerError) 105 | s.Logger.Error(err, "marshaling healthcheck json") 106 | } 107 | } 108 | } 109 | 110 | // otelFuncWrapper takes a route and an http handler function, wraps the function 111 | // with otelhttp, and returns the route again and http.Handler all set for mux.Handle(). 112 | func otelFuncWrapper(route string, h func(w http.ResponseWriter, req *http.Request)) (string, http.Handler) { 113 | return route, otelhttp.WithRouteTag(route, http.HandlerFunc(h)) 114 | } 115 | -------------------------------------------------------------------------------- /internal/ipxe/http/middleware.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-logr/logr" 10 | ) 11 | 12 | type loggingMiddleware struct { 13 | handler http.Handler 14 | log logr.Logger 15 | } 16 | 17 | // ServeHTTP implements http.Handler and add logging before and after the request. 18 | func (h *loggingMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) { 19 | var ( 20 | start = time.Now() 21 | method = req.Method 22 | uri = req.RequestURI 23 | client = clientIP(req.RemoteAddr) 24 | ) 25 | 26 | log := uri != "/metrics" 27 | 28 | res := &responseWriter{ResponseWriter: w} 29 | h.handler.ServeHTTP(res, req) // process the request 30 | 31 | // The "X-Global-Logging" header allows all registered HTTP handlers to disable this global logging 32 | // by setting the header to any non empty string. This is useful for handlers that handle partial content of 33 | // larger file. The ISO handler, for example. 34 | r := res.Header().Get("X-Global-Logging") 35 | 36 | if log && r == "" { 37 | h.log.Info("response", "method", method, "uri", uri, "client", client, "duration", time.Since(start), "status", res.statusCode) 38 | } 39 | } 40 | 41 | type responseWriter struct { 42 | http.ResponseWriter 43 | statusCode int 44 | } 45 | 46 | func (w *responseWriter) Write(b []byte) (int, error) { 47 | if w.statusCode == 0 { 48 | w.statusCode = 200 49 | } 50 | n, err := w.ResponseWriter.Write(b) 51 | if err != nil { 52 | return 0, fmt.Errorf("failed writing response: %w", err) 53 | } 54 | 55 | return n, nil 56 | } 57 | 58 | func (w *responseWriter) WriteHeader(code int) { 59 | if w.statusCode == 0 { 60 | w.statusCode = code 61 | } 62 | w.ResponseWriter.WriteHeader(code) 63 | } 64 | 65 | func clientIP(str string) string { 66 | host, _, err := net.SplitHostPort(str) 67 | if err != nil { 68 | return "?" 69 | } 70 | 71 | return host 72 | } 73 | -------------------------------------------------------------------------------- /internal/ipxe/http/xff.go: -------------------------------------------------------------------------------- 1 | /* 2 | https://github.com/sebest/xff 3 | Copyright (c) 2015 Sebastien Estienne (sebastien.estienne@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | package http 25 | 26 | import ( 27 | "net" 28 | "net/http" 29 | "strings" 30 | ) 31 | 32 | // xffOptions is a configuration container to setup the XFF middleware. 33 | type xffOptions struct { 34 | // AllowedSubnets is a list of Subnets from which we will accept the 35 | // X-Forwarded-For header. 36 | // If this list is empty we will accept every Subnets (default). 37 | AllowedSubnets []string 38 | // Debugging flag adds additional output to debug server side XFF issues. 39 | Debug bool 40 | } 41 | 42 | // xff http handler. 43 | type xff struct { 44 | // Set to true if all IPs or Subnets are allowed. 45 | allowAll bool 46 | // List of IP subnets that are allowed. 47 | allowedMasks []net.IPNet 48 | } 49 | 50 | // New creates a new XFF handler with the provided options. 51 | func newXFF(options xffOptions) (*xff, error) { 52 | allowedMasks, err := toMasks(options.AllowedSubnets) 53 | if err != nil { 54 | return nil, err 55 | } 56 | xff := &xff{ 57 | allowAll: len(options.AllowedSubnets) == 0, 58 | allowedMasks: allowedMasks, 59 | } 60 | 61 | return xff, nil 62 | } 63 | 64 | // Handler updates RemoteAdd from X-Fowarded-For Headers. 65 | func (xff *xff) Handler(h http.Handler) http.Handler { 66 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 67 | r.RemoteAddr = getRemoteAddrIfAllowed(r, xff.allowed) 68 | h.ServeHTTP(w, r) 69 | }) 70 | } 71 | 72 | // getRemoteAddrIfAllowed parses the given request, resolves the X-Forwarded-For header 73 | // and returns the resolved remote address if allowed. 74 | func getRemoteAddrIfAllowed(r *http.Request, allowed func(sip string) bool) string { 75 | if xffh := r.Header.Get("X-Forwarded-For"); xffh != "" { 76 | if sip, sport, err := net.SplitHostPort(r.RemoteAddr); err == nil && sip != "" { 77 | if allowed(sip) { 78 | if xip := parse(xffh, allowed); xip != "" { 79 | return net.JoinHostPort(xip, sport) 80 | } 81 | } 82 | } 83 | } 84 | return r.RemoteAddr 85 | } 86 | 87 | // parse parses the value of the X-Forwarded-For Header and returns the IP address. 88 | func parse(ipList string, allowed func(string) bool) string { 89 | ips := strings.Split(ipList, ",") 90 | if len(ips) == 0 { 91 | return "" 92 | } 93 | 94 | // simple case of only 1 proxy 95 | if len(ips) == 1 { 96 | ip := strings.TrimSpace(ips[0]) 97 | if net.ParseIP(ip) != nil { 98 | return ip 99 | } 100 | return "" 101 | } 102 | 103 | // multiple proxies 104 | // common form of X-F-F is: client, proxy1, proxy2, ... proxyN-1 105 | // so we verify backwards and return the first unallowed/untrusted proxy 106 | lastIP := "" 107 | for i := len(ips) - 1; i >= 0; i-- { 108 | ip := strings.TrimSpace(ips[i]) 109 | if net.ParseIP(ip) == nil { 110 | break 111 | } 112 | lastIP = ip 113 | if !allowed(ip) { 114 | break 115 | } 116 | } 117 | return lastIP 118 | } 119 | 120 | // converts a list of subnets' string to a list of net.IPNet. 121 | func toMasks(ips []string) (masks []net.IPNet, err error) { 122 | for _, cidr := range ips { 123 | var network *net.IPNet 124 | _, network, err = net.ParseCIDR(cidr) 125 | if err != nil { 126 | return 127 | } 128 | masks = append(masks, *network) 129 | } 130 | return 131 | } 132 | 133 | // checks that the IP is allowed. 134 | func (xff *xff) allowed(sip string) bool { 135 | if xff.allowAll { 136 | return true 137 | } else if ip := net.ParseIP(sip); ip != nil && ipInMasks(ip, xff.allowedMasks) { 138 | return true 139 | } 140 | return false 141 | } 142 | 143 | // checks if a net.IP is in a list of net.IPNet. 144 | func ipInMasks(ip net.IP, masks []net.IPNet) bool { 145 | for _, mask := range masks { 146 | if mask.Contains(ip) { 147 | return true 148 | } 149 | } 150 | return false 151 | } 152 | -------------------------------------------------------------------------------- /internal/ipxe/http/xff_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | https://github.com/sebest/xff 3 | Copyright (c) 2015 Sebastien Estienne (sebastien.estienne@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | package http 25 | 26 | import ( 27 | "net" 28 | "testing" 29 | 30 | "github.com/stretchr/testify/assert" 31 | ) 32 | 33 | func TestParse_none(t *testing.T) { 34 | res := parse("", nil) 35 | assert.Equal(t, "", res) 36 | } 37 | 38 | func allowAll(string) bool { return true } 39 | 40 | func TestParse_localhost(t *testing.T) { 41 | res := parse("127.0.0.1", allowAll) 42 | assert.Equal(t, "127.0.0.1", res) 43 | } 44 | 45 | func TestParse_invalid(t *testing.T) { 46 | res := parse("invalid", allowAll) 47 | assert.Equal(t, "", res) 48 | } 49 | 50 | func TestParse_invalid_sioux(t *testing.T) { 51 | res := parse("123#1#2#3", allowAll) 52 | assert.Equal(t, "", res) 53 | } 54 | 55 | func TestParse_invalid_private_lookalike(t *testing.T) { 56 | res := parse("102.3.2.1", allowAll) 57 | assert.Equal(t, "102.3.2.1", res) 58 | } 59 | 60 | func TestParse_valid(t *testing.T) { 61 | res := parse("68.45.152.220", allowAll) 62 | assert.Equal(t, "68.45.152.220", res) 63 | } 64 | 65 | func TestParse_multi_first(t *testing.T) { 66 | res := parse("12.13.14.15, 68.45.152.220", allowAll) 67 | assert.Equal(t, "12.13.14.15", res) 68 | } 69 | 70 | func TestParse_multi_with_invalid(t *testing.T) { 71 | res := parse("invalid, 190.57.149.90", allowAll) 72 | assert.Equal(t, "190.57.149.90", res) 73 | } 74 | 75 | func TestParse_multi_with_invalid2(t *testing.T) { 76 | res := parse("190.57.149.90, invalid", allowAll) 77 | assert.Equal(t, "", res) 78 | } 79 | 80 | func TestParse_multi_with_invalid_sioux(t *testing.T) { 81 | res := parse("190.57.149.90, 123#1#2#3", allowAll) 82 | assert.Equal(t, "", res) 83 | } 84 | 85 | func TestParse_ipv6_with_port(t *testing.T) { 86 | res := parse("2604:2000:71a9:bf00:f178:a500:9a2d:670d", allowAll) 87 | assert.Equal(t, "2604:2000:71a9:bf00:f178:a500:9a2d:670d", res) 88 | } 89 | 90 | func TestToMasks_empty(t *testing.T) { 91 | ips := []string{} 92 | masks, err := toMasks(ips) 93 | assert.Empty(t, masks) 94 | assert.Nil(t, err) 95 | } 96 | 97 | func TestToMasks(t *testing.T) { 98 | ips := []string{"127.0.0.1/32", "10.0.0.0/8"} 99 | masks, err := toMasks(ips) 100 | _, ipnet1, _ := net.ParseCIDR("127.0.0.1/32") 101 | _, ipnet2, _ := net.ParseCIDR("10.0.0.0/8") 102 | assert.Equal(t, []net.IPNet{*ipnet1, *ipnet2}, masks) 103 | assert.Nil(t, err) 104 | } 105 | 106 | func TestToMasks_error(t *testing.T) { 107 | ips := []string{"error"} 108 | masks, err := toMasks(ips) 109 | assert.Empty(t, masks) 110 | assert.Equal(t, &net.ParseError{Type: "CIDR address", Text: "error"}, err) 111 | } 112 | 113 | func TestAllowed_all(t *testing.T) { 114 | m, _ := newXFF(xffOptions{ 115 | AllowedSubnets: []string{}, 116 | }) 117 | assert.True(t, m.allowed("127.0.0.1")) 118 | } 119 | 120 | func TestAllowed_yes(t *testing.T) { 121 | m, _ := newXFF(xffOptions{ 122 | AllowedSubnets: []string{"127.0.0.0/16"}, 123 | }) 124 | assert.True(t, m.allowed("127.0.0.1")) 125 | 126 | m, _ = newXFF(xffOptions{ 127 | AllowedSubnets: []string{"127.0.0.1/32"}, 128 | }) 129 | assert.True(t, m.allowed("127.0.0.1")) 130 | } 131 | 132 | func TestAllowed_no(t *testing.T) { 133 | m, _ := newXFF(xffOptions{ 134 | AllowedSubnets: []string{"127.0.0.0/16"}, 135 | }) 136 | assert.False(t, m.allowed("127.1.0.1")) 137 | 138 | m, _ = newXFF(xffOptions{ 139 | AllowedSubnets: []string{"127.0.0.1/32"}, 140 | }) 141 | assert.False(t, m.allowed("127.0.0.2")) 142 | } 143 | 144 | func TestParseUnallowedMidway(t *testing.T) { 145 | m, _ := newXFF(xffOptions{ 146 | AllowedSubnets: []string{"127.0.0.0/16"}, 147 | }) 148 | res := parse("1.1.1.1, 8.8.8.8, 127.0.0.1, 127.0.0.2", m.allowed) 149 | assert.Equal(t, "8.8.8.8", res) 150 | } 151 | 152 | func TestParseMany(t *testing.T) { 153 | m, _ := newXFF(xffOptions{ 154 | AllowedSubnets: []string{"127.0.0.0/16"}, 155 | }) 156 | res := parse("1.1.1.1, 127.0.0.1, 127.0.0.2, 127.0.0.3", m.allowed) 157 | assert.Equal(t, "1.1.1.1", res) 158 | } 159 | -------------------------------------------------------------------------------- /internal/ipxe/script/auto.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "bytes" 5 | "text/template" 6 | ) 7 | 8 | func GenerateTemplate(d any, script string) (string, error) { 9 | t := template.New("auto.ipxe") 10 | t, err := t.Parse(script) 11 | if err != nil { 12 | return "", err 13 | } 14 | buffer := new(bytes.Buffer) 15 | if err := t.Execute(buffer, d); err != nil { 16 | return "", err 17 | } 18 | 19 | return buffer.String(), nil 20 | } 21 | -------------------------------------------------------------------------------- /internal/ipxe/script/auto_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestGenerateTemplate(t *testing.T) { 10 | tests := map[string]struct { 11 | h Hook 12 | script string 13 | want string 14 | wantErr bool 15 | }{ 16 | "no vlan": { 17 | h: Hook{ 18 | Arch: "x86_64", 19 | TinkGRPCAuthority: "1.2.3.4:42113", 20 | TinkerbellTLS: false, 21 | WorkerID: "3c:ec:ef:4c:4f:54", 22 | SyslogHost: "1.2.3.4", 23 | DownloadURL: "http://location:8080/to/kernel/and/initrd", 24 | Facility: "onprem", 25 | ExtraKernelParams: []string{"tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0", "tinkerbell=packet"}, 26 | HWAddr: "3c:ec:ef:4c:4f:54", 27 | Retries: 10, 28 | RetryDelay: 3, 29 | }, 30 | script: HookScript, 31 | want: `#!ipxe 32 | 33 | echo Loading the Tinkerbell Hook iPXE script... 34 | 35 | set arch x86_64 36 | set download-url http://location:8080/to/kernel/and/initrd 37 | set kernel vmlinuz-${arch} 38 | set initrd initramfs-${arch} 39 | set retries:int32 10 40 | set retry_delay:int32 3 41 | 42 | set idx:int32 0 43 | :retry_kernel 44 | kernel ${download-url}/${kernel} tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0 tinkerbell=packet \ 45 | 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 \ 46 | 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 47 | 48 | :download_initrd 49 | set idx:int32 0 50 | :retry_initrd 51 | 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 52 | 53 | :boot 54 | set idx:int32 0 55 | :retry_boot 56 | boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot 57 | 58 | :kernel-error 59 | echo Failed to load kernel 60 | imgfree 61 | exit 62 | 63 | :initrd-error 64 | echo Failed to load initrd 65 | imgfree 66 | exit 67 | 68 | :boot-error 69 | echo Failed to boot 70 | imgfree 71 | exit 72 | `, 73 | }, 74 | "with vlan": { 75 | h: Hook{ 76 | Arch: "x86_64", 77 | TinkGRPCAuthority: "1.2.3.4:42113", 78 | TinkerbellTLS: false, 79 | WorkerID: "3c:ec:ef:4c:4f:54", 80 | SyslogHost: "1.2.3.4", 81 | DownloadURL: "http://location:8080/to/kernel/and/initrd", 82 | Facility: "onprem", 83 | ExtraKernelParams: []string{"tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0", "tinkerbell=packet"}, 84 | HWAddr: "3c:ec:ef:4c:4f:54", 85 | VLANID: "16", 86 | Retries: 10, 87 | RetryDelay: 3, 88 | }, 89 | script: HookScript, 90 | want: `#!ipxe 91 | 92 | echo Loading the Tinkerbell Hook iPXE script... 93 | 94 | set arch x86_64 95 | set download-url http://location:8080/to/kernel/and/initrd 96 | set kernel vmlinuz-${arch} 97 | set initrd initramfs-${arch} 98 | set retries:int32 10 99 | set retry_delay:int32 3 100 | 101 | set idx:int32 0 102 | :retry_kernel 103 | kernel ${download-url}/${kernel} vlan_id=16 tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0 tinkerbell=packet \ 104 | 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 \ 105 | 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 106 | 107 | :download_initrd 108 | set idx:int32 0 109 | :retry_initrd 110 | 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 111 | 112 | :boot 113 | set idx:int32 0 114 | :retry_boot 115 | boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot 116 | 117 | :kernel-error 118 | echo Failed to load kernel 119 | imgfree 120 | exit 121 | 122 | :initrd-error 123 | echo Failed to load initrd 124 | imgfree 125 | exit 126 | 127 | :boot-error 128 | echo Failed to boot 129 | imgfree 130 | exit 131 | `, 132 | }, 133 | "parse error": { 134 | h: Hook{}, 135 | script: "bad {{ }", 136 | wantErr: true, 137 | }, 138 | "execute error": { 139 | h: Hook{}, 140 | script: "{{ .A }}", 141 | wantErr: true, 142 | }, 143 | } 144 | for name, tt := range tests { 145 | t.Run(name, func(t *testing.T) { 146 | got, err := GenerateTemplate(tt.h, tt.script) 147 | if (err != nil) != tt.wantErr { 148 | t.Errorf("Auto.autoDotIPXE() error = %v, wantErr %v", err, tt.wantErr) 149 | return 150 | } 151 | if diff := cmp.Diff(got, tt.want); diff != "" { 152 | t.Errorf("Auto.autoDotIPXE() mismatch (-want +got):\n%s", diff) 153 | } 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /internal/ipxe/script/custom.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import "net/url" 4 | 5 | // CustomScript is the template for the custom script. 6 | // It will either chain to a URL or execute an iPXE script. 7 | var CustomScript = `#!ipxe 8 | 9 | echo Loading custom Tinkerbell iPXE script... 10 | 11 | {{- if .Chain }} 12 | chain --autofree {{ .Chain }} 13 | {{- else }} 14 | {{ .Script }} 15 | {{- end }} 16 | ` 17 | 18 | // Custom holds either a URL to chain to or a script to execute. 19 | // There is no validation of the script. 20 | type Custom struct { 21 | Chain *url.URL 22 | Script string 23 | } 24 | -------------------------------------------------------------------------------- /internal/ipxe/script/hook.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | // HookScript is the default iPXE script for loading Hook. 4 | var HookScript = `#!ipxe 5 | 6 | echo Loading the Tinkerbell Hook iPXE script... 7 | {{- if .TraceID }} 8 | echo Debug TraceID: {{ .TraceID }} 9 | {{- end }} 10 | 11 | set arch {{ .Arch }} 12 | set download-url {{ .DownloadURL }} 13 | set kernel {{ if .Kernel }}{{ .Kernel }}{{ else }}vmlinuz-${arch}{{ end }} 14 | set initrd {{ if .Initrd }}{{ .Initrd }}{{ else }}initramfs-${arch}{{ end }} 15 | set retries:int32 {{ .Retries }} 16 | set retry_delay:int32 {{ .RetryDelay }} 17 | 18 | set idx:int32 0 19 | :retry_kernel 20 | kernel ${download-url}/${kernel} {{- if ne .VLANID "" }} vlan_id={{ .VLANID }} {{- end }} {{- range .ExtraKernelParams}} {{.}} {{- end}} \ 21 | facility={{ .Facility }} syslog_host={{ .SyslogHost }} grpc_authority={{ .TinkGRPCAuthority }} tinkerbell_tls={{ .TinkerbellTLS }} tinkerbell_insecure_tls={{ .TinkerbellInsecureTLS }} worker_id={{ .WorkerID }} hw_addr={{ .HWAddr }} \ 22 | 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 23 | 24 | :download_initrd 25 | set idx:int32 0 26 | :retry_initrd 27 | 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 28 | 29 | :boot 30 | set idx:int32 0 31 | :retry_boot 32 | boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot 33 | 34 | :kernel-error 35 | echo Failed to load kernel 36 | imgfree 37 | exit 38 | 39 | :initrd-error 40 | echo Failed to load initrd 41 | imgfree 42 | exit 43 | 44 | :boot-error 45 | echo Failed to boot 46 | imgfree 47 | exit 48 | ` 49 | 50 | // Hook holds the values used to generate the iPXE script that loads the Hook OS. 51 | type Hook struct { 52 | Arch string // example x86_64 53 | Console string // example ttyS1,115200 54 | DownloadURL string // example https://location:8080/to/kernel/and/initrd 55 | ExtraKernelParams []string // example tink_worker_image=quay.io/tinkerbell/tink-worker:v0.8.0 56 | Facility string 57 | HWAddr string // example 3c:ec:ef:4c:4f:54 58 | SyslogHost string 59 | TinkerbellTLS bool 60 | TinkerbellInsecureTLS bool 61 | TinkGRPCAuthority string // example 192.168.2.111:42113 62 | TraceID string 63 | VLANID string // string number between 1-4095 64 | WorkerID string // example 3c:ec:ef:4c:4f:54 or worker1 65 | Retries int // number of retries to attempt when fetching kernel and initrd files 66 | RetryDelay int // number of seconds to wait between retries 67 | Kernel string // name of the kernel file 68 | Initrd string // name of the initrd file 69 | } 70 | -------------------------------------------------------------------------------- /internal/ipxe/script/ipxe_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/tinkerbell/smee/internal/metric" 12 | "go.opentelemetry.io/otel/trace" 13 | ) 14 | 15 | func TestCustomScript(t *testing.T) { 16 | tests := map[string]struct { 17 | ipxeURL string 18 | ipxeScript string 19 | want string 20 | shouldErr bool 21 | }{ 22 | "got script": {want: "#!ipxe\n\necho Loading custom Tinkerbell iPXE script...\n#!ipxe\nautoboot\n", ipxeScript: "#!ipxe\nautoboot"}, 23 | "got url": {want: "#!ipxe\n\necho Loading custom Tinkerbell iPXE script...\nchain --autofree https://boot.netboot.xyz\n", ipxeURL: "https://boot.netboot.xyz"}, 24 | "invalid URL prefix": {want: "", ipxeURL: "invalid", shouldErr: true}, 25 | "invalid URL": {want: "", ipxeURL: "http://invalid.:123.com", shouldErr: true}, 26 | "no script or url": {want: "", shouldErr: true}, 27 | } 28 | for name, tt := range tests { 29 | t.Run(name, func(t *testing.T) { 30 | h := &Handler{} 31 | u, err := url.Parse(tt.ipxeURL) 32 | if err != nil && !tt.shouldErr { 33 | t.Fatal(err) 34 | } 35 | 36 | d := data{MACAddress: net.HardwareAddr{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, IPXEScript: tt.ipxeScript, IPXEScriptURL: u} 37 | got, err := h.customScript(d) 38 | if err != nil && !tt.shouldErr { 39 | t.Fatal(err) 40 | } 41 | if diff := cmp.Diff(tt.want, got); diff != "" { 42 | t.Fatal(diff) 43 | } 44 | }) 45 | } 46 | } 47 | 48 | func TestDefaultScript(t *testing.T) { 49 | one := `#!ipxe 50 | 51 | echo Loading the Tinkerbell Hook iPXE script... 52 | 53 | set arch x86_64 54 | set download-url http://127.1.1.1 55 | set kernel vmlinuz-${arch} 56 | set initrd initramfs-${arch} 57 | set retries:int32 10 58 | set retry_delay:int32 3 59 | 60 | set idx:int32 0 61 | :retry_kernel 62 | kernel ${download-url}/${kernel} vlan_id=1234 \ 63 | 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 \ 64 | 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 65 | 66 | :download_initrd 67 | set idx:int32 0 68 | :retry_initrd 69 | 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 70 | 71 | :boot 72 | set idx:int32 0 73 | :retry_boot 74 | boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot 75 | 76 | :kernel-error 77 | echo Failed to load kernel 78 | imgfree 79 | exit 80 | 81 | :initrd-error 82 | echo Failed to load initrd 83 | imgfree 84 | exit 85 | 86 | :boot-error 87 | echo Failed to boot 88 | imgfree 89 | exit 90 | ` 91 | tests := map[string]struct { 92 | want string 93 | }{ 94 | "success with defaults": {want: one}, 95 | } 96 | for name, tt := range tests { 97 | t.Run(name, func(t *testing.T) { 98 | h := &Handler{ 99 | OSIEURL: "http://127.1.1.1", 100 | IPXEScriptRetries: 10, 101 | IPXEScriptRetryDelay: 3, 102 | } 103 | d := data{MACAddress: net.HardwareAddr{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}, VLANID: "1234", Facility: "onprem", Arch: "x86_64"} 104 | sp := trace.SpanFromContext(context.Background()) 105 | got, err := h.defaultScript(sp, d) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | if diff := cmp.Diff(tt.want, got); diff != "" { 110 | t.Log(got) 111 | t.Fatal(diff) 112 | } 113 | }) 114 | } 115 | } 116 | 117 | func TestStaticScript(t *testing.T) { 118 | want := `#!ipxe 119 | 120 | echo Loading the static Tinkerbell iPXE script... 121 | 122 | set arch ${buildarch} 123 | # Tinkerbell only supports 64 bit archectures. 124 | # The build architecture does not necessarily represent the architecture of the machine on which iPXE is running. 125 | # https://ipxe.org/cfg/buildarch 126 | iseq ${arch} i386 && set arch x86_64 || 127 | iseq ${arch} arm32 && set arch aarch64 || 128 | iseq ${arch} arm64 && set arch aarch64 || 129 | set download-url http://127.0.0.1 130 | set retries:int32 0 131 | set retry_delay:int32 0 132 | 133 | set worker_id ${mac} 134 | set grpc_authority 127.0.0.1:42113 135 | set syslog_host 127.1.1.1 136 | set tinkerbell_tls false 137 | 138 | echo worker_id=${mac} 139 | echo grpc_authority=127.0.0.1:42113 140 | echo syslog_host=127.1.1.1 141 | echo tinkerbell_tls=false 142 | 143 | set idx:int32 0 144 | :retry_kernel 145 | kernel ${download-url}/vmlinuz-${arch} \ 146 | syslog_host=${syslog_host} grpc_authority=${grpc_authority} tinkerbell_tls=${tinkerbell_tls} worker_id=${worker_id} hw_addr=${mac} \ 147 | console=tty1 console=tty2 console=ttyAMA0,115200 console=ttyAMA1,115200 console=ttyS0,115200 console=ttyS1,115200 k=v k2=v2 \ 148 | 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 149 | 150 | :download_initrd 151 | set idx:int32 0 152 | :retry_initrd 153 | 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 154 | 155 | :boot 156 | set idx:int32 0 157 | :retry_boot 158 | boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot 159 | 160 | :kernel-error 161 | echo Failed to load kernel 162 | imgfree 163 | exit 164 | 165 | :initrd-error 166 | echo Failed to load initrd 167 | imgfree 168 | exit 169 | 170 | :boot-error 171 | echo Failed to boot 172 | imgfree 173 | exit 174 | ` 175 | metric.Init() 176 | h := &Handler{ 177 | OSIEURL: "http://127.0.0.1", 178 | ExtraKernelParams: []string{"k=v", "k2=v2"}, 179 | PublicSyslogFQDN: "127.1.1.1", 180 | TinkServerTLS: false, 181 | TinkServerGRPCAddr: "127.0.0.1:42113", 182 | StaticIPXEEnabled: true, 183 | } 184 | hf := h.HandlerFunc() 185 | writer := httptest.NewRecorder() 186 | req := httptest.NewRequest("GET", "/auto.ipxe", nil) 187 | hf(writer, req) 188 | if writer.Code != 200 { 189 | t.Errorf("expected status code 200, got %d", writer.Code) 190 | } 191 | if diff := cmp.Diff(writer.Body.String(), want); diff != "" { 192 | t.Fatalf("expected custom script, got %s", diff) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /internal/ipxe/script/static.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | // StaticScript is the iPXE script used when in the auto-proxy mode. 4 | // It is built to be generic enough for all hardware to use. 5 | var StaticScript = `#!ipxe 6 | 7 | echo Loading the static Tinkerbell iPXE script... 8 | 9 | set arch ${buildarch} 10 | # Tinkerbell only supports 64 bit archectures. 11 | # The build architecture does not necessarily represent the architecture of the machine on which iPXE is running. 12 | # https://ipxe.org/cfg/buildarch 13 | iseq ${arch} i386 && set arch x86_64 || 14 | iseq ${arch} arm32 && set arch aarch64 || 15 | iseq ${arch} arm64 && set arch aarch64 || 16 | set download-url {{ .DownloadURL }} 17 | set retries:int32 {{ .Retries }} 18 | set retry_delay:int32 {{ .RetryDelay }} 19 | 20 | set worker_id ${mac} 21 | set grpc_authority {{ .TinkGRPCAuthority }} 22 | set syslog_host {{ .SyslogHost }} 23 | set tinkerbell_tls {{ .TinkerbellTLS }} 24 | 25 | echo worker_id=${mac} 26 | echo grpc_authority={{ .TinkGRPCAuthority }} 27 | echo syslog_host={{ .SyslogHost }} 28 | echo tinkerbell_tls={{ .TinkerbellTLS }} 29 | 30 | set idx:int32 0 31 | :retry_kernel 32 | kernel ${download-url}/vmlinuz-${arch} \ 33 | syslog_host=${syslog_host} grpc_authority=${grpc_authority} tinkerbell_tls=${tinkerbell_tls} worker_id=${worker_id} hw_addr=${mac} \ 34 | console=tty1 console=tty2 console=ttyAMA0,115200 console=ttyAMA1,115200 console=ttyS0,115200 console=ttyS1,115200 {{- range .ExtraKernelParams}} {{.}} {{- end}} \ 35 | 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 36 | 37 | :download_initrd 38 | set idx:int32 0 39 | :retry_initrd 40 | 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 41 | 42 | :boot 43 | set idx:int32 0 44 | :retry_boot 45 | boot || iseq ${idx} ${retries} && goto boot-error || inc idx && echo retry in ${retry_delay} seconds ; sleep ${retry_delay} ; goto retry_boot 46 | 47 | :kernel-error 48 | echo Failed to load kernel 49 | imgfree 50 | exit 51 | 52 | :initrd-error 53 | echo Failed to load initrd 54 | imgfree 55 | exit 56 | 57 | :boot-error 58 | echo Failed to boot 59 | imgfree 60 | exit 61 | ` 62 | -------------------------------------------------------------------------------- /internal/iso/internal/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2009 The Go Authors. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /internal/iso/internal/acsii.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package internal 6 | 7 | // EqualFold is [strings.EqualFold], ASCII only. It reports whether s and t 8 | // are equal, ASCII-case-insensitively. 9 | func EqualFold(s, t string) bool { 10 | if len(s) != len(t) { 11 | return false 12 | } 13 | for i := 0; i < len(s); i++ { 14 | if lower(s[i]) != lower(t[i]) { 15 | return false 16 | } 17 | } 18 | return true 19 | } 20 | 21 | // lower returns the ASCII lowercase version of b. 22 | func lower(b byte) byte { 23 | if 'A' <= b && b <= 'Z' { 24 | return b + ('a' - 'A') 25 | } 26 | return b 27 | } 28 | 29 | // IsPrint returns whether s is ASCII and printable according to 30 | // https://tools.ietf.org/html/rfc20#section-4.2. 31 | func IsPrint(s string) bool { 32 | for i := 0; i < len(s); i++ { 33 | if s[i] < ' ' || s[i] > '~' { 34 | return false 35 | } 36 | } 37 | return true 38 | } 39 | -------------------------------------------------------------------------------- /internal/iso/internal/acsii_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package internal 6 | 7 | import "testing" 8 | 9 | func TestEqualFold(t *testing.T) { 10 | var tests = []struct { 11 | name string 12 | a, b string 13 | want bool 14 | }{ 15 | { 16 | name: "empty", 17 | want: true, 18 | }, 19 | { 20 | name: "simple match", 21 | a: "CHUNKED", 22 | b: "chunked", 23 | want: true, 24 | }, 25 | { 26 | name: "same string", 27 | a: "chunked", 28 | b: "chunked", 29 | want: true, 30 | }, 31 | { 32 | name: "Unicode Kelvin symbol", 33 | a: "chunKed", // This "K" is 'KELVIN SIGN' (\u212A) 34 | b: "chunked", 35 | want: false, 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | if got := EqualFold(tt.a, tt.b); got != tt.want { 41 | t.Errorf("AsciiEqualFold(%q,%q): got %v want %v", tt.a, tt.b, got, tt.want) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func TestIsPrint(t *testing.T) { 48 | var tests = []struct { 49 | name string 50 | in string 51 | want bool 52 | }{ 53 | { 54 | name: "empty", 55 | want: true, 56 | }, 57 | { 58 | name: "ASCII low", 59 | in: "This is a space: ' '", 60 | want: true, 61 | }, 62 | { 63 | name: "ASCII high", 64 | in: "This is a tilde: '~'", 65 | want: true, 66 | }, 67 | { 68 | name: "ASCII low non-print", 69 | in: "This is a unit separator: \x1F", 70 | want: false, 71 | }, 72 | { 73 | name: "Ascii high non-print", 74 | in: "This is a Delete: \x7F", 75 | want: false, 76 | }, 77 | { 78 | name: "Unicode letter", 79 | in: "Today it's 280K outside: it's freezing!", // This "K" is 'KELVIN SIGN' (\u212A) 80 | want: false, 81 | }, 82 | { 83 | name: "Unicode emoji", 84 | in: "Gophers like 🧀", 85 | want: false, 86 | }, 87 | } 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | if got := IsPrint(tt.in); got != tt.want { 91 | t.Errorf("IsASCIIPrint(%q): got %v want %v", tt.in, got, tt.want) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /internal/iso/internal/context.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "context" 4 | 5 | type patchCtxKeyType string 6 | 7 | const isoPatchCtxKey patchCtxKeyType = "iso-patch" 8 | 9 | func WithPatch(ctx context.Context, patch []byte) context.Context { 10 | return context.WithValue(ctx, isoPatchCtxKey, patch) 11 | } 12 | 13 | func GetPatch(ctx context.Context) []byte { 14 | patch, ok := ctx.Value(isoPatchCtxKey).([]byte) 15 | if !ok { 16 | return nil 17 | } 18 | return patch 19 | } 20 | -------------------------------------------------------------------------------- /internal/iso/ipam.go: -------------------------------------------------------------------------------- 1 | package iso 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/netip" 7 | "strings" 8 | 9 | "github.com/tinkerbell/smee/internal/dhcp/data" 10 | ) 11 | 12 | func parseIPAM(d *data.DHCP) string { 13 | if d == nil { 14 | return "" 15 | } 16 | // return format is ipam=:::::::: 17 | ipam := make([]string, 9) 18 | ipam[0] = func() string { 19 | m := d.MACAddress.String() 20 | 21 | return strings.ReplaceAll(m, ":", "-") 22 | }() 23 | ipam[1] = func() string { 24 | if d.VLANID != "" { 25 | return d.VLANID 26 | } 27 | return "" 28 | }() 29 | ipam[2] = func() string { 30 | if d.IPAddress.Compare(netip.Addr{}) != 0 { 31 | return d.IPAddress.String() 32 | } 33 | return "" 34 | }() 35 | ipam[3] = func() string { 36 | if d.SubnetMask != nil { 37 | return net.IP(d.SubnetMask).String() 38 | } 39 | return "" 40 | }() 41 | ipam[4] = func() string { 42 | if d.DefaultGateway.Compare(netip.Addr{}) != 0 { 43 | return d.DefaultGateway.String() 44 | } 45 | return "" 46 | }() 47 | ipam[5] = d.Hostname 48 | ipam[6] = func() string { 49 | var nameservers []string 50 | for _, e := range d.NameServers { 51 | nameservers = append(nameservers, e.String()) 52 | } 53 | if len(nameservers) > 0 { 54 | return strings.Join(nameservers, ",") 55 | } 56 | 57 | return "" 58 | }() 59 | ipam[7] = func() string { 60 | if len(d.DomainSearch) > 0 { 61 | return strings.Join(d.DomainSearch, ",") 62 | } 63 | 64 | return "" 65 | }() 66 | ipam[8] = func() string { 67 | var ntp []string 68 | for _, e := range d.NTPServers { 69 | ntp = append(ntp, e.String()) 70 | } 71 | if len(ntp) > 0 { 72 | return strings.Join(ntp, ",") 73 | } 74 | 75 | return "" 76 | }() 77 | 78 | return fmt.Sprintf("ipam=%s", strings.Join(ipam, ":")) 79 | } 80 | -------------------------------------------------------------------------------- /internal/iso/ipam_test.go: -------------------------------------------------------------------------------- 1 | package iso 2 | 3 | import ( 4 | "net" 5 | "net/netip" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/tinkerbell/smee/internal/dhcp/data" 10 | ) 11 | 12 | func TestParseIPAM(t *testing.T) { 13 | tests := map[string]struct { 14 | input *data.DHCP 15 | want string 16 | }{ 17 | "empty": {}, 18 | "only MAC": { 19 | input: &data.DHCP{MACAddress: net.HardwareAddr{0xde, 0xed, 0xbe, 0xef, 0xfe, 0xed}}, 20 | want: "ipam=de-ed-be-ef-fe-ed::::::::", 21 | }, 22 | "everything": { 23 | input: &data.DHCP{ 24 | MACAddress: net.HardwareAddr{0xde, 0xed, 0xbe, 0xef, 0xfe, 0xed}, 25 | IPAddress: netip.AddrFrom4([4]byte{127, 0, 0, 1}), 26 | SubnetMask: net.IPv4Mask(255, 255, 255, 0), 27 | DefaultGateway: netip.AddrFrom4([4]byte{127, 0, 0, 2}), 28 | NameServers: []net.IP{{1, 1, 1, 1}, {4, 4, 4, 4}}, 29 | Hostname: "myhost", 30 | NTPServers: []net.IP{{129, 6, 15, 28}, {129, 6, 15, 29}}, 31 | DomainSearch: []string{"example.com", "example.org"}, 32 | VLANID: "400", 33 | }, 34 | 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", 35 | }, 36 | } 37 | 38 | for name, tt := range tests { 39 | t.Run(name, func(t *testing.T) { 40 | got := parseIPAM(tt.input) 41 | if diff := cmp.Diff(tt.want, got); diff != "" { 42 | t.Fatalf("diff: %v", diff) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/iso/iso_test.go: -------------------------------------------------------------------------------- 1 | package iso 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | "os" 13 | "testing" 14 | 15 | diskfs "github.com/diskfs/go-diskfs" 16 | "github.com/diskfs/go-diskfs/disk" 17 | "github.com/diskfs/go-diskfs/filesystem" 18 | "github.com/diskfs/go-diskfs/filesystem/iso9660" 19 | "github.com/go-logr/logr" 20 | "github.com/google/go-cmp/cmp" 21 | "github.com/tinkerbell/smee/internal/dhcp/data" 22 | ) 23 | 24 | const magicString = `464vn90e7rbj08xbwdjejmdf4it17c5zfzjyfhthbh19eij201hjgit021bmpdb9ctrc87x2ymc8e7icu4ffi15x1hah9iyaiz38ckyap8hwx2vt5rm44ixv4hau8iw718q5yd019um5dt2xpqqa2rjtdypzr5v1gun8un110hhwp8cex7pqrh2ivh0ynpm4zkkwc8wcn367zyethzy7q8hzudyeyzx3cgmxqbkh825gcak7kxzjbgjajwizryv7ec1xm2h0hh7pz29qmvtgfjj1vphpgq1zcbiiehv52wrjy9yq473d9t1rvryy6929nk435hfx55du3ih05kn5tju3vijreru1p6knc988d4gfdz28eragvryq5x8aibe5trxd0t6t7jwxkde34v6pj1khmp50k6qqj3nzgcfzabtgqkmeqhdedbvwf3byfdma4nkv3rcxugaj2d0ru30pa2fqadjqrtjnv8bu52xzxv7irbhyvygygxu1nt5z4fh9w1vwbdcmagep26d298zknykf2e88kumt59ab7nq79d8amnhhvbexgh48e8qc61vq2e9qkihzt1twk1ijfgw70nwizai15iqyted2dt9gfmf2gg7amzufre79hwqkddc1cd935ywacnkrnak6r7xzcz7zbmq3kt04u2hg1iuupid8rt4nyrju51e6uejb2ruu36g9aibmz3hnmvazptu8x5tyxk820g2cdpxjdij766bt2n3djur7v623a2v44juyfgz80ekgfb9hkibpxh3zgknw8a34t4jifhf116x15cei9hwch0fye3xyq0acuym8uhitu5evc4rag3ui0fny3qg4kju7zkfyy8hwh537urd5uixkzwu5bdvafz4jmv7imypj543xg5em8jk8cgk7c4504xdd5e4e71ihaumt6u5u2t1w7um92fepzae8p0vq93wdrd1756npu1pziiur1payc7kmdwyxg3hj5n4phxbc29x0tcddamjrwt260b0w` 25 | 26 | func TestReqPathInvalid(t *testing.T) { 27 | tests := map[string]struct { 28 | isoURL string 29 | statusCode int 30 | }{ 31 | "invalid URL prefix": {isoURL: "invalid", statusCode: http.StatusNotFound}, 32 | "invalid URL": {isoURL: "http://invalid.:123/hook.iso", statusCode: http.StatusBadRequest}, 33 | "no script or url": {isoURL: "http://10.10.10.10:8080/aa:aa:aa:aa:aa:aa/invalid.iso", statusCode: http.StatusInternalServerError}, 34 | } 35 | for name, tt := range tests { 36 | u, _ := url.Parse(tt.isoURL) 37 | t.Run(name, func(t *testing.T) { 38 | h := &Handler{ 39 | parsedURL: u, 40 | } 41 | req := http.Request{ 42 | Method: http.MethodGet, 43 | URL: u, 44 | } 45 | 46 | got, err := h.RoundTrip(&req) 47 | got.Body.Close() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | if got.StatusCode != tt.statusCode { 52 | t.Fatalf("got response status code: %d, want status code: %d", got.StatusCode, tt.statusCode) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestCreateISO(t *testing.T) { 59 | t.Skip("Unskip this test to create a new ISO file") 60 | grubCfg := `set timeout=0 61 | set gfxpayload=text 62 | menuentry 'LinuxKit ISO Image' { 63 | linuxefi /kernel 464vn90e7rbj08xbwdjejmdf4it17c5zfzjyfhthbh19eij201hjgit021bmpdb9ctrc87x2ymc8e7icu4ffi15x1hah9iyaiz38ckyap8hwx2vt5rm44ixv4hau8iw718q5yd019um5dt2xpqqa2rjtdypzr5v1gun8un110hhwp8cex7pqrh2ivh0ynpm4zkkwc8wcn367zyethzy7q8hzudyeyzx3cgmxqbkh825gcak7kxzjbgjajwizryv7ec1xm2h0hh7pz29qmvtgfjj1vphpgq1zcbiiehv52wrjy9yq473d9t1rvryy6929nk435hfx55du3ih05kn5tju3vijreru1p6knc988d4gfdz28eragvryq5x8aibe5trxd0t6t7jwxkde34v6pj1khmp50k6qqj3nzgcfzabtgqkmeqhdedbvwf3byfdma4nkv3rcxugaj2d0ru30pa2fqadjqrtjnv8bu52xzxv7irbhyvygygxu1nt5z4fh9w1vwbdcmagep26d298zknykf2e88kumt59ab7nq79d8amnhhvbexgh48e8qc61vq2e9qkihzt1twk1ijfgw70nwizai15iqyted2dt9gfmf2gg7amzufre79hwqkddc1cd935ywacnkrnak6r7xzcz7zbmq3kt04u2hg1iuupid8rt4nyrju51e6uejb2ruu36g9aibmz3hnmvazptu8x5tyxk820g2cdpxjdij766bt2n3djur7v623a2v44juyfgz80ekgfb9hkibpxh3zgknw8a34t4jifhf116x15cei9hwch0fye3xyq0acuym8uhitu5evc4rag3ui0fny3qg4kju7zkfyy8hwh537urd5uixkzwu5bdvafz4jmv7imypj543xg5em8jk8cgk7c4504xdd5e4e71ihaumt6u5u2t1w7um92fepzae8p0vq93wdrd1756npu1pziiur1payc7kmdwyxg3hj5n4phxbc29x0tcddamjrwt260b0w text 64 | initrdefi /initrd.img 65 | } 66 | ` 67 | if err := os.Remove("testdata/output.iso"); err != nil && !os.IsNotExist(err) { 68 | t.Fatal(err) 69 | } 70 | var diskSize int64 = 51200 // 50Kb 71 | mydisk, err := diskfs.Create("./testdata/output.iso", diskSize, diskfs.SectorSizeDefault) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | defer mydisk.Close() 76 | 77 | // the following line is required for an ISO, which may have logical block sizes 78 | // only of 2048, 4096, 8192 79 | mydisk.LogicalBlocksize = 2048 80 | fspec := disk.FilesystemSpec{Partition: 0, FSType: filesystem.TypeISO9660, VolumeLabel: "label"} 81 | fs, err := mydisk.CreateFilesystem(fspec) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | if err := fs.Mkdir("EFI/BOOT"); err != nil { 86 | t.Fatal(err) 87 | } 88 | rw, err := fs.OpenFile("EFI/BOOT/grub.cfg", os.O_CREATE|os.O_RDWR) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | content := []byte(grubCfg) 93 | _, err = rw.Write(content) 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | iso, ok := fs.(*iso9660.FileSystem) 98 | if !ok { 99 | t.Fatal(fmt.Errorf("not an iso9660 filesystem")) 100 | } 101 | err = iso.Finalize(iso9660.FinalizeOptions{}) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | } 106 | 107 | func TestPatching(t *testing.T) { 108 | // create a small ISO file with the magic string 109 | // serve ISO with a http server 110 | // patch the ISO file 111 | // mount the ISO file and check if the magic string was patched 112 | 113 | // If anything changes here the space padding will be different. Be sure to update it accordingly. 114 | wantGrubCfg := `set timeout=0 115 | set gfxpayload=text 116 | menuentry 'LinuxKit ISO Image' { 117 | 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 118 | initrdefi /initrd.img 119 | }` 120 | // This expects that testdata/output.iso exists. Run the TestCreateISO test to create it. 121 | 122 | // serve it with a http server 123 | hs := httptest.NewServer(http.FileServer(http.Dir("./testdata"))) 124 | defer hs.Close() 125 | 126 | // patch the ISO file 127 | u := hs.URL + "/output.iso" 128 | parsedURL, err := url.Parse(u) 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | 133 | h := &Handler{ 134 | Logger: logr.Discard(), 135 | Backend: &mockBackend{}, 136 | SourceISO: u, 137 | ExtraKernelParams: []string{}, 138 | Syslog: "127.0.0.1:514", 139 | TinkServerTLS: false, 140 | TinkServerGRPCAddr: "127.0.0.1:42113", 141 | parsedURL: parsedURL, 142 | MagicString: magicString, 143 | } 144 | h.magicStrPadding = bytes.Repeat([]byte{' '}, len(h.MagicString)) 145 | // for debugging enable a logger 146 | // h.Logger = logr.FromSlogHandler(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})) 147 | 148 | hf, err := h.HandlerFunc() 149 | if err != nil { 150 | t.Fatal(err) 151 | } 152 | 153 | w := httptest.NewRecorder() 154 | hf.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/iso/de:ed:be:ef:fe:ed/output.iso", nil)) 155 | 156 | res := w.Result() 157 | defer res.Body.Close() 158 | if res.StatusCode != http.StatusOK { 159 | t.Fatalf("got status code: %d, want status code: %d", res.StatusCode, http.StatusOK) 160 | } 161 | 162 | isoContents, err := io.ReadAll(res.Body) 163 | if err != nil { 164 | t.Fatal(err) 165 | } 166 | 167 | idx := bytes.Index(isoContents, []byte(`set timeout=0`)) 168 | if idx == -1 { 169 | t.Fatalf("could not find the expected grub.cfg contents in the ISO") 170 | } 171 | contents := isoContents[idx : idx+len(wantGrubCfg)] 172 | 173 | if diff := cmp.Diff(wantGrubCfg, string(contents)); diff != "" { 174 | t.Fatalf("patched grub.cfg contents don't match expected: %v", diff) 175 | } 176 | } 177 | 178 | type mockBackend struct{} 179 | 180 | func (m *mockBackend) GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) { 181 | d := &data.DHCP{} 182 | n := &data.Netboot{ 183 | Facility: "test", 184 | } 185 | return d, n, nil 186 | } 187 | 188 | func (m *mockBackend) GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) { 189 | d := &data.DHCP{} 190 | n := &data.Netboot{ 191 | Facility: "test", 192 | } 193 | return d, n, nil 194 | } 195 | -------------------------------------------------------------------------------- /internal/iso/testdata/output.iso: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinkerbell/smee/5f31a4ab8025193a15b5071f1b778048836f65b3/internal/iso/testdata/output.iso -------------------------------------------------------------------------------- /internal/metric/metric.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/promauto" 6 | ) 7 | 8 | var ( 9 | DHCPTotal *prometheus.CounterVec 10 | 11 | DiscoverDuration prometheus.ObserverVec 12 | HardwareDiscovers *prometheus.CounterVec 13 | DiscoversInProgress *prometheus.GaugeVec 14 | 15 | JobDuration prometheus.ObserverVec 16 | JobsTotal *prometheus.CounterVec 17 | JobsInProgress *prometheus.GaugeVec 18 | ) 19 | 20 | func Init() { 21 | DHCPTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 22 | Name: "dhcp_total", 23 | Help: "Number of DHCP Requests handled.", 24 | }, []string{"op", "type", "giaddr"}) 25 | 26 | labelValues := []prometheus.Labels{ 27 | {"op": "recv", "type": "DHCPACK", "giaddr": "0.0.0.0"}, 28 | {"op": "recv", "type": "DHCPDECLINE", "giaddr": "0.0.0.0"}, 29 | {"op": "recv", "type": "DHCPDISCOVER", "giaddr": "0.0.0.0"}, 30 | {"op": "recv", "type": "DHCPINFORM", "giaddr": "0.0.0.0"}, 31 | {"op": "recv", "type": "DHCPNAK", "giaddr": "0.0.0.0"}, 32 | {"op": "recv", "type": "DHCPOFFER", "giaddr": "0.0.0.0"}, 33 | {"op": "recv", "type": "DHCPRELEASE", "giaddr": "0.0.0.0"}, 34 | {"op": "recv", "type": "DHCPREQUEST", "giaddr": "0.0.0.0"}, 35 | {"op": "send", "type": "DHCPOFFER", "giaddr": "0.0.0.0"}, 36 | } 37 | initCounterLabels(DHCPTotal, labelValues) 38 | 39 | labelValues = []prometheus.Labels{ 40 | {"from": "dhcp"}, 41 | {"from": "ip"}, 42 | } 43 | 44 | DiscoverDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 45 | Name: "discover_duration_seconds", 46 | Help: "Duration taken to get a response for a newly discovered request.", 47 | Buckets: prometheus.LinearBuckets(.01, .05, 10), 48 | }, []string{"from"}) 49 | HardwareDiscovers = promauto.NewCounterVec(prometheus.CounterOpts{ 50 | Name: "discover_total", 51 | Help: "Number of discover requests requested.", 52 | }, []string{"from"}) 53 | DiscoversInProgress = promauto.NewGaugeVec(prometheus.GaugeOpts{ 54 | Name: "discover_in_progress", 55 | Help: "Number of discover requests that have yet to receive a response.", 56 | }, []string{"from"}) 57 | 58 | initObserverLabels(DiscoverDuration, labelValues) 59 | initCounterLabels(HardwareDiscovers, labelValues) 60 | initGaugeLabels(DiscoversInProgress, labelValues) 61 | 62 | JobDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ 63 | Name: "jobs_duration_seconds", 64 | Help: "Duration taken for a job to complete.", 65 | Buckets: prometheus.LinearBuckets(.01, .05, 10), 66 | }, []string{"from", "op"}) 67 | JobsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ 68 | Name: "jobs_total", 69 | Help: "Number of jobs.", 70 | }, []string{"from", "op"}) 71 | JobsInProgress = promauto.NewGaugeVec(prometheus.GaugeOpts{ 72 | Name: "jobs_in_progress", 73 | Help: "Number of jobs waiting to complete.", 74 | }, []string{"from", "op"}) 75 | 76 | labelValues = []prometheus.Labels{ 77 | {"from": "dhcp", "op": "DHCPACK"}, 78 | {"from": "dhcp", "op": "DHCPDECLINE"}, 79 | {"from": "dhcp", "op": "DHCPDISCOVER"}, 80 | {"from": "dhcp", "op": "DHCPINFORM"}, 81 | {"from": "dhcp", "op": "DHCPNAK"}, 82 | {"from": "dhcp", "op": "DHCPOFFER"}, 83 | {"from": "dhcp", "op": "DHCPRELEASE"}, 84 | {"from": "dhcp", "op": "DHCPREQUEST"}, 85 | {"from": "http", "op": "file"}, 86 | {"from": "http", "op": "hardware-components"}, 87 | {"from": "http", "op": "phone-home"}, 88 | {"from": "http", "op": "problem"}, 89 | {"from": "http", "op": "event"}, 90 | {"from": "tftp", "op": "read"}, 91 | } 92 | 93 | initObserverLabels(JobDuration, labelValues) 94 | initCounterLabels(JobsTotal, labelValues) 95 | initGaugeLabels(JobsInProgress, labelValues) 96 | } 97 | 98 | func initCounterLabels(m *prometheus.CounterVec, l []prometheus.Labels) { 99 | for _, labels := range l { 100 | m.With(labels) 101 | } 102 | } 103 | 104 | func initGaugeLabels(m *prometheus.GaugeVec, l []prometheus.Labels) { 105 | for _, labels := range l { 106 | m.With(labels) 107 | } 108 | } 109 | 110 | func initObserverLabels(m prometheus.ObserverVec, l []prometheus.Labels) { 111 | for _, labels := range l { 112 | m.With(labels) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/otel/otel.go: -------------------------------------------------------------------------------- 1 | /* 2 | https://github.com/equinix-labs/otel-init-go 3 | Copyright [yyyy] [name of copyright owner] 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | package otel 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | "time" 24 | 25 | "github.com/go-logr/logr" 26 | "go.opentelemetry.io/otel" 27 | "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" 28 | "go.opentelemetry.io/otel/propagation" 29 | "go.opentelemetry.io/otel/sdk/resource" 30 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 31 | semconv "go.opentelemetry.io/otel/semconv/v1.4.0" 32 | "google.golang.org/grpc" 33 | "google.golang.org/grpc/credentials" 34 | ) 35 | 36 | // SimpleCarrier is an abstraction for handling traceparent propagation 37 | // that needs a type that implements the propagation.TextMapCarrier(). 38 | // This is the simplest possible implementation that is a little fragile 39 | // but since we're not doing anything else with it, it's fine for this. 40 | type SimpleCarrier map[string]string 41 | 42 | // Get implements the otel interface for propagation. 43 | func (otp SimpleCarrier) Get(key string) string { 44 | return otp[key] 45 | } 46 | 47 | // Set implements the otel interface for propagation. 48 | func (otp SimpleCarrier) Set(key, value string) { 49 | otp[key] = value 50 | } 51 | 52 | // Keys implements the otel interface for propagation. 53 | func (otp SimpleCarrier) Keys() []string { 54 | out := []string{} 55 | for k := range otp { 56 | out = append(out, k) 57 | } 58 | return out 59 | } 60 | 61 | // Clear implements the otel interface for propagation. 62 | func (otp SimpleCarrier) Clear() { 63 | for k := range otp { 64 | delete(otp, k) 65 | } 66 | } 67 | 68 | // TraceparentStringFromContext gets the current trace from the context and 69 | // returns a W3C traceparent string. Depends on global OTel TextMapPropagator. 70 | func TraceparentStringFromContext(ctx context.Context) string { 71 | carrier := SimpleCarrier{} 72 | prop := otel.GetTextMapPropagator() 73 | prop.Inject(ctx, carrier) 74 | return carrier.Get("traceparent") 75 | } 76 | 77 | // ContextWithEnvTraceparent is a helper that looks for the the TRACEPARENT 78 | // environment variable and if it's set, it grabs the traceparent and 79 | // adds it to the context it returns. When there is no envvar or it's 80 | // empty, the original context is returned unmodified. 81 | // Depends on global OTel TextMapPropagator. 82 | func ContextWithEnvTraceparent(ctx context.Context) context.Context { 83 | traceparent := os.Getenv("TRACEPARENT") 84 | if traceparent != "" { 85 | return ContextWithTraceparentString(ctx, traceparent) 86 | } 87 | return ctx 88 | } 89 | 90 | // ContextWithTraceparentString takes a W3C traceparent string, uses the otel 91 | // carrier code to get it into a context it returns ready to go. 92 | // Depends on global OTel TextMapPropagator. 93 | func ContextWithTraceparentString(ctx context.Context, traceparent string) context.Context { 94 | carrier := SimpleCarrier{} 95 | carrier.Set("traceparent", traceparent) 96 | prop := otel.GetTextMapPropagator() 97 | return prop.Extract(ctx, carrier) 98 | } 99 | 100 | // Config holds the typed values of configuration read from the environment. 101 | // It is public mainly to make testing easier and most users should never 102 | // use it directly. 103 | type Config struct { 104 | Servicename string `json:"service_name"` 105 | Endpoint string `json:"endpoint"` 106 | Insecure bool `json:"insecure"` 107 | Logger logr.Logger 108 | } 109 | 110 | // Init sets up the OpenTelemetry plumbing so it's ready to use. 111 | // It requires a context.Context and returns context and a func() that encapuslates clean shutdown. 112 | func Init(ctx context.Context, c Config) (context.Context, context.CancelFunc, error) { 113 | if c.Endpoint != "" { 114 | return c.initTracing(ctx) 115 | } 116 | 117 | // no configuration, nothing to do, the calling code is inert 118 | // config is available in the returned context (for test/debug) 119 | return ctx, func() {}, nil 120 | } 121 | 122 | func (c Config) initTracing(ctx context.Context) (context.Context, context.CancelFunc, error) { 123 | // set the service name that will show up in tracing UIs 124 | resAttrs := resource.WithAttributes(semconv.ServiceNameKey.String(c.Servicename)) 125 | res, err := resource.New(ctx, resAttrs) 126 | if err != nil { 127 | return ctx, nil, fmt.Errorf("failed to create OpenTelemetry service name resource: %w", err) 128 | } 129 | 130 | retryPolicy := `{ 131 | "methodConfig": [{ 132 | "retryPolicy": { 133 | "MaxAttempts": 1000, 134 | "InitialBackoff": ".01s", 135 | "MaxBackoff": ".01s", 136 | "BackoffMultiplier": 1.0, 137 | "RetryableStatusCodes": [ "UNAVAILABLE" ] 138 | } 139 | }] 140 | }` 141 | 142 | grpcOpts := []otlptracegrpc.Option{ 143 | otlptracegrpc.WithEndpoint(c.Endpoint), 144 | otlptracegrpc.WithDialOption(grpc.WithDefaultServiceConfig(retryPolicy)), 145 | otlptracegrpc.WithRetry(otlptracegrpc.RetryConfig{ 146 | Enabled: true, 147 | InitialInterval: time.Second * 5, 148 | MaxInterval: time.Second * 30, 149 | MaxElapsedTime: time.Minute * 5, 150 | }), 151 | } 152 | if c.Insecure { 153 | grpcOpts = append(grpcOpts, otlptracegrpc.WithInsecure()) 154 | } else { 155 | creds := credentials.NewClientTLSFromCert(nil, "") 156 | grpcOpts = append(grpcOpts, otlptracegrpc.WithTLSCredentials(creds)) 157 | } 158 | // TODO: add TLS client cert auth 159 | 160 | exporter, err := otlptracegrpc.New(context.Background(), grpcOpts...) 161 | if err != nil { 162 | return ctx, nil, fmt.Errorf("failed to configure OTLP exporter: %w", err) 163 | } 164 | 165 | // TODO: more configuration opportunities here 166 | bsp := sdktrace.NewBatchSpanProcessor(exporter) 167 | tracerProvider := sdktrace.NewTracerProvider( 168 | sdktrace.WithResource(res), 169 | sdktrace.WithSpanProcessor(bsp), 170 | ) 171 | 172 | // set global propagator to tracecontext (the default is no-op). 173 | prop := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) 174 | otel.SetTextMapPropagator(prop) 175 | 176 | // inject the tracer into the otel globals, start background goroutines 177 | otel.SetTracerProvider(tracerProvider) 178 | 179 | // logger 180 | otel.SetLogger(c.Logger) 181 | 182 | // set a custom error handler so that we can use our own logger 183 | otel.SetErrorHandler(c) 184 | 185 | // the public function will wrap this in its own shutdown function 186 | return ctx, func() { 187 | ctx1, done := context.WithTimeout(context.Background(), 5*time.Second) 188 | err = tracerProvider.Shutdown(ctx1) 189 | if err != nil { 190 | c.Logger.Info("shutdown of OpenTelemetry tracerProvider failed: %s", err) 191 | } 192 | done() 193 | 194 | ctx2, done := context.WithTimeout(context.Background(), 5*time.Second) 195 | err = exporter.Shutdown(ctx2) 196 | if err != nil { 197 | c.Logger.Info("shutdown of OpenTelemetry OTLP exporter failed: %s", err) 198 | } 199 | done() 200 | }, nil 201 | } 202 | 203 | func (c Config) Handle(err error) { 204 | if err != nil { 205 | c.Logger.Info("OpenTelemetry error", "err", err) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /internal/syslog/facility_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=facility -output=facility_string.go"; DO NOT EDIT. 2 | 3 | package syslog 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[kern-0] 12 | _ = x[user-1] 13 | _ = x[mail-2] 14 | _ = x[daemon-3] 15 | _ = x[auth-4] 16 | _ = x[syslog-5] 17 | _ = x[lpr-6] 18 | _ = x[news-7] 19 | _ = x[uucp-8] 20 | _ = x[clock-9] 21 | _ = x[authpriv-10] 22 | _ = x[ftp-11] 23 | _ = x[ntp-12] 24 | _ = x[audit-13] 25 | _ = x[alert-14] 26 | _ = x[cron-15] 27 | _ = x[local0-16] 28 | _ = x[local1-17] 29 | _ = x[local2-18] 30 | _ = x[local3-19] 31 | _ = x[local4-20] 32 | _ = x[local5-21] 33 | _ = x[local6-22] 34 | _ = x[local7-23] 35 | } 36 | 37 | const _facility_name = "kernusermaildaemonauthsysloglprnewsuucpclockauthprivftpntpauditalertcronlocal0local1local2local3local4local5local6local7" 38 | 39 | 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} 40 | 41 | func (i facility) String() string { 42 | if i >= facility(len(_facility_index)-1) { 43 | return "facility(" + strconv.FormatInt(int64(i), 10) + ")" 44 | } 45 | return _facility_name[_facility_index[i]:_facility_index[i+1]] 46 | } 47 | -------------------------------------------------------------------------------- /internal/syslog/message.go: -------------------------------------------------------------------------------- 1 | package syslog 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | //go:generate go run golang.org/x/tools/cmd/stringer@latest -type=facility -output=facility_string.go 12 | type facility byte 13 | 14 | const ( 15 | kern facility = iota 16 | user 17 | mail 18 | daemon 19 | auth 20 | syslog 21 | lpr 22 | news 23 | uucp 24 | clock 25 | authpriv 26 | ftp 27 | ntp 28 | audit 29 | alert 30 | cron 31 | local0 32 | local1 33 | local2 34 | local3 35 | local4 36 | local5 37 | local6 38 | local7 39 | ) 40 | 41 | //go:generate go run golang.org/x/tools/cmd/stringer@latest -type=severity -output=severity_string.go 42 | type severity byte 43 | 44 | const ( 45 | EMERG severity = iota 46 | ALERT 47 | CRIT 48 | ERR 49 | WARNING 50 | NOTICE 51 | INFO 52 | DEBUG 53 | ) 54 | 55 | type message struct { 56 | buf [2048]byte 57 | size int 58 | time time.Time 59 | host net.IP 60 | 61 | // parsed fields 62 | priority byte 63 | hostname []byte 64 | app []byte 65 | procid []byte 66 | msgid []byte 67 | msg []byte 68 | } 69 | 70 | func (m *message) Facility() facility { 71 | return facility(m.priority / 8) 72 | } 73 | 74 | func (m *message) Host() string { 75 | return m.host.String() 76 | } 77 | 78 | func (m *message) Severity() severity { 79 | return severity(m.priority % 8) 80 | } 81 | 82 | var msgCleanup = strings.NewReplacer([]string{"\b", ""}...) 83 | 84 | func (m *message) String() string { 85 | if m.msg == nil { 86 | return fmt.Sprintf("host=%s syslog=%q", m.host, m.buf[:m.size]) 87 | } 88 | 89 | fields := make([]string, 0, 7) 90 | 91 | // fields = append(fields, fmt.Sprintf("ptr=%p", m)) 92 | // fields = append(fields, "time=" + m.time.Format(time.RFC3339)) 93 | 94 | if m.hostname != nil { 95 | fields = append(fields, fmt.Sprintf("host=%s", m.hostname)) 96 | } else { 97 | fields = append(fields, "host="+m.host.String()) 98 | } 99 | 100 | fields = append(fields, "facility="+m.Facility().String()) 101 | fields = append(fields, "severity="+m.Severity().String()) 102 | 103 | if m.app != nil { 104 | fields = append(fields, fmt.Sprintf("app-name=%s", m.app)) 105 | } 106 | 107 | if m.procid != nil { 108 | fields = append(fields, fmt.Sprintf("procid=%s", m.procid)) 109 | } 110 | 111 | if m.msgid != nil { 112 | fields = append(fields, fmt.Sprintf("msgid=%s", m.msgid)) 113 | } 114 | 115 | fields = append(fields, fmt.Sprintf("msg=%q", msgCleanup.Replace(string(m.msg)))) 116 | 117 | return strings.Join(fields, " ") 118 | } 119 | 120 | func (m *message) Timestamp() time.Time { 121 | return m.time 122 | } 123 | 124 | func (m *message) correctLegacyTime(t time.Time) { 125 | t = t.AddDate(m.time.Year(), 0, 0) 126 | 127 | offset := m.time.Sub(t) //nolint:ifshort // erroneous warning. offset is used below 128 | if offset < 0 { 129 | offset = -offset 130 | } 131 | 132 | if hoursOff := (offset - (offset % time.Hour)) / time.Hour; hoursOff > 1 { 133 | t = t.Add(hoursOff) 134 | } 135 | 136 | m.time = t 137 | } 138 | 139 | func (m *message) parse() bool { 140 | if !m.parsePriority() { 141 | return false 142 | } 143 | if !m.parseVersion() { 144 | return m.parseLegacyHeader() 145 | } 146 | if !m.parseHeader() { 147 | return false 148 | } 149 | 150 | return m.parseStructuredData() 151 | } 152 | 153 | func (m *message) parseHeader() bool { 154 | // TIMESTAMP HOSTNAME APP-NAME PROCID MSGID MSG 155 | parts := bytes.SplitN(m.msg, []byte{' '}, 6) 156 | 157 | if len(parts) != 6 || !m.parseTimestamp(parts[0]) { 158 | return false 159 | } 160 | m.hostname = ignoreNil(parts[1]) 161 | m.app = ignoreNil(parts[2]) 162 | m.procid = ignoreNil(parts[3]) 163 | m.msgid = ignoreNil(parts[4]) 164 | m.msg = parts[5] 165 | 166 | return true 167 | } 168 | 169 | func (m *message) parseStructuredData() bool { 170 | if len(m.msg) >= 2 && m.msg[0] == '-' && m.msg[1] == ' ' { 171 | m.msg = m.msg[2:] 172 | 173 | return true 174 | } 175 | 176 | return false 177 | } 178 | 179 | func (m *message) parseLegacyHeader() bool { 180 | const ( 181 | layout = time.Stamp 182 | timeLen = len(layout) 183 | ) 184 | if len(m.msg) <= timeLen || m.msg[timeLen] != ' ' { 185 | goto parseHostname // too short or missing expected space after timestamp 186 | } 187 | 188 | if t, err := time.Parse(layout, string(m.msg[:timeLen])); err != nil { 189 | goto parseHostname // doesn't match the expected layout 190 | } else if !t.IsZero() { // if zero, ignore and use the current time 191 | m.correctLegacyTime(t) 192 | } 193 | m.msg = m.msg[timeLen+1:] 194 | 195 | parseHostname: 196 | m.hostname = nil 197 | 198 | m.parseLegacyTag() 199 | 200 | m.trimSeverityPrefix() 201 | m.trimTimePrefix() 202 | m.trimCarriageReturns() 203 | 204 | return true 205 | } 206 | 207 | func (m *message) parseLegacyTag() { 208 | b := m.msg 209 | 210 | for i, c := range b { 211 | if c >= '0' && c <= '9' { 212 | continue 213 | } 214 | if c >= 'a' && c <= 'z' { 215 | continue 216 | } 217 | if c >= 'A' && c <= 'Z' { 218 | continue 219 | } 220 | if c == '-' || c == '_' || c == '/' || c == '.' { 221 | continue 222 | } 223 | if c == '[' { 224 | m.app, b = b[:i], b[i:] 225 | 226 | goto parsePid 227 | } 228 | m.app, b = b[:i], b[i:] 229 | 230 | goto trimColon 231 | } 232 | m.app = nil 233 | m.procid = nil 234 | 235 | return 236 | 237 | parsePid: 238 | if i := bytes.IndexByte(b[1:], ']'); i != -1 { 239 | m.procid = b[1 : 1+i] 240 | b = b[1+i+1:] 241 | } else { 242 | m.procid = nil 243 | } 244 | 245 | trimColon: 246 | m.msg = bytes.TrimPrefix(b, []byte{':', ' '}) 247 | } 248 | 249 | func (m *message) parsePriority() bool { 250 | if m.size < 3 || m.buf[0] != '<' { 251 | return false 252 | } 253 | var pri byte 254 | for i, c := range m.buf[1:5] { 255 | if c == '>' { 256 | m.priority = pri 257 | m.msg = m.buf[1+i+1 : m.size] 258 | 259 | return true 260 | } 261 | if c < '0' || c > '9' { 262 | return false 263 | } 264 | pri = pri*10 + c - '0' 265 | } 266 | 267 | return false 268 | } 269 | 270 | func (m *message) parseTimestamp(b []byte) bool { 271 | if ignoreNil(b) == nil { 272 | return true // NILVALUE 273 | } 274 | 275 | const ( 276 | layout = "2006-01-02T15:04:05.999999Z07:00" 277 | timeLen = len(layout) 278 | ) 279 | if len(b) > timeLen { 280 | return false // too long 281 | } 282 | t, err := time.Parse(layout, string(b)) 283 | if err != nil { 284 | return false 285 | } 286 | m.time = t 287 | 288 | return true 289 | } 290 | 291 | func (m *message) parseVersion() bool { 292 | if len(m.msg) < 2 { 293 | return false // too short 294 | } 295 | if m.msg[1] != ' ' { 296 | return false // missing space after version 297 | } 298 | if m.msg[0] != '1' { 299 | return false // we only support version 1 300 | } 301 | m.msg = m.msg[2:] 302 | 303 | return true 304 | } 305 | 306 | func (m *message) reset() { 307 | m.priority = 0 308 | m.hostname = nil 309 | m.app = nil 310 | m.procid = nil 311 | m.msgid = nil 312 | m.msg = nil 313 | } 314 | 315 | func (m *message) trimSeverityPrefix() { 316 | prefix := []byte(m.Severity().String() + ": ") 317 | m.msg = bytes.TrimPrefix(m.msg, prefix) 318 | } 319 | 320 | func (m *message) trimTimePrefix() { 321 | m.msg = bytes.TrimPrefix(m.msg, []byte(m.time.Format("2006-01-02 15:04:05 "))) 322 | } 323 | 324 | func (m *message) trimCarriageReturns() { 325 | if len(m.msg) > 0 && m.msg[0] == '\r' { 326 | m.msg = m.msg[1:] 327 | } 328 | // m.msg = bytes.Replace(m.msg, "\r", "(CR)", -1) 329 | } 330 | 331 | func ignoreNil(b []byte) []byte { 332 | if len(b) == 1 && b[0] == '-' { 333 | return nil 334 | } 335 | 336 | return b 337 | } 338 | -------------------------------------------------------------------------------- /internal/syslog/receiver.go: -------------------------------------------------------------------------------- 1 | package syslog 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/go-logr/logr" 14 | ) 15 | 16 | var syslogMessagePool = sync.Pool{ 17 | New: func() interface{} { return new(message) }, 18 | } 19 | 20 | type Receiver struct { 21 | c *net.UDPConn 22 | parse chan *message 23 | done chan struct{} 24 | err error 25 | 26 | Logger logr.Logger 27 | } 28 | 29 | func StartReceiver(ctx context.Context, logger logr.Logger, laddr string, parsers int) error { 30 | if parsers < 1 { 31 | parsers = 1 32 | } 33 | 34 | addr, err := net.ResolveUDPAddr("udp4", laddr) 35 | if err != nil { 36 | return fmt.Errorf("resolve syslog udp listen address: %w", err) 37 | } 38 | 39 | c, err := net.ListenUDP("udp4", addr) 40 | if err != nil { 41 | return fmt.Errorf("listen on syslog udp address: %w", err) 42 | } 43 | 44 | s := &Receiver{ 45 | c: c, 46 | parse: make(chan *message, parsers), 47 | done: make(chan struct{}), 48 | Logger: logger, 49 | } 50 | 51 | for i := 0; i < parsers; i++ { 52 | go s.runParser() 53 | } 54 | go s.run(ctx) 55 | 56 | return nil 57 | } 58 | 59 | func (r *Receiver) Done() <-chan struct{} { 60 | return r.done 61 | } 62 | 63 | func (r *Receiver) Err() error { 64 | return r.err 65 | } 66 | 67 | func (r *Receiver) cleanup() { 68 | r.c.Close() 69 | 70 | close(r.parse) 71 | close(r.done) 72 | } 73 | 74 | func (r *Receiver) run(ctx context.Context) { 75 | var msg *message 76 | defer func() { 77 | if msg != nil { 78 | syslogMessagePool.Put(msg) 79 | } 80 | }() 81 | 82 | go func() { 83 | <-ctx.Done() 84 | r.cleanup() 85 | }() 86 | 87 | for { 88 | if msg == nil { 89 | var ok bool 90 | msg, ok = syslogMessagePool.Get().(*message) 91 | if !ok { 92 | r.Logger.Error(errors.New("error type asserting pool item into message"), "error type asserting pool item into message") 93 | 94 | continue 95 | } 96 | } 97 | n, from, err := r.c.ReadFromUDP(msg.buf[:]) 98 | if err != nil { 99 | err = fmt.Errorf("error reading udp message: %w", err) 100 | if _, ok := err.(net.Error); ok { 101 | r.Logger.Error(err, "error reading udp message") 102 | 103 | continue 104 | } 105 | r.err = err 106 | 107 | return 108 | } 109 | msg.time = time.Now().UTC() 110 | msg.host = from.IP 111 | msg.size = n 112 | r.parse <- msg 113 | msg = nil 114 | } 115 | } 116 | 117 | func parse(m *message) map[string]interface{} { 118 | structured := make(map[string]interface{}) 119 | if m.Facility().String() != "" { 120 | structured["facility"] = m.Facility().String() 121 | } 122 | if m.Severity().String() != "" { 123 | structured["severity"] = m.Severity().String() 124 | } 125 | if string(m.hostname) != "" { 126 | structured["hostname"] = string(m.hostname) 127 | } 128 | if string(m.app) != "" { 129 | structured["app-name"] = string(m.app) 130 | } 131 | if string(m.procid) != "" { 132 | structured["procid"] = string(m.procid) 133 | } 134 | if string(m.msgid) != "" { 135 | structured["msgid"] = string(m.msgid) 136 | } 137 | if string(m.msg) != "" { 138 | if strings.HasPrefix(string(m.msg), "{") { 139 | var j map[string]interface{} 140 | if err := json.Unmarshal(m.msg, &j); err == nil { 141 | structured["msg"] = j 142 | } 143 | } else { 144 | structured["msg"] = string(m.msg) 145 | } 146 | } 147 | structured["host"] = m.host.String() 148 | 149 | return structured 150 | } 151 | 152 | func (r *Receiver) runParser() { 153 | for m := range r.parse { 154 | if m.parse() { 155 | structured := parse(m) 156 | sl := r.Logger.WithValues("msg", structured) 157 | if m.Severity() == DEBUG { 158 | sl.V(1).Info("msg") 159 | } else { 160 | sl.Info("msg") 161 | } 162 | } else { 163 | r.Logger.V(1).Info("msg", "msg", m) 164 | } 165 | m.reset() 166 | syslogMessagePool.Put(m) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /internal/syslog/severity_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=severity -output=severity_string.go"; DO NOT EDIT. 2 | 3 | package syslog 4 | 5 | import "strconv" 6 | 7 | func _() { 8 | // An "invalid array index" compiler error signifies that the constant values have changed. 9 | // Re-run the stringer command to generate them again. 10 | var x [1]struct{} 11 | _ = x[EMERG-0] 12 | _ = x[ALERT-1] 13 | _ = x[CRIT-2] 14 | _ = x[ERR-3] 15 | _ = x[WARNING-4] 16 | _ = x[NOTICE-5] 17 | _ = x[INFO-6] 18 | _ = x[DEBUG-7] 19 | } 20 | 21 | const _severity_name = "EMERGALERTCRITERRWARNINGNOTICEINFODEBUG" 22 | 23 | var _severity_index = [...]uint8{0, 5, 10, 14, 17, 24, 30, 34, 39} 24 | 25 | func (i severity) String() string { 26 | if i >= severity(len(_severity_index)-1) { 27 | return "severity(" + strconv.FormatInt(int64(i), 10) + ")" 28 | } 29 | return _severity_name[_severity_index[i]:_severity_index[i+1]] 30 | } 31 | -------------------------------------------------------------------------------- /lint.mk: -------------------------------------------------------------------------------- 1 | # BEGIN: lint-install github.com/tinkerbell/smee 2 | # http://github.com/tinkerbell/lint-install 3 | 4 | .PHONY: lint 5 | lint: _lint ## Run linting 6 | 7 | LINT_ARCH := $(shell uname -m) 8 | LINT_OS := $(shell uname) 9 | LINT_OS_LOWER := $(shell echo $(LINT_OS) | tr '[:upper:]' '[:lower:]') 10 | LINT_ROOT := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 11 | 12 | # shellcheck and hadolint lack arm64 native binaries: rely on x86-64 emulation 13 | ifeq ($(LINT_OS),Darwin) 14 | ifeq ($(LINT_ARCH),arm64) 15 | LINT_ARCH=x86_64 16 | endif 17 | endif 18 | 19 | LINTERS := 20 | FIXERS := 21 | 22 | GOLANGCI_LINT_CONFIG := $(LINT_ROOT)/.golangci.yml 23 | GOLANGCI_LINT_VERSION ?= v2.2.1 24 | GOLANGCI_LINT_BIN := $(LINT_ROOT)/out/linters/golangci-lint-$(GOLANGCI_LINT_VERSION)-$(LINT_ARCH) 25 | $(GOLANGCI_LINT_BIN): 26 | mkdir -p $(LINT_ROOT)/out/linters 27 | rm -rf $(LINT_ROOT)/out/linters/golangci-lint-* 28 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LINT_ROOT)/out/linters $(GOLANGCI_LINT_VERSION) 29 | mv $(LINT_ROOT)/out/linters/golangci-lint $@ 30 | 31 | LINTERS += golangci-lint-lint 32 | golangci-lint-lint: $(GOLANGCI_LINT_BIN) 33 | find . -name go.mod -execdir sh -c '"$(GOLANGCI_LINT_BIN)" run -c "$(GOLANGCI_LINT_CONFIG)"' '{}' '+' 34 | 35 | FIXERS += golangci-lint-fix 36 | golangci-lint-fix: $(GOLANGCI_LINT_BIN) 37 | find . -name go.mod -execdir "$(GOLANGCI_LINT_BIN)" run -c "$(GOLANGCI_LINT_CONFIG)" --fix \; 38 | 39 | .PHONY: _lint $(LINTERS) 40 | _lint: $(LINTERS) 41 | 42 | .PHONY: fix $(FIXERS) 43 | fix: $(FIXERS) 44 | 45 | # END: lint-install github.com/tinkerbell/smee 46 | -------------------------------------------------------------------------------- /rules.mk: -------------------------------------------------------------------------------- 1 | # Only use the recipes defined in these makefiles 2 | MAKEFLAGS += --no-builtin-rules 3 | .SUFFIXES: 4 | # Delete target files if there's an error 5 | # This avoids a failure to then skip building on next run if the output is created by shell redirection for example 6 | # Not really necessary for now, but just good to have already if it becomes necessary later. 7 | .DELETE_ON_ERROR: 8 | # Treat the whole recipe as a one shell script/invocation instead of one-per-line 9 | .ONESHELL: 10 | # Use bash instead of plain sh 11 | SHELL := bash 12 | .SHELLFLAGS := -o pipefail -euc 13 | 14 | # Runnable tools 15 | GO ?= go 16 | GOIMPORTS := $(GO) run golang.org/x/tools/cmd/goimports@latest 17 | 18 | .PHONY: all smee crosscompile dc image gen run test 19 | 20 | CGO_ENABLED := 0 21 | export CGO_ENABLED 22 | 23 | GitRev := $(shell git rev-parse --short HEAD) 24 | 25 | crossbinaries := cmd/smee/smee-linux-amd64 cmd/smee/smee-linux-arm64 26 | cmd/smee/smee-linux-amd64: FLAGS=GOARCH=amd64 27 | cmd/smee/smee-linux-arm64: FLAGS=GOARCH=arm64 28 | cmd/smee/smee-linux-amd64 cmd/smee/smee-linux-arm64: smee 29 | ${FLAGS} GOOS=linux go build -ldflags="-X main.GitRev=${GitRev}" -o $@ ./cmd/smee/ 30 | 31 | generated_go_files := \ 32 | internal/syslog/facility_string.go \ 33 | internal/syslog/severity_string.go \ 34 | 35 | # go generate 36 | go_generate: $(generated_go_files) 37 | $(filter %_string.go,$(generated_go_files)): 38 | internal/syslog/facility_string.go: internal/syslog/message.go 39 | internal/syslog/severity_string.go: internal/syslog/message.go 40 | $(generated_go_files): 41 | go generate -run="$(@F)" ./... 42 | $(GOIMPORTS) -w $@ 43 | 44 | cmd/smee/smee: internal/syslog/facility_string.go internal/syslog/severity_string.go cleanup 45 | go build -v -ldflags="-X main.GitRev=${GitRev}" -o $@ ./cmd/smee/ 46 | 47 | cleanup: 48 | rm -f cmd/smee/smee cmd/smee/smee-linux-amd64 cmd/smee/smee-linux-arm64 -------------------------------------------------------------------------------- /test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.14 2 | EXPOSE 67 69 3 | 4 | RUN apk add --update --upgrade --no-cache net-tools busybox tftp-hpa curl tcpdump 5 | 6 | COPY busybox-udhcpc-script.sh /busybox-udhcpc-script.sh 7 | COPY extract-traceparent-from-opt43.sh /extract-traceparent-from-opt43.sh 8 | COPY test-smee.sh /test-smee.sh 9 | 10 | ENTRYPOINT /test-smee.sh 11 | -------------------------------------------------------------------------------- /test/busybox-udhcpc-script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # instead of messing with the actual interface configuration 3 | # this just dumps the environment variables to a file and stdout 4 | 5 | env | grep -v '^[A-Z]' | sort | tee /tmp/dhcpoffer-vars.sh 6 | -------------------------------------------------------------------------------- /test/extract-traceparent-from-opt43.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # shellcheck shell=dash 3 | 4 | # extract_traceparent_from_opt43 takes a hex string from busybox udhcpc's opt43 5 | # and extracts sub-option 69 which is where we stuff the traceparent in binary, 6 | # which busybox helpfully gives us in a hex string as $opt43 7 | # 8 | # PXE_DISCOVERY_CONTROL is 060108 (option 6, 1 byte long, value 8) 9 | # traceparent is 451a (type 69, 26 bytes, value is tp) 10 | # 11 | # The DHCP spec says nothing about ordering and smee can be observed to serve 12 | # the types in a different order on different runs, so the option has to be 13 | # fully parsed to get the right data. 14 | # 15 | # this would be way easier in perl/python but this needs to work in dash 16 | # and with busybox shell tools 17 | # 18 | # takes 1 argument, usually $opt43 19 | # sets $opt43x69 to the hex traceparent 20 | # exports $TRACEPARENT to the W3C-formatted traceparent string 21 | extract_traceparent_from_opt43() { 22 | local hexdata strlen offset 23 | hexdata=$1 24 | shift 25 | opt43x69="" # in case the global is still set, empty it 26 | strlen=$(echo -n "$hexdata" | wc -c) 27 | offset=1 # cut(1) uses offsets starting at 1 28 | 29 | while [ "$offset" -lt "$strlen" ]; do 30 | # extract the type number, 1 byte 31 | local type_end htype type 32 | type_end=$((offset + 1)) 33 | htype=$(echo -n "$hexdata" | cut -c "${offset}-${type_end}") 34 | type=$(printf '%d' "0x$htype") 35 | 36 | # extract the value length, 1 byte 37 | local len_start len_end hlen len 38 | len_start=$((offset + 2)) 39 | len_end=$((offset + 3)) 40 | hlen=$(echo -n "$hexdata" | cut -c "${len_start}-${len_end}") 41 | len=$(printf '%d' "0x$hlen") 42 | 43 | # calculate value offsets 44 | local bov eov 45 | bov=$((offset + 4)) # beginning of value 46 | eov=$((bov + len * 2 - 1)) # end of value 47 | 48 | if [ "$type" -eq 69 ]; then 49 | # set global to the full tp hex data 50 | opt43x69=$(echo -n "$hexdata" | cut -c "${bov}-${eov}") 51 | 52 | # break out the sections of the traceparent to make a proper W3C tp string 53 | local ver trace_id span_id flags 54 | ver=$(echo -n "$opt43x69" | cut -c "1-2") # 1 byte 55 | trace_id=$(echo -n "$opt43x69" | cut -c "3-34") # 16 bytes 56 | span_id=$(echo -n "$opt43x69" | cut -c "35-50") # 8 bytes 57 | flags=$(echo -n "$opt43x69" | cut -c "51-53") # 1 byte 58 | 59 | # set TRACEPARENT to the W3C-formatted string 60 | export TRACEPARENT="${ver}-${trace_id}-${span_id}-${flags}" 61 | fi 62 | 63 | # add to the offset: 64 | # 4 characters for type and len e.g. 0601 (type 6, length 1) 65 | # len (is bytes) * 2 (bc hex) = chars of offset e.g. 08 (value is 8, 2 chars in hex) 66 | offset=$((4 + offset + len * 2)) 67 | local next 68 | next=$(echo -n "$hexdata" | cut -c "${offset}-$((offset + 1))") 69 | 70 | # opt43 always ends with 0xff so if the next byte is ff it's the end for sure 71 | if [ "$next" = "ff" ]; then 72 | break 73 | fi 74 | done 75 | } 76 | -------------------------------------------------------------------------------- /test/hardware.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 02:00:00:00:00:ff: 3 | ipAddress: "192.168.99.43" 4 | subnetMask: "255.255.255.0" 5 | defaultGateway: "192.168.99.1" 6 | nameServers: 7 | - "8.8.8.8" 8 | hostname: "smee-test-client" 9 | domainName: "example.com" 10 | broadcastAddress: "192.168.2.255" 11 | ntpServers: 12 | - "132.163.96.2" 13 | leaseTime: 86400 14 | domainSearch: 15 | - "example.com" 16 | netboot: 17 | allowPxe: true 18 | -------------------------------------------------------------------------------- /test/otel-collector.yaml: -------------------------------------------------------------------------------- 1 | # opentelemetry-collector is a proxy for telemetry events. 2 | # 3 | # This configuration is set up for use in smee development. 4 | # With collector in debug mode every trace is printed to the console 5 | # so you can see traces without any complex tooling. There are also 6 | # examples below for how to send to Lightstep and Honeycomb. 7 | 8 | receivers: 9 | otlp: 10 | protocols: 11 | grpc: 12 | endpoint: "0.0.0.0:4317" 13 | 14 | processors: 15 | batch: 16 | 17 | exporters: 18 | # set to debug and your traces will get printed to the console spammily 19 | logging: 20 | logLevel: debug 21 | # Lightstep: set & export LIGHTSTEP_TOKEN and enable below 22 | otlp/1: 23 | endpoint: "ingest.lightstep.com:443" 24 | headers: 25 | "lightstep-access-token": "${LIGHTSTEP_TOKEN}" 26 | # Honeycomb: set & export HONEYCOMB_TEAM to the auth token, and set/export 27 | # HONEYCOMB_DATASET to the dataset name you want to use, then enable below 28 | otlp/2: 29 | endpoint: "api.honeycomb.io:443" 30 | headers: 31 | "x-honeycomb-team": "${HONEYCOMB_TEAM}" 32 | "x-honeycomb-dataset": "${HONEYCOMB_DATASET}" 33 | 34 | service: 35 | pipelines: 36 | traces: 37 | receivers: [otlp] 38 | processors: [batch] 39 | # only enable logging by default 40 | exporters: [logging] 41 | # Lightstep: 42 | # exporters: [logging, otlp/1] 43 | # Honeycomb: 44 | # exporters: [logging, otlp/2] 45 | -------------------------------------------------------------------------------- /test/start-smee.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # the docker-compose overrides the smee container's ENTRYPOINT 3 | # with this script so it's a little easier to debug things 4 | # 5 | # configuration environment variables are provided by docker-compose 6 | 7 | # for example, to see the DHCP packets coming from the DHCP client 8 | # container, uncomment these. 9 | # apk update && apk add --no-cache tcpdump 10 | # tcpdump -nvvei eth0 port 67 or port 68 & 11 | # or just apk add tcpdump then run this in another terminal: 12 | # docker exec -ti smee_smee_1 tcpdump -nvvei eth0 port 67 or port 68 13 | 14 | # start smee and explicitly bind DHCP to broadcast address otherwise 15 | # smee will start up fine but not see the DHCP requests 16 | # TODO: probably move smee to just use the envvars for otel 17 | /usr/bin/smee & 18 | 19 | sleep 100000 20 | -------------------------------------------------------------------------------- /test/test-smee.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # shellcheck shell=dash disable=SC1091,SC2154 3 | 4 | # useful for debugging sometimes 5 | # tcpdump -ni eth0 & 6 | # alternatively, only show DHCP and pretty print the packets 7 | # tcpdump -nvvei eth0 port 67 or port 68 & 8 | 9 | sleep_at_start=3 10 | echo "starting DHCP in $sleep_at_start seconds" 11 | sleep $sleep_at_start 12 | 13 | # busybox udhcpc will happily set arbitrary DHCP options and is easy 14 | # to configure with a custom setup script to call on DHCPOFFER 15 | # 16 | # dummy setup script for -s is copied in by Dockerfile 17 | # -q tells udhcpc to exit after getting a lease, otherwise it will keep generating new traces 18 | # opt60 (-V PXEClient) pretend to be an Intel PXE client. required to be noticed by smee 19 | # opt93 (-x 0x5d) set to 0 for "Intel x86PC" platform, required by smee 20 | # opt94 (-x 0x5e) set to 0 for "UNDI" firmware type, required by smee 21 | # opt97 (-x 0x61) sets the client guid (https://datatracker.ietf.org/doc/html/rfc4578#section-2.3) 22 | # first 8 octets should be zeroes to make smee happy (Intel PXE does this) 23 | # ID: 4a525bd43517df7f8b4799c18d (randomly generated and hard-coded here) 24 | busybox udhcpc \ 25 | -q \ 26 | -s /busybox-udhcpc-script.sh \ 27 | -V PXEClient \ 28 | -x 0x5d:0000 \ 29 | -x 0x5e:0000 \ 30 | -x 0x61:000000004a525bd43517df7f8b4799c18d 31 | 32 | # set boot_file variable ahead of sourcing dhcpoffer-vars.sh to please the linter 33 | boot_file="" 34 | 35 | # the busybox script writes the DHCP variables to /tmp/dhcpoffer-vars.sh 36 | # shellcheck disable=SC1091 37 | . /tmp/dhcpoffer-vars.sh 38 | 39 | # smee sets 2 values in option 43, check out dhcp/pxe.go 40 | # these can come in out of order so we have to look for the traceparent's 41 | # id and length which is always 0x451a 42 | # busybox udhcpc helpfully returns options in hex 43 | # option43 ordering is not guaranteed, at least not in this implementation 44 | . extract-traceparent-from-opt43.sh # load a function to do the parsing 45 | extract_traceparent_from_opt43 "$opt43" # parse the value, exports TRACEPARENT 46 | echo "got traceparent $TRACEPARENT from opt43 value $opt43" 47 | # write it to the shell profile.d for easy loading 48 | echo "export TRACEPARENT=$TRACEPARENT" >/etc/profile.d/smee-traceparent.sh 49 | 50 | # fetch / from the server with the traceparent set 51 | tp_header="Traceparent: $TRACEPARENT" 52 | curl -H "$tp_header" http://192.168.99.42/auto.ipxe 53 | # TODO: test opportunity here: validate the returned traceparent matches the one in boot_file 54 | 55 | # boot_file is set by the DHCP envvars 56 | # OTEL in Smee is enabled by default. 57 | tftp 192.168.99.42 -c get "${boot_file}" 58 | 59 | # sleep a long time so you can enter the container with 60 | # docker exec -ti smee_client_1 /bin/sh 61 | sleep 30000 62 | --------------------------------------------------------------------------------