Skip to content
Open
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
17 changes: 16 additions & 1 deletion cmd/obol/sell_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,22 @@ func applySellerProfile(cfg *config.Config, profile schemas.StorefrontProfile) e
if err != nil {
return err
}
if err := kubectlApply(cfg, manifest); err != nil {
raw, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("marshal storefront profile: %w", err)
}
bin, kubeconfig := kubectl.Paths(cfg)
// Inline data:image values can make kubectl's client-side
// last-applied-configuration annotation exceed its 256 KiB limit even
// though the ConfigMap itself remains below Kubernetes' 1 MiB limit.
// The host-side profile record is authoritative, so use server-side apply
// for both initial writes and updates.
if err := kubectl.ApplyServerSideForceConflicts(
bin,
kubeconfig,
raw,
"obol-storefront-profile",
); err != nil {
return fmt.Errorf("apply storefront profile: %w", err)
}
return nil
Expand Down
37 changes: 37 additions & 0 deletions cmd/obol/sell_info_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,49 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/schemas"
)

func TestApplySellerProfileUsesServerSideApplyForInlineImages(t *testing.T) {
cfg := &config.Config{ConfigDir: t.TempDir(), BinDir: t.TempDir()}
argsPath := filepath.Join(t.TempDir(), "kubectl-args")
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %q\ncat >/dev/null\n", argsPath)
if err := os.WriteFile(filepath.Join(cfg.BinDir, "kubectl"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}

profile := schemas.StorefrontProfile{
LogoURL: "data:image/png;base64," + strings.Repeat("a", 270_000),
}
if err := applySellerProfile(cfg, profile); err != nil {
t.Fatalf("applySellerProfile: %v", err)
}

args, err := os.ReadFile(argsPath)
if err != nil {
t.Fatal(err)
}
got := string(args)
for _, want := range []string{
"apply\n",
"--server-side\n",
"--force-conflicts\n",
"--field-manager=obol-storefront-profile\n",
"-f\n-\n",
} {
if !strings.Contains(got, want) {
t.Fatalf("kubectl args missing %q:\n%s", want, got)
}
}
}

func TestClearProfileFields(t *testing.T) {
base := schemas.StorefrontProfile{
DisplayName: "Acme",
Expand Down
9 changes: 8 additions & 1 deletion internal/stack/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ func Up(cfg *config.Config, u *ui.UI, wildcardDNS bool) error {
// Ensure the base host before syncing defaults. Include existing agent
// hostnames so stack up never shrinks the managed /etc/hosts block to only
// obol.stack when default setup is skipped.
if err := dns.EnsureHostsEntries(agentruntime.CollectHostnames(cfg)); err != nil {
hostnames := append(agentruntime.CollectHostnames(cfg), tunnel.StorefrontPreviewHostname)
if err := dns.EnsureHostsEntries(hostnames); err != nil {
u.Warnf("Could not update /etc/hosts for obol.stack: %v", err)
}

Expand Down Expand Up @@ -588,6 +589,12 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s
u.Dim(" You can manually apply later with: obol agent init")
}

// Local-only storefront preview must exist even when the public tunnel is
// dormant — the operator branding editor iframes storefront-preview.obol.stack.
if err := tunnel.EnsureLocalStorefrontPreview(cfg); err != nil {
u.Warnf("Could not publish local storefront preview: %v", err)
}

// Start the Cloudflare tunnel only if a persistent DNS tunnel is provisioned.
// Quick tunnels are dormant by default and activate on first `obol sell`.
u.Blank()
Expand Down
12 changes: 11 additions & 1 deletion internal/storefront/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,17 @@ func ReconcileRecorded(cfg *config.Config, u *ui.UI) {
}

bin, kubeconfig := kubectl.Paths(cfg)
if err := kubectl.Apply(bin, kubeconfig, payload); err != nil {
// Profiles may contain inline data:image URIs. Client-side apply mirrors
// the complete manifest into last-applied-configuration, and that annotation
// has a 256 KiB hard limit even though the ConfigMap itself allows 1 MiB.
// The host record is authoritative, so server-side ownership is appropriate
// and keeps large valid profiles replayable after stack recreation.
if err := kubectl.ApplyServerSideForceConflicts(
bin,
kubeconfig,
payload,
"obol-storefront-profile",
); err != nil {
u.Warnf("Could not reconcile recorded storefront profile: %v", err)
return
}
Expand Down
136 changes: 113 additions & 23 deletions internal/tunnel/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/ObolNetwork/obol-stack/internal/agentruntime"
"github.com/ObolNetwork/obol-stack/internal/config"
stackdefaults "github.com/ObolNetwork/obol-stack/internal/defaults"
"github.com/ObolNetwork/obol-stack/internal/images"
"github.com/ObolNetwork/obol-stack/internal/ui"
)
Expand Down Expand Up @@ -1229,8 +1230,16 @@ func deleteLocalCloudflareTunnel(u *ui.UI, tunnelName, tunnelID string) error {
return nil
}

// storefrontNamespace is where the storefront landing page resources live.
const storefrontNamespace = "traefik"
// Storefront resources live beside the Gateway. The preview hostname is
// deliberately local-only: unlike tunnel hostnames it is never written into
// cloudflared ingress configuration, so the operator dashboard can frame the
// real renderer without bringing the public tunnel into its trust boundary.
const (
storefrontNamespace = "traefik"
// StorefrontPreviewHostname is the local-only operator preview origin.
// It is never written into cloudflared ingress configuration.
StorefrontPreviewHostname = "storefront-preview.obol.stack"
)

// storefrontHostnames returns the hostnames the public storefront should be
// published on: the full tracked set for a persistent tunnel, else the host
Expand Down Expand Up @@ -1276,6 +1285,63 @@ func offerBoundHostnames(kubectlPath, kubeconfigPath string) (map[string]bool, e
return bound, nil
}

func buildStorefrontPreviewHTTPRoute() map[string]any {
return map[string]any{
"apiVersion": "gateway.networking.k8s.io/v1",
"kind": "HTTPRoute",
"metadata": map[string]any{
"name": "storefront-preview",
"namespace": storefrontNamespace,
},
"spec": map[string]any{
"hostnames": []string{StorefrontPreviewHostname},
"parentRefs": []map[string]any{
{
"name": "traefik-gateway",
"namespace": "traefik",
"sectionName": "web",
},
},
"rules": []map[string]any{
{
"matches": []map[string]any{
{"path": map[string]string{"type": "PathPrefix", "value": "/"}},
},
"filters": []map[string]any{
{
"type": "ResponseHeaderModifier",
"responseHeaderModifier": map[string]any{
"set": []map[string]string{
{
"name": "Content-Security-Policy",
"value": "frame-ancestors 'self' http://obol.stack:* https://obol.stack:*",
},
{
"name": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=(), payment=()",
},
{"name": "X-Content-Type-Options", "value": "nosniff"},
},
},
},
},
"backendRefs": []map[string]any{
{"name": "tunnel-storefront", "port": 3000},
},
},
},
},
}
}

func storefrontImage(cfg *config.Config) string {
const repo = "ghcr.io/obolnetwork/obol-stack-public-storefront"
if strings.EqualFold(strings.TrimSpace(os.Getenv("OBOL_DEVELOPMENT")), "true") {
return images.ResolveDev(repo, stackdefaults.ReadDevImageTag(cfg))
}
return images.Resolve(repo)
}

// CreateStorefront creates (or updates) the public storefront landing page and
// publishes it at the root path of EVERY supplied hostname. Each argument may be
// a bare hostname or a full URL (scheme/path stripped); empty or duplicate
Expand Down Expand Up @@ -1315,22 +1381,41 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error {
}
hosts = kept
if len(hosts) == 0 {
// Every tracked hostname is offer-bound: the storefront has
// nothing left to serve at any hostname. Tear it down instead
// of leaving the previously-applied HTTPRoute (with the now-
// stale wider host list) on the cluster — Gateway API breaks
// the resulting PathPrefix-/ tie by route age, so that stale
// route would otherwise keep shadowing the offer's own
// dedicated-origin route.
return DeleteStorefront(cfg)
// Every tracked public hostname is offer-bound: remove only the
// public catch-all HTTPRoute so it cannot shadow dedicated-origin
// offer routes. Keep the renderer + local preview hostname so the
// operator dashboard can still frame a non-public copy.
cmd := exec.Command(kubectlPath,
"--kubeconfig", kubeconfigPath,
"delete", "httproute/tunnel-storefront",
"-n", storefrontNamespace,
"--ignore-not-found",
)
_ = cmd.Run()
return ensureStorefrontRenderer(cfg, nil)
}
}

return ensureStorefrontRenderer(cfg, hosts)
}

// EnsureLocalStorefrontPreview publishes the local-only storefront renderer
// (Deployment + Service + storefront-preview.obol.stack HTTPRoute) without
// requiring a public tunnel hostname. Safe to call on every stack up so the
// operator branding editor can iframe a non-public copy before tunnel setup.
func EnsureLocalStorefrontPreview(cfg *config.Config) error {
return ensureStorefrontRenderer(cfg, nil)
}

// ensureStorefrontRenderer applies the Next.js storefront Deployment + Service
// and the local-only preview HTTPRoute. When publicHosts is non-empty it also
// publishes the tunnel catch-all HTTPRoute for those hostnames.
func ensureStorefrontRenderer(cfg *config.Config, publicHosts []string) error {
kubectlPath := filepath.Join(cfg.BinDir, "kubectl")
kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")
labels := map[string]string{"app": "tunnel-storefront"}

// Build the resources for the public storefront.
resources := []map[string]any{
// Deployment: Next.js public storefront image.
{
"apiVersion": "apps/v1",
"kind": "Deployment",
Expand All @@ -1351,7 +1436,7 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error {
"containers": []map[string]any{
{
"name": "storefront",
"image": images.Resolve("ghcr.io/obolnetwork/obol-stack-public-storefront"),
"image": storefrontImage(cfg),
"imagePullPolicy": "IfNotPresent",
"ports": []map[string]any{
{"containerPort": 3000, "name": "http"},
Expand Down Expand Up @@ -1397,7 +1482,6 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error {
},
},
},
// Service
{
"apiVersion": "v1",
"kind": "Service",
Expand All @@ -1412,16 +1496,22 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error {
},
},
},
// HTTPRoute: tunnel hostname → storefront (more specific than frontend catch-all).
{
// Local-only preview route. Hostname scoping is the security boundary:
// cloudflared never advertises *.obol.stack, while the CSP limits who
// may frame this renderer even on the local machine.
buildStorefrontPreviewHTTPRoute(),
}

if len(publicHosts) > 0 {
resources = append(resources, map[string]any{
"apiVersion": "gateway.networking.k8s.io/v1",
"kind": "HTTPRoute",
"metadata": map[string]any{
"name": "tunnel-storefront",
"namespace": storefrontNamespace,
},
"spec": map[string]any{
"hostnames": hosts,
"hostnames": publicHosts,
"parentRefs": []map[string]any{
{
"name": "traefik-gateway",
Expand All @@ -1443,10 +1533,9 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error {
},
},
},
},
})
}

// Apply each resource via kubectl apply.
for _, res := range resources {
data, err := json.Marshal(res)
if err != nil {
Expand All @@ -1468,7 +1557,10 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error {
return nil
}

// DeleteStorefront removes the storefront landing page resources.
// DeleteStorefront removes the public storefront catch-all HTTPRoute while
// keeping the local-only preview renderer (Deployment/Service +
// storefront-preview.obol.stack). The operator branding editor must keep
// working when the tunnel is dormant or the last offer is deleted.
func DeleteStorefront(cfg *config.Config) error {
kubectlPath := filepath.Join(cfg.BinDir, "kubectl")

Expand All @@ -1479,8 +1571,6 @@ func DeleteStorefront(cfg *config.Config) error {

for _, resource := range []string{
"httproute/tunnel-storefront",
"service/tunnel-storefront",
"deployment/tunnel-storefront",
"configmap/tunnel-storefront",
} {
cmd := exec.Command(kubectlPath,
Expand All @@ -1492,7 +1582,7 @@ func DeleteStorefront(cfg *config.Config) error {
_ = cmd.Run() // best-effort cleanup
}

return nil
return EnsureLocalStorefrontPreview(cfg)
}

func parseQuickTunnelURL(logs string) (string, bool) {
Expand Down
Loading
Loading