Skip to content

Commit

Permalink
Serve and patch Hook as an ISO during runtime (#543)
Browse files Browse the repository at this point in the history
## Description
This change adds the ability in Smee to serve Hook as an ISO file through HTTP and also patch it with all the necessary dynamic information needed to connect with a Tink Server. 

## Why is this needed
Hook recently added an [feature](tinkerbell/hook#241) to support building it as an ISO file. This change leverages that feature to be able to serve and patch Hook as an ISO file. This will be needed to be able to get Tinkerbell stack running on L3 without any Layer 2 dependency.  
 



## How Has This Been Tested?
Built a custom smee image with above changes, and tested with that image and replacing the smee deployment of Tink stack on a k3d cluster with real hardware. Set the Hardware to boot from Virtual media mount and verified that the hardware was able to boot properly.

## How are existing users impacted? What migration steps/scripts do we need?
ISO serving will not be enabled by default in the CLI and users have to explicitly set the 'iso-enabled' flag to make use of the feature. So as such the change will not have any impact on existing users.
  • Loading branch information
mergify[bot] authored Nov 14, 2024
2 parents 00ce18f + e491a35 commit 2b894eb
Show file tree
Hide file tree
Showing 6 changed files with 338 additions and 0 deletions.
7 changes: 7 additions & 0 deletions cmd/smee/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ func otelFlags(c *config, fs *flag.FlagSet) {
fs.BoolVar(&c.otel.insecure, "otel-insecure", true, "[otel] OpenTelemetry collector insecure")
}

func isoFlags(c *config, fs *flag.FlagSet) {
fs.BoolVar(&c.iso.enabled, "iso-enabled", false, "[iso] enable serving Hook as an iso")
fs.StringVar(&c.iso.url, "iso-url", "", "[iso] the url for source iso before binary patching")
fs.StringVar(&c.iso.magicString, "iso-magic-string", "", "[iso] the string pattern to match for in the source iso, if not set the default from HookOS is used")
}

func setFlags(c *config, fs *flag.FlagSet) {
fs.StringVar(&c.logLevel, "log-level", "info", "log level (debug, info)")
dhcpFlags(c, fs)
Expand All @@ -162,6 +168,7 @@ func setFlags(c *config, fs *flag.FlagSet) {
syslogFlags(c, fs)
backendFlags(c, fs)
otelFlags(c, fs)
isoFlags(c, fs)
}

func newCLI(cfg *config, fs *flag.FlagSet) *ffcli.Command {
Expand Down
13 changes: 13 additions & 0 deletions cmd/smee/flag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func TestParser(t *testing.T) {
injectMacAddress: true,
},
},
iso: isoConfig{
enabled: true,
url: "http://10.10.10.10:8787/hook.iso",
magicString: magicString,
},
logLevel: "info",
backends: dhcpBackends{
file: File{},
Expand All @@ -77,6 +82,9 @@ func TestParser(t *testing.T) {
"-dhcp-tftp-ip", "192.168.2.4",
"-dhcp-http-ipxe-binary-host", "192.168.2.4",
"-dhcp-http-ipxe-script-host", "192.168.2.4",
"-iso-enabled=true",
"-iso-magic-string", magicString,
"-iso-url", "http://10.10.10.10:8787/hook.iso",
}
cli := newCLI(&got, fs)
cli.Parse(args)
Expand All @@ -89,9 +97,11 @@ func TestParser(t *testing.T) {
cmp.AllowUnexported(dhcpConfig{}),
cmp.AllowUnexported(dhcpBackends{}),
cmp.AllowUnexported(httpIpxeScript{}),
cmp.AllowUnexported(isoConfig{}),
cmp.AllowUnexported(otelConfig{}),
cmp.AllowUnexported(urlBuilder{}),
}

if diff := cmp.Diff(want, got, opts); diff != "" {
t.Fatal(diff)
}
Expand Down Expand Up @@ -143,6 +153,9 @@ FLAGS
-tink-server-insecure-tls [http] use insecure TLS for Tink server (default "false")
-tink-server-tls [http] use TLS for Tink server (default "false")
-trusted-proxies [http] comma separated list of trusted proxies in CIDR notation
-iso-enabled [iso] enable serving Hook as an iso (default "false")
-iso-magic-string [iso] the string pattern to match for in the source iso, if not set the default from HookOS is used
-iso-url [iso] the url for source iso before binary patching
-otel-endpoint [otel] OpenTelemetry collector endpoint
-otel-insecure [otel] OpenTelemetry collector insecure (default "true")
-syslog-addr [syslog] local IP to listen on for Syslog messages (default "%[1]v")
Expand Down
38 changes: 38 additions & 0 deletions cmd/smee/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/tinkerbell/smee/internal/dhcp/server"
"github.com/tinkerbell/smee/internal/ipxe/http"
"github.com/tinkerbell/smee/internal/ipxe/script"
"github.com/tinkerbell/smee/internal/iso"
"github.com/tinkerbell/smee/internal/metric"
"github.com/tinkerbell/smee/internal/otel"
"github.com/tinkerbell/smee/internal/syslog"
Expand All @@ -47,6 +48,9 @@ const (
dhcpModeProxy dhcpMode = "proxy"
dhcpModeReservation dhcpMode = "reservation"
dhcpModeAutoProxy dhcpMode = "auto-proxy"
// magicString comes from the HookOS repo
// ref: /~https://github.com/tinkerbell/hook/blob/main/linuxkit-templates/hook.template.yaml
magicString = `464vn90e7rbj08xbwdjejmdf4it17c5zfzjyfhthbh19eij201hjgit021bmpdb9ctrc87x2ymc8e7icu4ffi15x1hah9iyaiz38ckyap8hwx2vt5rm44ixv4hau8iw718q5yd019um5dt2xpqqa2rjtdypzr5v1gun8un110hhwp8cex7pqrh2ivh0ynpm4zkkwc8wcn367zyethzy7q8hzudyeyzx3cgmxqbkh825gcak7kxzjbgjajwizryv7ec1xm2h0hh7pz29qmvtgfjj1vphpgq1zcbiiehv52wrjy9yq473d9t1rvryy6929nk435hfx55du3ih05kn5tju3vijreru1p6knc988d4gfdz28eragvryq5x8aibe5trxd0t6t7jwxkde34v6pj1khmp50k6qqj3nzgcfzabtgqkmeqhdedbvwf3byfdma4nkv3rcxugaj2d0ru30pa2fqadjqrtjnv8bu52xzxv7irbhyvygygxu1nt5z4fh9w1vwbdcmagep26d298zknykf2e88kumt59ab7nq79d8amnhhvbexgh48e8qc61vq2e9qkihzt1twk1ijfgw70nwizai15iqyted2dt9gfmf2gg7amzufre79hwqkddc1cd935ywacnkrnak6r7xzcz7zbmq3kt04u2hg1iuupid8rt4nyrju51e6uejb2ruu36g9aibmz3hnmvazptu8x5tyxk820g2cdpxjdij766bt2n3djur7v623a2v44juyfgz80ekgfb9hkibpxh3zgknw8a34t4jifhf116x15cei9hwch0fye3xyq0acuym8uhitu5evc4rag3ui0fny3qg4kju7zkfyy8hwh537urd5uixkzwu5bdvafz4jmv7imypj543xg5em8jk8cgk7c4504xdd5e4e71ihaumt6u5u2t1w7um92fepzae8p0vq93wdrd1756npu1pziiur1payc7kmdwyxg3hj5n4phxbc29x0tcddamjrwt260b0w`
)

type config struct {
Expand All @@ -55,6 +59,7 @@ type config struct {
ipxeHTTPBinary ipxeHTTPBinary
ipxeHTTPScript ipxeHTTPScript
dhcp dhcpConfig
iso isoConfig

// loglevel is the log level for smee.
logLevel string
Expand Down Expand Up @@ -137,6 +142,12 @@ type otelConfig struct {
insecure bool
}

type isoConfig struct {
enabled bool
url string
magicString string
}

func main() {
cfg := &config{}
cli := newCLI(cfg, flag.NewFlagSet(name, flag.ExitOnError))
Expand Down Expand Up @@ -239,6 +250,33 @@ func main() {
handlers["/"] = jh.HandlerFunc()
}

if cfg.iso.enabled {
br, err := cfg.backend(ctx, log)
if err != nil {
panic(fmt.Errorf("failed to create backend: %w", err))
}
ih := iso.Handler{
Logger: log,
Backend: br,
SourceISO: cfg.iso.url,
ExtraKernelParams: strings.Split(cfg.ipxeHTTPScript.extraKernelArgs, " "),
Syslog: cfg.dhcp.syslogIP,
TinkServerTLS: cfg.ipxeHTTPScript.tinkServerUseTLS,
TinkServerGRPCAddr: cfg.ipxeHTTPScript.tinkServer,
MagicString: func() string {
if cfg.iso.magicString == "" {
return magicString
}
return cfg.iso.magicString
}(),
}
isoHandler, err := ih.Reverse()
if err != nil {
panic(fmt.Errorf("failed to create iso handler: %w", err))
}
handlers["/iso/"] = isoHandler
}

if len(handlers) > 0 {
// start the http server for ipxe binaries and scripts
tp := parseTrustedProxies(cfg.ipxeHTTPScript.trustedProxies)
Expand Down
15 changes: 15 additions & 0 deletions internal/backend/kube/error.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
package kube

import (
"net/http"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type hardwareNotFoundError struct{}

func (hardwareNotFoundError) NotFound() bool { return true }

func (hardwareNotFoundError) Error() string { return "hardware not found" }

// Status() implements the APIStatus interface from apimachinery/pkg/api/errors
// so that IsNotFound function could be used against this error type.
func (hardwareNotFoundError) Status() metav1.Status {
return metav1.Status{
Reason: metav1.StatusReasonNotFound,
Code: http.StatusNotFound,
}
}
226 changes: 226 additions & 0 deletions internal/iso/iso.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package iso

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"path"
"path/filepath"
"strings"

apierrors "k8s.io/apimachinery/pkg/api/errors"

"github.com/go-logr/logr"
"github.com/tinkerbell/smee/internal/dhcp/handler"
)

const (
defaultConsoles = "console=ttyS1 console=ttyS1 console=ttyS0 console=ttyAMA0 console=ttyS1 console=tty0"
)

type Handler struct {
Logger logr.Logger
Backend handler.BackendReader
// SourceISO is the source url where the unmodified iso lives
// patch this at runtime, should be a HTTP(S) url.
SourceISO string
ExtraKernelParams []string
Syslog string
TinkServerTLS bool
TinkServerGRPCAddr string
// parsedURL derives a url.URL from the SourceISO
// It helps accessing different parts of URL
parsedURL *url.URL
// MagicString is the string pattern that will be matched
// in the source iso before patching. The field can be set
// during build time by setting this field.
// Ref: /~https://github.com/tinkerbell/hook/blob/main/linuxkit-templates/hook.template.yaml
MagicString string
}

func (h *Handler) RoundTrip(req *http.Request) (*http.Response, error) {
h.Logger.V(1).Info("entered the roundtrip func")
if req.Method != http.MethodHead && req.Method != http.MethodGet {
return &http.Response{
Status: fmt.Sprintf("%d %s", http.StatusNotImplemented, http.StatusText(http.StatusNotImplemented)),
StatusCode: http.StatusNotImplemented,
Body: http.NoBody,
Request: req,
}, nil
}

if filepath.Ext(req.URL.Path) != ".iso" {
h.Logger.Info("Extension not supported, only supported type is '.iso'", "path", req.URL.Path)
return &http.Response{
Status: fmt.Sprintf("%d %s", http.StatusNotFound, http.StatusText(http.StatusNotFound)),
StatusCode: http.StatusNotFound,
Body: http.NoBody,
Request: req,
}, nil
}

ctx := req.Context()
// The incoming request url is expected to have the mac address present.
// Fetch the mac and validate if there's a hardware object
// associated with the mac.
//
// We serve the iso only if this validation passes.
ha, err := getMAC(req.URL.Path)
if err != nil {
h.Logger.Info("unable to get the mac address", "error", err)
return &http.Response{
Status: "400 BAD REQUEST",
StatusCode: http.StatusBadRequest,
Body: http.NoBody,
Request: req,
}, nil
}

f, err := getFacility(ctx, ha, h.Backend)
if err != nil {
h.Logger.V(1).Info("unable to get facility", "mac", ha, "error", err)
if apierrors.IsNotFound(err) {
return &http.Response{
Status: fmt.Sprintf("%d %s", http.StatusNotFound, http.StatusText(http.StatusNotFound)),
StatusCode: http.StatusNotFound,
Body: http.NoBody,
Request: req,
}, nil
}
return &http.Response{
Status: fmt.Sprintf("%d %s", http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)),
StatusCode: http.StatusInternalServerError,
Body: http.NoBody,
Request: req,
}, nil
}

// The hardware object doesn't contain a specific field for consoles
// right now facility is used instead.
var consoles string
switch {
case f != "" && strings.Contains(f, "console="):
consoles = f
case f != "":
consoles = fmt.Sprintf("%s %s", f, defaultConsoles)
default:
consoles = defaultConsoles
}

// Reverse Proxy modifies the request url to
// the same path it received the incoming request.
// mac-id is added to the url path to do hardware lookups using the backend reader
// and is not used when making http calls to the source url.
req.URL.Path = h.parsedURL.Path

// RoundTripper needs a Transport to execute a HTTP transaction
// For our use case the default transport will suffice.
resp, err := http.DefaultTransport.RoundTrip(req)
// resp, err := h.RoundTripper.RoundTrip(req)
if err != nil {
h.Logger.Info("HTTP request didn't receive a response", "sourceIso", h.SourceISO, "error", err)
return nil, err
}

if req.Method == http.MethodHead {
// Fuse client typically make a HEAD request before they start requesting content.
h.Logger.V(1).Info("HTTP HEAD request received, patching only occurs on 206 requests")
return resp, nil
}

// roundtripper should only return error when no response from the server
// for any other case just log the error and return 404 response
if resp.StatusCode == http.StatusPartialContent {
b, err := io.ReadAll(resp.Body)
if err != nil {
h.Logger.Error(err, "reading response bytes", "response", resp.Body)
return &http.Response{
Status: fmt.Sprintf("%d %s", http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)),
StatusCode: http.StatusInternalServerError,
Body: http.NoBody,
Request: req,
}, nil
}
if err := resp.Body.Close(); err != nil {
h.Logger.Error(err, "closing response body", "response", resp.Body)
return &http.Response{
Status: fmt.Sprintf("%d %s", http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)),
StatusCode: http.StatusInternalServerError,
Body: http.NoBody,
Request: req,
}, nil
}

magicStringPadding := bytes.Repeat([]byte{' '}, len(h.MagicString))

// TODO: revisit later to handle the magic string potentially being spread across two chunks.
// In current implementation we will never patch the above case. Add logic to patch the case of
// magic string spread across multiple response bodies in the future.
i := bytes.Index(b, []byte(h.MagicString))
if i != -1 {
h.Logger.Info("Magic string found, patching the iso at runtime")
dup := make([]byte, len(b))
copy(dup, b)
copy(dup[i:], magicStringPadding)
copy(dup[i:], []byte(h.constructPatch(fmt.Sprintf("facility=%s", consoles), ha.String())))
b = dup
}

resp.Body = io.NopCloser(bytes.NewReader(b))
}

h.Logger.Info("roundtrip complete")
return resp, nil
}

func (h *Handler) Reverse() (http.HandlerFunc, error) {
target, err := url.Parse(h.SourceISO)
if err != nil {
return nil, err
}
h.parsedURL = target
proxy := httputil.NewSingleHostReverseProxy(target)

proxy.Transport = h
proxy.FlushInterval = -1

return proxy.ServeHTTP, nil
}

func (h *Handler) constructPatch(console, mac string) string {
syslogHost := fmt.Sprintf("syslog_host=%s", h.Syslog)
grpcAuthority := fmt.Sprintf("grpc_authority=%s", h.TinkServerGRPCAddr)
tinkerbellTLS := fmt.Sprintf("tinkerbell_tls=%v", h.TinkServerTLS)
workerID := fmt.Sprintf("worker_id=%s", mac)

return strings.Join([]string{strings.Join(h.ExtraKernelParams, " "), console, syslogHost, grpcAuthority, tinkerbellTLS, workerID}, " ")
}

func getMAC(urlPath string) (net.HardwareAddr, error) {
mac := path.Base(path.Dir(urlPath))
hw, err := net.ParseMAC(mac)
if err != nil {
return nil, fmt.Errorf("failed to parse URL path: %s , the second to last element in the URL path must be a valid mac address, err: %w", urlPath, err)
}

return hw, nil
}

func getFacility(ctx context.Context, mac net.HardwareAddr, br handler.BackendReader) (string, error) {
if br == nil {
return "", errors.New("backend is nil")
}

_, n, err := br.GetByMac(ctx, mac)
if err != nil {
return "", err
}

return n.Facility, nil
}
Loading

0 comments on commit 2b894eb

Please sign in to comment.