Skip to content

Commit

Permalink
Merge pull request #10 from OpenCHAMI/lritzdorf/rearchitect
Browse files Browse the repository at this point in the history
Rearchitect to remove Gin server package, add JWT-protected HTTP routes
  • Loading branch information
alexlovelltroy authored Jul 17, 2024
2 parents 2c368cd + ce70174 commit e99542a
Show file tree
Hide file tree
Showing 13 changed files with 393 additions and 233 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/ochami.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
push:
tags:
- v*

permissions: write-all # Necessary for the generate-build-provenance action with containers

jobs:
Expand Down Expand Up @@ -52,11 +52,11 @@ jobs:
node process.js
echo "digest=$(cat digest.txt)" >> $GITHUB_OUTPUT
- name: Attest Binaries
uses: github-early-access/generate-build-provenance@main
uses: actions/attest-build-provenance@v1
with:
subject-path: dist/smd*
subject-path: dist/cloud-init*
- name: generate build provenance
uses: github-early-access/generate-build-provenance@main
uses: actions/attest-build-provenance@v1
with:
subject-name: ghcr.io/openchami/cloud-init
subject-digest: ${{ steps.process_goreleaser_output.outputs.digest }}
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
dist
dist
# Our compiled binary
/cloud-init-server
4 changes: 2 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ dockers:
image_templates:
- ghcr.io/openchami/{{.ProjectName}}:latest
- ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}
- ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}
- ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}
- ghcr.io/openchami/{{.ProjectName}}:v{{ .Major }}
- ghcr.io/openchami/{{.ProjectName}}:v{{ .Major }}.{{ .Minor }}
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] - 2024-07-17

### Added

- Added an additional URL endpoint (`/cloud-init-secure`) which requires JWT authentication for access
- At the Docker level, if the `JWKS_URL` env var is set, this server will attempt to load the corresponding JSON Web Key Set at startup.
If this succeeds, the secure route will be enabled, with tokens validated against the JWKS keyset.
- During a query, if no xnames are found for the given input name (usually a MAC address), the input name is used directly.
This enables users to query an xname (i.e. without needing to look up its MAC first and query using that), or a group name.

### Changed

- Switched from [Gin](https://github.com/gin-gonic/gin) HTTP router to [Chi](https://github.com/go-chi/chi)
- When adding entries to the internal datastore, names are no longer "slug-ified" (via the `gosimple/slug` package).
This means that when a user requests data for a node, the name they query should be a standard colon-separated MAC address, as opposed to using dashes.
- Rather than requiring a single static JWT on launch, we now accept an OIDC token endpoint. New JWTs are requested from the endpoint as necessary, allowing us to run for longer than the lifetime of a single token.

## [0.0.4] - 2024-01-17

### Added
Expand All @@ -13,4 +30,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Created SMD client
- Added memory-based store
- Able to provide cloud-init payloads that work with newly booted nodes
- Build and release with goreleaser
- Build and release with goreleaser
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ RUN set -ex \
# Get the boot-script-service from the builder stage.
COPY cloud-init-server /usr/local/bin/

ENV TOKEN_URL="http://opaal:3333/token"
ENV SMD_URL="http://smd:27779"
ENV SMD_TOKEN=""
ENV LISTEN_ADDR="0.0.0.0:27777"
ENV JWKS_URL=""

# nobody 65534:65534
USER 65534:65534

# Set up the command to start the service.
CMD /usr/local/bin/cloud-init-server --listen ${LISTEN_ADDR} --smd-url ${SMD_URL} --smd-token ${SMD_TOKEN}
CMD /usr/local/bin/cloud-init-server --listen ${LISTEN_ADDR} --smd-url ${SMD_URL} --token-url ${TOKEN_URL} --jwks-url ${JWKS_URL:-""}


ENTRYPOINT ["/sbin/tini", "--"]
61 changes: 61 additions & 0 deletions cmd/cloud-init-server/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

// Adapted from OpenCHAMI SMD's auth.go

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

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

type statusCheckTransport struct {
http.RoundTripper
}

func (ct *statusCheckTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := http.DefaultTransport.RoundTrip(req)
if err == nil && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code: %d", resp.StatusCode)
}

return resp, err
}

func newHTTPClient() *http.Client {
return &http.Client{Transport: &statusCheckTransport{}}
}

func fetchPublicKeyFromURL(url string) (*jwtauth.JWTAuth, error) {
client := newHTTPClient()

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
set, err := jwk.Fetch(ctx, url, jwk.WithHTTPClient(client))
if err != nil {
msg := "%w"

// if the error tree contains an EOF, it means that the response was empty,
// so add a more descriptive message to the error tree
if errors.Is(err, io.EOF) {
msg = "received empty response for key: %w"
}

return nil, fmt.Errorf(msg, err)
}
jwks, err := json.Marshal(set)
if err != nil {
return nil, fmt.Errorf("failed to marshal JWKS: %v", err)
}
keyset, err := jwtauth.NewKeySet(jwks)
if err != nil {
return nil, fmt.Errorf("failed to initialize JWKS: %v", err)
}

return keyset, nil
}
112 changes: 65 additions & 47 deletions cmd/cloud-init-server/handlers.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package main

import (
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/OpenCHAMI/cloud-init/internal/memstore"
"github.com/OpenCHAMI/cloud-init/internal/smdclient"
"github.com/OpenCHAMI/cloud-init/pkg/citypes"
"github.com/gin-gonic/gin"
"github.com/gosimple/slug"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
yaml "gopkg.in/yaml.v2"
)

Expand All @@ -30,13 +32,13 @@ func NewCiHandler(s ciStore, c *smdclient.SMDClient) *CiHandler {
// @Produce json
// @Success 200 {object} map[string]CI
// @Router /harbor [get]
func (h CiHandler) ListEntries(c *gin.Context) {
func (h CiHandler) ListEntries(w http.ResponseWriter, r *http.Request) {
ci, err := h.store.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusInternalServerError)
}

c.JSON(200, ci)
render.JSON(w, r, ci)
}

// AddEntry godoc
Expand All @@ -49,26 +51,29 @@ func (h CiHandler) ListEntries(c *gin.Context) {
// @Failure 400 {string} string "bad request"
// @Failure 500 {string} string "internal server error"
// @Router /harbor [post]
func (h CiHandler) AddEntry(c *gin.Context) {
func (h CiHandler) AddEntry(w http.ResponseWriter, r *http.Request) {
var ci citypes.CI
if err := c.ShouldBindJSON(&ci); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err = json.Unmarshal(body, &ci); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

id := slug.Make(ci.Name)

err := h.store.Add(id, ci)
err = h.store.Add(ci.Name, ci)
if err != nil {
if err == memstore.ExistingEntryErr {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusNotFound)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, ci.Name)
render.JSON(w, r, ci.Name)
}

// GetEntry godoc
Expand All @@ -79,89 +84,102 @@ func (h CiHandler) AddEntry(c *gin.Context) {
// @Success 200 {object} CI
// @Failure 404 {string} string "not found"
// @Router /harbor/{id} [get]
func (h CiHandler) GetEntry(c *gin.Context) {
id := c.Param("id")
func (h CiHandler) GetEntry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")

ci, err := h.store.Get(id, h.sm)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusNotFound)
} else {
render.JSON(w, r, ci)
}

c.JSON(200, ci)
}

func (h CiHandler) GetUserData(c *gin.Context) {
id := c.Param("id")
func (h CiHandler) GetUserData(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")

ci, err := h.store.Get(id, h.sm)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusNotFound)
}
ud, err := yaml.Marshal(ci.CIData.UserData)
if err != nil {
fmt.Print(err)
}
s := fmt.Sprintf("#cloud-config\n%s", string(ud[:]))
//c.Header("Content-Type", "text/yaml")
c.Data(200, "text/yaml", []byte(s))
w.Header().Set("Content-Type", "text/yaml")
w.Write([]byte(s))
}

func (h CiHandler) GetMetaData(c *gin.Context) {
id := c.Param("id")
func (h CiHandler) GetMetaData(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")

ci, err := h.store.Get(id, h.sm)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusNotFound)
}

c.YAML(200, ci.CIData.MetaData)
md, err := yaml.Marshal(ci.CIData.MetaData)
if err != nil {
fmt.Print(err)
}
w.Header().Set("Content-Type", "text/yaml")
w.Write([]byte(md))
}

func (h CiHandler) GetVendorData(c *gin.Context) {
id := c.Param("id")
func (h CiHandler) GetVendorData(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")

ci, err := h.store.Get(id, h.sm)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusNotFound)
}

c.YAML(200, ci.CIData.VendorData)
md, err := yaml.Marshal(ci.CIData.VendorData)
if err != nil {
fmt.Print(err)
}
w.Header().Set("Content-Type", "text/yaml")
w.Write([]byte(md))
}

func (h CiHandler) UpdateEntry(c *gin.Context) {
func (h CiHandler) UpdateEntry(w http.ResponseWriter, r *http.Request) {
var ci citypes.CI
if err := c.ShouldBindJSON(&ci); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err = json.Unmarshal(body, &ci); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

id := c.Param("id")
id := chi.URLParam(r, "id")

err := h.store.Update(id, ci)
err = h.store.Update(id, ci)
if err != nil {
if err == memstore.NotFoundErr {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusNotFound)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, id)
render.JSON(w, r, id)
}

func (h CiHandler) DeleteEntry(c *gin.Context) {
id := c.Param("id")
func (h CiHandler) DeleteEntry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")

err := h.store.Remove(id)
if err != nil {
if err == memstore.NotFoundErr {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusNotFound)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

c.JSON(http.StatusOK, gin.H{"status": "success"})
render.JSON(w, r, map[string]string{"status": "success"})
}
Loading

0 comments on commit e99542a

Please sign in to comment.