Skip to content

Commit 2b0dc71

Browse files
committed
feat: add build-web-bot-auth CLI command for Web Bot Auth extension
Add a new `kernel extensions build-web-bot-auth` command that: - Downloads Cloudflare's web-bot-auth browser extension from GitHub - Builds it with a configurable Ed25519 signing key (defaults to RFC9421 test key) - Optionally uploads the built extension to Kernel Also adds a test script (scripts/test-web-bot-auth.ts) for verifying the extension works against Cloudflare's test site.
1 parent 5d277a1 commit 2b0dc71

File tree

2 files changed

+399
-0
lines changed

2 files changed

+399
-0
lines changed

cmd/extensions.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package cmd
22

33
import (
4+
"archive/tar"
45
"bytes"
6+
"compress/gzip"
57
"context"
8+
"encoding/json"
69
"fmt"
710
"io"
811
"net/http"
912
"os"
13+
"os/exec"
1014
"path/filepath"
15+
"strings"
1116
"time"
1217

1318
"github.com/onkernel/cli/pkg/util"
@@ -49,6 +54,13 @@ type ExtensionsUploadInput struct {
4954
Name string
5055
}
5156

57+
type ExtensionsBuildWebBotAuthInput struct {
58+
Output string
59+
KeyFile string
60+
Upload bool
61+
Name string
62+
}
63+
5264
// ExtensionsCmd handles extension operations independent of cobra.
5365
type ExtensionsCmd struct {
5466
extensions ExtensionsService
@@ -307,6 +319,233 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err
307319
return nil
308320
}
309321

322+
// RFC9421 test key for Cloudflare's test site
323+
const defaultWebBotAuthKey = `{"kty":"OKP","crv":"Ed25519","d":"n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU","x":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}`
324+
325+
func (e ExtensionsCmd) BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWebBotAuthInput) error {
326+
if in.Output == "" {
327+
return fmt.Errorf("missing --to output directory")
328+
}
329+
330+
// Check npm is available
331+
if _, err := exec.LookPath("npm"); err != nil {
332+
return fmt.Errorf("npm is required but not found in PATH. Please install Node.js and npm")
333+
}
334+
335+
// Resolve output directory
336+
outDir, err := filepath.Abs(in.Output)
337+
if err != nil {
338+
return fmt.Errorf("failed to resolve output path: %w", err)
339+
}
340+
341+
// Ensure output directory exists and is empty
342+
if st, err := os.Stat(outDir); err == nil {
343+
if !st.IsDir() {
344+
return fmt.Errorf("output path exists and is not a directory: %s", outDir)
345+
}
346+
entries, _ := os.ReadDir(outDir)
347+
if len(entries) > 0 {
348+
return fmt.Errorf("output directory must be empty: %s", outDir)
349+
}
350+
} else {
351+
if err := os.MkdirAll(outDir, 0o755); err != nil {
352+
return fmt.Errorf("failed to create output directory: %w", err)
353+
}
354+
}
355+
356+
// Determine the signing key to use
357+
var keyJSON string
358+
if in.KeyFile != "" {
359+
keyData, err := os.ReadFile(in.KeyFile)
360+
if err != nil {
361+
return fmt.Errorf("failed to read key file: %w", err)
362+
}
363+
// Validate it's valid JSON
364+
var keyObj map[string]interface{}
365+
if err := json.Unmarshal(keyData, &keyObj); err != nil {
366+
return fmt.Errorf("key file is not valid JSON: %w", err)
367+
}
368+
keyJSON = string(keyData)
369+
pterm.Info.Printf("Using signing key from: %s\n", in.KeyFile)
370+
} else {
371+
keyJSON = defaultWebBotAuthKey
372+
pterm.Info.Println("Using default RFC9421 test key (for Cloudflare's test site)")
373+
}
374+
375+
// Create temp directory for building
376+
tmpDir, err := os.MkdirTemp("", "kernel-web-bot-auth-*")
377+
if err != nil {
378+
return fmt.Errorf("failed to create temp directory: %w", err)
379+
}
380+
defer os.RemoveAll(tmpDir)
381+
382+
// Download web-bot-auth repo tarball
383+
pterm.Info.Println("Downloading web-bot-auth from GitHub...")
384+
tarballURL := "https://github.com/cloudflare/web-bot-auth/archive/refs/heads/main.tar.gz"
385+
resp, err := http.Get(tarballURL)
386+
if err != nil {
387+
return fmt.Errorf("failed to download web-bot-auth: %w", err)
388+
}
389+
defer resp.Body.Close()
390+
if resp.StatusCode != http.StatusOK {
391+
return fmt.Errorf("failed to download web-bot-auth: HTTP %d", resp.StatusCode)
392+
}
393+
394+
// Extract tarball
395+
pterm.Info.Println("Extracting...")
396+
if err := extractTarGz(resp.Body, tmpDir); err != nil {
397+
return fmt.Errorf("failed to extract tarball: %w", err)
398+
}
399+
400+
// Find the extracted directory (it will be named web-bot-auth-main)
401+
repoDir := filepath.Join(tmpDir, "web-bot-auth-main")
402+
if _, err := os.Stat(repoDir); err != nil {
403+
return fmt.Errorf("extracted directory not found: %w", err)
404+
}
405+
406+
// Write the signing key
407+
keyDir := filepath.Join(repoDir, "examples", "rfc9421-keys")
408+
if err := os.MkdirAll(keyDir, 0o755); err != nil {
409+
return fmt.Errorf("failed to create key directory: %w", err)
410+
}
411+
keyPath := filepath.Join(keyDir, "ed25519.json")
412+
if err := os.WriteFile(keyPath, []byte(keyJSON), 0o644); err != nil {
413+
return fmt.Errorf("failed to write signing key: %w", err)
414+
}
415+
416+
// Remove package-lock.json to work around npm optional dependencies bug
417+
// See: https://github.com/npm/cli/issues/4828
418+
_ = os.Remove(filepath.Join(repoDir, "package-lock.json"))
419+
420+
// Run npm install at the repo root (workspace root) to install all dependencies including tsup
421+
pterm.Info.Println("Installing dependencies (npm install)...")
422+
npmInstall := exec.CommandContext(ctx, "npm", "install")
423+
npmInstall.Dir = repoDir
424+
npmInstall.Stdout = os.Stdout
425+
npmInstall.Stderr = os.Stderr
426+
if err := npmInstall.Run(); err != nil {
427+
return fmt.Errorf("npm install failed: %w", err)
428+
}
429+
430+
// Build the web-bot-auth package first (the browser extension depends on it)
431+
pterm.Info.Println("Building web-bot-auth package...")
432+
npmBuildPkg := exec.CommandContext(ctx, "npm", "run", "build")
433+
npmBuildPkg.Dir = repoDir
434+
npmBuildPkg.Stdout = os.Stdout
435+
npmBuildPkg.Stderr = os.Stderr
436+
if err := npmBuildPkg.Run(); err != nil {
437+
return fmt.Errorf("npm run build failed: %w", err)
438+
}
439+
440+
// Run npm run build:chrome in the browser-extension directory
441+
extDir := filepath.Join(repoDir, "examples", "browser-extension")
442+
pterm.Info.Println("Building extension (npm run build:chrome)...")
443+
npmBuild := exec.CommandContext(ctx, "npm", "run", "build:chrome")
444+
npmBuild.Dir = extDir
445+
npmBuild.Stdout = os.Stdout
446+
npmBuild.Stderr = os.Stderr
447+
if err := npmBuild.Run(); err != nil {
448+
return fmt.Errorf("npm run build:chrome failed: %w", err)
449+
}
450+
451+
// Copy built extension to output directory
452+
builtDir := filepath.Join(extDir, "dist", "mv3", "chromium")
453+
manifestSrc := filepath.Join(extDir, "platform", "mv3", "chromium", "manifest.json")
454+
455+
// Copy background.mjs
456+
bgSrc := filepath.Join(builtDir, "background.mjs")
457+
if err := copyFile(bgSrc, filepath.Join(outDir, "background.mjs")); err != nil {
458+
return fmt.Errorf("failed to copy background.mjs: %w", err)
459+
}
460+
461+
// Copy manifest.json
462+
if err := copyFile(manifestSrc, filepath.Join(outDir, "manifest.json")); err != nil {
463+
return fmt.Errorf("failed to copy manifest.json: %w", err)
464+
}
465+
466+
pterm.Success.Printf("Built extension to: %s\n", outDir)
467+
468+
// Optionally upload
469+
if in.Upload {
470+
name := in.Name
471+
if name == "" {
472+
name = "web-bot-auth"
473+
}
474+
pterm.Info.Printf("Uploading extension as '%s'...\n", name)
475+
if err := e.Upload(ctx, ExtensionsUploadInput{Dir: outDir, Name: name}); err != nil {
476+
return err
477+
}
478+
}
479+
480+
return nil
481+
}
482+
483+
// extractTarGz extracts a tar.gz stream to the destination directory
484+
func extractTarGz(r io.Reader, destDir string) error {
485+
gzr, err := gzip.NewReader(r)
486+
if err != nil {
487+
return err
488+
}
489+
defer gzr.Close()
490+
491+
tr := tar.NewReader(gzr)
492+
for {
493+
header, err := tr.Next()
494+
if err == io.EOF {
495+
break
496+
}
497+
if err != nil {
498+
return err
499+
}
500+
501+
target := filepath.Join(destDir, header.Name)
502+
503+
// Protect against directory traversal
504+
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
505+
return fmt.Errorf("illegal file path: %s", header.Name)
506+
}
507+
508+
switch header.Typeflag {
509+
case tar.TypeDir:
510+
if err := os.MkdirAll(target, 0o755); err != nil {
511+
return err
512+
}
513+
case tar.TypeReg:
514+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
515+
return err
516+
}
517+
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
518+
if err != nil {
519+
return err
520+
}
521+
if _, err := io.Copy(f, tr); err != nil {
522+
f.Close()
523+
return err
524+
}
525+
f.Close()
526+
}
527+
}
528+
return nil
529+
}
530+
531+
// copyFile copies a file from src to dst
532+
func copyFile(src, dst string) error {
533+
srcFile, err := os.Open(src)
534+
if err != nil {
535+
return err
536+
}
537+
defer srcFile.Close()
538+
539+
dstFile, err := os.Create(dst)
540+
if err != nil {
541+
return err
542+
}
543+
defer dstFile.Close()
544+
545+
_, err = io.Copy(dstFile, srcFile)
546+
return err
547+
}
548+
310549
// --- Cobra wiring ---
311550

312551
var extensionsCmd = &cobra.Command{
@@ -381,16 +620,63 @@ var extensionsUploadCmd = &cobra.Command{
381620
},
382621
}
383622

623+
var extensionsBuildWebBotAuthCmd = &cobra.Command{
624+
Use: "build-web-bot-auth",
625+
Short: "Build Cloudflare's Web Bot Auth browser extension",
626+
Long: `Build the Web Bot Auth browser extension for signing HTTP requests.
627+
628+
This command downloads and builds Cloudflare's web-bot-auth browser extension,
629+
which adds RFC 9421 HTTP Message Signatures to all outgoing requests.
630+
631+
By default, it uses the RFC9421 test key that works with Cloudflare's test site
632+
at https://http-message-signatures-example.research.cloudflare.com/
633+
634+
To use your own signing key, provide a JWK file with --key.
635+
636+
Examples:
637+
# Build with default test key
638+
kernel extensions build-web-bot-auth --to ./web-bot-auth-ext
639+
640+
# Build with custom key
641+
kernel extensions build-web-bot-auth --to ./web-bot-auth-ext --key ./my-key.jwk
642+
643+
# Build and upload to Kernel
644+
kernel extensions build-web-bot-auth --to ./web-bot-auth-ext --upload --name my-web-bot-auth`,
645+
Args: cobra.NoArgs,
646+
RunE: func(cmd *cobra.Command, args []string) error {
647+
client := getKernelClient(cmd)
648+
output, _ := cmd.Flags().GetString("to")
649+
keyFile, _ := cmd.Flags().GetString("key")
650+
upload, _ := cmd.Flags().GetBool("upload")
651+
name, _ := cmd.Flags().GetString("name")
652+
svc := client.Extensions
653+
e := ExtensionsCmd{extensions: &svc}
654+
return e.BuildWebBotAuth(cmd.Context(), ExtensionsBuildWebBotAuthInput{
655+
Output: output,
656+
KeyFile: keyFile,
657+
Upload: upload,
658+
Name: name,
659+
})
660+
},
661+
}
662+
384663
func init() {
385664
extensionsCmd.AddCommand(extensionsListCmd)
386665
extensionsCmd.AddCommand(extensionsDeleteCmd)
387666
extensionsCmd.AddCommand(extensionsDownloadCmd)
388667
extensionsCmd.AddCommand(extensionsDownloadWebStoreCmd)
389668
extensionsCmd.AddCommand(extensionsUploadCmd)
669+
extensionsCmd.AddCommand(extensionsBuildWebBotAuthCmd)
390670

391671
extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
392672
extensionsDownloadCmd.Flags().String("to", "", "Output zip file path")
393673
extensionsDownloadWebStoreCmd.Flags().String("to", "", "Output zip file path for the downloaded archive")
394674
extensionsDownloadWebStoreCmd.Flags().String("os", "", "Target OS: mac, win, or linux (default linux)")
395675
extensionsUploadCmd.Flags().String("name", "", "Optional unique extension name")
676+
677+
extensionsBuildWebBotAuthCmd.Flags().String("to", "", "Output directory for the built extension (required)")
678+
extensionsBuildWebBotAuthCmd.Flags().String("key", "", "Path to JWK file with Ed25519 signing key (defaults to RFC9421 test key)")
679+
extensionsBuildWebBotAuthCmd.Flags().Bool("upload", false, "Upload the extension to Kernel after building")
680+
extensionsBuildWebBotAuthCmd.Flags().String("name", "web-bot-auth", "Extension name when uploading")
681+
_ = extensionsBuildWebBotAuthCmd.MarkFlagRequired("to")
396682
}

0 commit comments

Comments
 (0)