Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/Release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ jobs:
go-version: ${{ needs.go-version.outputs.version }}
goreleaser-version: "v2.11.2"
pre-build-commands: |
go install github.com/swaggo/swag/cmd/swag@v1.16.6
sudo apt update && sudo apt install -y git gcc g++ make ca-certificates curl gnupg gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu libc6-dev-arm64-cross software-properties-common clang-tools libstdc++-13-dev-arm64-cross
attestation-binary-path: "dist/cloud-init*"
registry-name: ghcr.io/openchami/cloud-init
3 changes: 1 addition & 2 deletions .goreleaser-darwin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ version: 2
project_name: cloud-init
before:
hooks:
- go install github.com/swaggo/swag/cmd/swag@v1.16.6
- swag init -g cmd/cloud-init-server/main.go
- make swagger

builds:
- id: darwin
Expand Down
3 changes: 1 addition & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ version: 2
project_name: cloud-init
before:
hooks:
- go install github.com/swaggo/swag/cmd/swag@v1.16.6
- swag init -g cmd/cloud-init-server/main.go
- make swagger

builds:
- id: linux
Expand Down
5 changes: 5 additions & 0 deletions .swaggo
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC
// SPDX-License-Identifier: MIT

// json.Number marshals as a JSON number, despite its underlying Go string type.
replace encoding/json.Number number
2 changes: 1 addition & 1 deletion Dockerfile.debug
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
#
# dlv connect container-hostname.example:2345

FROM golang:1.26.9-bookworm
FROM golang:1.26.10-bookworm

# Configure go env
ENV GOPATH=/usr/local/golib
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# SPDX-License-Identifier: MIT

.PHONY: help build clean docker-build fmt mod-install lint lint-fix release-check release-snapshot run test test-race test-stress tidy tidy-check vet vuln check
.PHONY: help build clean docker-build fmt mod-install lint lint-fix release-check release-snapshot run test test-race test-stress tidy tidy-check vet vuln check swagger

GO ?= go
GIT ?= git
Expand All @@ -19,6 +19,7 @@ GO_MOD_VERSION ?= $(shell awk '/^go / {print $$2; exit}' go.mod)
GO_TOOLCHAIN_VERSION ?= $(GO_MOD_VERSION)
GOTOOLCHAIN ?= go$(GO_TOOLCHAIN_VERSION)
GO_ENV := GOTOOLCHAIN=$(GOTOOLCHAIN)
SWAG_VERSION ?= v1.16.6
GORELEASER_VERSION ?= v2.11.2
GORELEASER ?= $(GO_ENV) $(GO) run github.com/goreleaser/goreleaser/v2@$(GORELEASER_VERSION)

Expand All @@ -45,6 +46,9 @@ LDFLAGS := -ldflags "-X 'main.GitCommit=$(COMMIT)' \
help: ## Display this help screen
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[VAR=val]... <target>\033[0m\n\nTargets:\n"} /^[a-zA-Z0-9_\/.-]+:.*##/ {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)

swagger: ## Regenerate API docs, including embedded simulator node models
$(GO_ENV) $(GO) run github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) init -g main.go --dir cmd/cloud-init-server,internal/smdclient,pkg/cistore,pkg/wgtunnel --parseDependencyLevel 1 --parseInternal

mod-install: ## Download and verify Go modules
$(GO_ENV) $(GO) mod download
$(GO_ENV) $(GO) mod verify
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ _Example:_

### Fake SMD Mode

All `/admin/` APIs, including impersonation and simulator administration, now require
a bearer JWT. Configure `JWKS_URL` or `--jwks-url`; absent or failed JWKS initialization
leaves admin requests unavailable (503), while node bootstrap remains tokenless.
See [admin authentication and upgrade guidance](docs/admin-authentication.md) for
the existing claim profile, key trust/rotation limits and client migration.

For development purposes, you can run the cloud-init server without connecting to a real SMD instance. By setting the environment variable `CLOUD_INIT_SMD_SIMULATOR` to `true`, the service will generate a set of simulated nodes.

**Example command:**
Expand All @@ -130,7 +136,8 @@ By default, the service determines what configuration to return based on the IP

**Sample commands:**
```bash
curl http://localhost:27777/cloud-init/admin/impersonation/x3000c1b1n1/meta-data
curl --fail-with-body -H "Authorization: Bearer ${CLOUD_INIT_ADMIN_TOKEN}" \
http://localhost:27777/cloud-init/admin/impersonation/x3000c1b1n1/meta-data
```

### Nocloud-net Datasource
Expand Down Expand Up @@ -200,7 +207,8 @@ The service supports advanced configuration through group handling and instance
This example sets a syslog aggregator via jinja templating. The group data is stored under the group name and then used in the vendor-data file.

```bash
curl -X POST http://localhost:27777/cloud-init/admin/groups/ \
curl --fail-with-body -X POST http://localhost:27777/cloud-init/admin/groups/ \
-H "Authorization: Bearer ${CLOUD_INIT_ADMIN_TOKEN}" \
Comment thread
synackd marked this conversation as resolved.
Resolved
-H "Content-Type: application/json" \
-d '{
"name": "x3001",
Expand All @@ -224,7 +232,8 @@ To add more sophisticated vendor-data (for example, installing the slurm client)
#### Set Cluster Defaults:

```bash
curl -X POST http://localhost:27777/cloud-init/admin/cluster-defaults/ \
curl --fail-with-body -X POST http://localhost:27777/cloud-init/admin/cluster-defaults/ \
-H "Authorization: Bearer ${CLOUD_INIT_ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"cloud-provider": "openchami",
Expand All @@ -241,7 +250,8 @@ curl -X POST http://localhost:27777/cloud-init/admin/cluster-defaults/ \
#### Override Instance Data:

```bash
curl -X PUT http://localhost:27777/cloud-init/admin/instance-info/x3000c1b1n1 \
curl --fail-with-body -X PUT http://localhost:27777/cloud-init/admin/instance-info/x3000c1b1n1 \
-H "Authorization: Bearer ${CLOUD_INIT_ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"local-hostname": "compute-1",
Expand Down
158 changes: 158 additions & 0 deletions cmd/cloud-init-server/admin_auth_http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC
// SPDX-License-Identifier: MIT

package main

import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"testing"
"time"

"github.com/OpenCHAMI/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
)

func TestAdminJWTThroughJWKSAndProxy(t *testing.T) {
private, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
key, err := jwk.FromRaw(private)
if err != nil {
t.Fatal(err)
}
if err = key.Set(jwk.KeyIDKey, "fixture-admin-key"); err != nil {
t.Fatal(err)
}
if err = key.Set(jwk.AlgorithmKey, jwa.RS256); err != nil {
t.Fatal(err)
}
public, err := key.PublicKey()
if err != nil {
t.Fatal(err)
}
set := jwk.NewSet()
if err = set.AddKey(public); err != nil {
t.Fatal(err)
}
keys := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if encodeErr := json.NewEncoder(w).Encode(set); encodeErr != nil {
t.Error(encodeErr)
}
}))
t.Cleanup(keys.Close)
verifier, err := fetchPublicKeyFromURL(keys.URL)
if err != nil {
t.Fatal(err)
}
token := adminTestToken(t, jwtauth.New("RS256", key, nil), adminTestClaims())
if err = key.Set(jwk.KeyIDKey, "unknown-fixture-key"); err != nil {
t.Fatal(err)
}
unknownKeyToken := adminTestToken(t, jwtauth.New("RS256", key, nil), adminTestClaims())
router, data := adminTestRouter(t, verifier)
backend := httptest.NewServer(router)
t.Cleanup(backend.Close)
target, err := url.Parse(backend.URL)
if err != nil {
t.Fatal(err)
}
transport := &http.Transport{Proxy: nil}
t.Cleanup(transport.CloseIdleConnections)
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = transport
frontend := httptest.NewServer(proxy)
t.Cleanup(frontend.Close)
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
for _, entry := range []struct{ name, address string }{{"direct", backend.URL}, {"proxied", frontend.URL}} {
t.Run(entry.name, func(t *testing.T) {
for _, test := range []struct {
name, method, path, token, body string
status int
}{
{"anonymous-read", "GET", "/admin/groups", "", "", 401},
{"invalid-read", "GET", "/admin/groups", "fixture-invalid-token", "", 401},
{"unknown-key", "GET", "/admin/groups", unknownKeyToken, "", 401},
{"authenticated-read", "GET", "/admin/groups", token, "", 200},
{"authenticated-write", "POST", "/admin/cluster-defaults", token, `{"cluster-name":"fixture-authenticated"}`, 201},
{"anonymous-write", "POST", "/admin/cluster-defaults", "", `{"cluster-name":"unexpected"}`, 401},
{"impersonation", "GET", "/admin/impersonation/x3000c0b0n1/user-data", token, "", 200},
{"tokenless-bootstrap", "GET", "/user-data", "", "", 200},
} {
t.Run(test.name, func(t *testing.T) {
request, requestErr := http.NewRequest(test.method, entry.address+test.path, strings.NewReader(test.body))
if requestErr != nil {
t.Fatal(requestErr)
}
if test.token != "" {
request.Header.Set("Authorization", "Bearer "+test.token)
}
// An attacker-controlled forwarding claim never bypasses authentication.
request.Header.Set("X-Forwarded-For", "127.0.0.1")
response, requestErr := client.Do(request)
if requestErr != nil {
t.Fatal(requestErr)
}
body, requestErr := io.ReadAll(response.Body)
_ = response.Body.Close()
if requestErr != nil {
t.Fatal(requestErr)
}
if response.StatusCode != test.status {
t.Fatalf("got %d, want %d", response.StatusCode, test.status)
}
if test.name == "tokenless-bootstrap" && string(body) != "#cloud-config" {
t.Fatal("bootstrap response changed")
}
})
}
})
}
if data.ClusterDefaults.ClusterName != "fixture-authenticated" {
t.Fatal("JWT-rejected request changed cluster defaults")
}
}

func TestAdminJWKSLoadFailureClosesOnlyAdmin(t *testing.T) {
for _, test := range []struct {
name, body string
status int
}{
{"non-success", `{"keys":[]}`, http.StatusServiceUnavailable},
{"malformed", `not-a-jwks`, http.StatusOK},
{"empty", `{"keys":[]}`, http.StatusOK},
} {
t.Run(test.name, func(t *testing.T) {
keys := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(test.status)
_, _ = io.WriteString(w, test.body)
}))
t.Cleanup(keys.Close)
verifier, err := fetchPublicKeyFromURL(keys.URL)
if err == nil || verifier != nil {
t.Fatal("failed JWKS load returned an enabled verifier")
}
router, _ := adminTestRouter(t, verifier)
for _, probe := range []struct {
path string
status int
}{{"/admin/groups", 503}, {"/user-data", 200}} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, probe.path, nil))
if response.Code != probe.status {
t.Fatalf("%s: got %d, want %d", probe.path, response.Code, probe.status)
}
}
})
}
}
Loading
Loading