diff --git a/cmd/portal-tunnel/README.md b/cmd/portal-tunnel/README.md index 5da806a5..876dcfd6 100644 --- a/cmd/portal-tunnel/README.md +++ b/cmd/portal-tunnel/README.md @@ -159,6 +159,8 @@ Common `portal expose` flags: --description Service description metadata --tags Service tags metadata, comma-separated --thumbnail Service thumbnail URL metadata +--thumbnail-from-target Use the target's advertised image when --thumbnail is empty + (og:image, then twitter:image, then an icon link; absolute URLs only) --owner Service owner metadata --hide Hide service from relay listing screens --serve Serve a local static site: a directory (served with index.html) or an HTML file (folder served with that file as SPA/CSR entry) diff --git a/cmd/portal-tunnel/agent/config.go b/cmd/portal-tunnel/agent/config.go index 90ff1f8e..a2503093 100644 --- a/cmd/portal-tunnel/agent/config.go +++ b/cmd/portal-tunnel/agent/config.go @@ -59,6 +59,7 @@ type TunnelConfig struct { Tags []string `koanf:"tags"` Owner string `koanf:"owner"` Thumbnail string `koanf:"thumbnail"` + ThumbnailFromTarget bool `koanf:"thumbnail_from_target"` Hide bool `koanf:"hide"` X402PayTo string `koanf:"x402_pay_to"` X402Testnet bool `koanf:"x402_testnet"` @@ -222,6 +223,9 @@ func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any { addStringSliceDocumentField(out, "tags", cfg.Tags) addStringDocumentField(out, "owner", cfg.Owner) addStringDocumentField(out, "thumbnail", cfg.Thumbnail) + if cfg.ThumbnailFromTarget { + out["thumbnail_from_target"] = cfg.ThumbnailFromTarget + } if cfg.Hide { out["hide"] = cfg.Hide } diff --git a/cmd/portal-tunnel/agent/manager.go b/cmd/portal-tunnel/agent/manager.go index b814b703..4695950f 100644 --- a/cmd/portal-tunnel/agent/manager.go +++ b/cmd/portal-tunnel/agent/manager.go @@ -13,6 +13,7 @@ import ( "github.com/rs/zerolog/log" + "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/thumbnail" "github.com/gosuda/portal-tunnel/v2/sdk" "github.com/gosuda/portal-tunnel/v2/types" "github.com/gosuda/portal-tunnel/v2/utils" @@ -479,6 +480,12 @@ type managedTunnel struct { exposure *sdk.Exposure lastError string runtime types.AgentTunnelStatus + + // discoveredThumbnail is what --thumbnail-from-target found at startup. + // cfg.Thumbnail stays as configured, so without keeping this the next + // metadata update would rebuild metadata from cfg and erase the value, and + // Snapshot would report the tunnel as having no thumbnail at all. + discoveredThumbnail string } func newTunnel(cfg TunnelConfig) *managedTunnel { @@ -567,7 +574,7 @@ func (t *managedTunnel) UpdateSettings(updateMetadata, updateMaxActiveRelays boo } var err error if updateMetadata { - err = errors.Join(err, exposure.UpdateMetadata(metadataFromTunnelConfig(cfg))) + err = errors.Join(err, exposure.UpdateMetadata(t.metadata(cfg))) } if updateMaxActiveRelays { err = errors.Join(err, exposure.UpdateMaxActiveRelays(cfg.MaxActiveRelays)) @@ -616,7 +623,7 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus { Discovery: discovery, MaxActiveRelays: cfg.MaxActiveRelays, ECH: cfg.ECH, - Metadata: metadataFromTunnelConfig(cfg), + Metadata: t.metadata(cfg), MultiHop: append([]string(nil), cfg.MultiHop...), X402PayTo: strings.TrimSpace(cfg.X402PayTo), X402Testnet: cfg.X402Testnet, @@ -709,6 +716,15 @@ func (t *managedTunnel) runOnce(ctx context.Context) error { if x402FacilitatorToken == "" { x402FacilitatorToken = strings.TrimSpace(os.Getenv("CSPR_CLOUD_API_KEY")) } + // Only when the tunnel starts. The metadata update and snapshot paths reuse + // metadataFromTunnelConfig and must not re-read the target every time. + exposeMetadata := metadataFromTunnelConfig(cfg) + exposeMetadata.Thumbnail = thumbnail.Resolve( + ctx, exposeMetadata.Thumbnail, cfg.TargetAddr, cfg.ThumbnailFromTarget) + t.mu.Lock() + t.discoveredThumbnail = exposeMetadata.Thumbnail + t.mu.Unlock() + exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{ RelayURLs: append([]string(nil), cfg.RelayURLs...), Discovery: discovery, @@ -724,7 +740,7 @@ func (t *managedTunnel) runOnce(ctx context.Context) error { MultiHopDepth: cfg.MultiHopDepth, BanMITM: banMITM, MaxActiveRelays: cfg.MaxActiveRelays, - Metadata: metadataFromTunnelConfig(cfg), + Metadata: exposeMetadata, X402PayTo: cfg.X402PayTo, X402Testnet: cfg.X402Testnet, X402Network: cfg.X402Network, @@ -770,6 +786,20 @@ func (t *managedTunnel) runOnce(ctx context.Context) error { return err } +// metadata is metadataFromTunnelConfig plus whatever discovery supplied, so a +// value the operator never typed survives an update that rebuilds from cfg. +// An explicit thumbnail always wins, exactly as it does at startup. +func (t *managedTunnel) metadata(cfg TunnelConfig) types.LeaseMetadata { + meta := metadataFromTunnelConfig(cfg) + if strings.TrimSpace(meta.Thumbnail) != "" { + return meta + } + t.mu.RLock() + meta.Thumbnail = t.discoveredThumbnail + t.mu.RUnlock() + return meta +} + func metadataFromTunnelConfig(cfg TunnelConfig) types.LeaseMetadata { return types.LeaseMetadata{ Description: strings.TrimSpace(cfg.Description), diff --git a/cmd/portal-tunnel/agent/metadata_test.go b/cmd/portal-tunnel/agent/metadata_test.go new file mode 100644 index 00000000..46be74ba --- /dev/null +++ b/cmd/portal-tunnel/agent/metadata_test.go @@ -0,0 +1,35 @@ +package agent + +import "testing" + +// A thumbnail found by --thumbnail-from-target is not in cfg, so anything that +// rebuilds metadata from cfg alone erases it. UpdateSettings and Snapshot both +// do exactly that, which meant the discovered image appeared once at startup +// and vanished at the first metadata update. +func TestTunnelMetadataKeepsTheDiscoveredThumbnail(t *testing.T) { + tunnel := &managedTunnel{discoveredThumbnail: "https://cdn.example.com/card.png"} + + got := tunnel.metadata(TunnelConfig{Name: "app"}).Thumbnail + if got != "https://cdn.example.com/card.png" { + t.Fatalf("thumbnail = %q, want the discovered value to survive", got) + } +} + +// An explicit value wins here for the same reason it wins at startup: the +// operator answered the question already. +func TestTunnelMetadataPrefersTheConfiguredThumbnail(t *testing.T) { + tunnel := &managedTunnel{discoveredThumbnail: "https://cdn.example.com/discovered.png"} + cfg := TunnelConfig{Name: "app", Thumbnail: "https://example.com/explicit.png"} + + if got := tunnel.metadata(cfg).Thumbnail; got != "https://example.com/explicit.png" { + t.Fatalf("thumbnail = %q, want the configured value", got) + } +} + +func TestTunnelMetadataLeavesThumbnailEmptyWithoutDiscovery(t *testing.T) { + tunnel := &managedTunnel{} + + if got := tunnel.metadata(TunnelConfig{Name: "app"}).Thumbnail; got != "" { + t.Fatalf("thumbnail = %q, want empty", got) + } +} diff --git a/cmd/portal-tunnel/main.go b/cmd/portal-tunnel/main.go index cb409320..28136161 100644 --- a/cmd/portal-tunnel/main.go +++ b/cmd/portal-tunnel/main.go @@ -18,6 +18,7 @@ import ( "github.com/rs/zerolog/log" "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer" + "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/thumbnail" "github.com/gosuda/portal-tunnel/v2/sdk" "github.com/gosuda/portal-tunnel/v2/types" "github.com/gosuda/portal-tunnel/v2/utils" @@ -59,6 +60,7 @@ type exposeFlags struct { tags string owner string thumbnail string + thumbnailFromTarget bool hide bool x402PayTo string x402Testnet bool @@ -95,6 +97,7 @@ func runExposeCommand(args []string) error { utils.StringFlag(fs, &flags.tags, "tags", "", "Service tags metadata (comma-separated)") utils.StringFlag(fs, &flags.owner, "owner", "", "Service owner metadata") utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata") + utils.BoolFlag(fs, &flags.thumbnailFromTarget, "thumbnail-from-target", false, "when --thumbnail is empty, use the first absolute image URL the target advertises (og:image, then twitter:image, then an icon link)") utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from relay listing screens") utils.StringFlag(fs, &flags.x402PayTo, "x402-pay-to", "", "Payment recipient address for this tunnel") utils.BoolFlag(fs, &flags.x402Testnet, "x402-testnet", false, "Use the testnet for x402 payments when --x402-network is omitted; default is Sui mainnet") @@ -243,8 +246,11 @@ func runExposeCommand(args []string) error { Description: flags.desc, Tags: utils.SplitCSV(flags.tags), Owner: flags.owner, - Thumbnail: flags.thumbnail, - Hide: flags.hide, + // Resolved here rather than by the SDK: how metadata.thumbnail was + // chosen is not part of the tunnelling contract, and sdk.Expose + // should receive a finished value. + Thumbnail: thumbnail.Resolve(ctx, flags.thumbnail, flags.targetAddr, flags.thumbnailFromTarget), + Hide: flags.hide, }, X402PayTo: flags.x402PayTo, X402Testnet: flags.x402Testnet, diff --git a/cmd/portal-tunnel/thumbnail/thumbnail.go b/cmd/portal-tunnel/thumbnail/thumbnail.go new file mode 100644 index 00000000..ae2e08a6 --- /dev/null +++ b/cmd/portal-tunnel/thumbnail/thumbnail.go @@ -0,0 +1,237 @@ +// Package thumbnail reads the image a tunnel's target application advertises, +// so a service does not have to be described twice: once to the app and once on +// the command line. +// +// This is metadata construction, not part of the tunnelling contract, so it +// lives here rather than in the SDK. The SDK receives a resolved +// types.LeaseMetadata and stays agnostic about how metadata.thumbnail was +// chosen; that keeps HTML fetching and parsing out of its public surface, and +// keeps this an explicit CLI action against the target the operator just named +// rather than a general-purpose capability every SDK consumer inherits. +package thumbnail + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog/log" + "golang.org/x/net/html" + + "github.com/gosuda/portal-tunnel/v2/utils" +) + +const ( + // Short on purpose: this runs before the tunnel is announced, and a card + // image must not hold startup open while a target that is not listening yet + // times out. + fetchTimeout = 3 * time.Second + htmlReadLimit = 512 << 10 +) + +// Resolve returns the thumbnail a lease should carry. +// +// An explicit value always wins, and without the opt-in the target is not +// contacted at all. Both halves are the contract with whoever runs this: they +// named a specific image, or they asked for none of this. +// +// It never fails. What was found, or why nothing was, goes to the log so the +// chosen value is visible rather than guessed at. +func Resolve(ctx context.Context, declared, targetAddr string, fromTarget bool) string { + if strings.TrimSpace(declared) != "" || !fromTarget { + return declared + } + + found, err := FromTarget(ctx, targetAddr) + switch { + case err != nil: + log.Info().Err(err).Str("target", targetAddr). + Msg("could not read a thumbnail from the target; pass --thumbnail to set one") + return "" + case found == "": + log.Info().Str("target", targetAddr). + Msg("target advertises no absolute og:image, twitter:image or icon URL; leaving the thumbnail empty") + return "" + } + log.Info().Str("thumbnail", found).Str("target", targetAddr). + Msg("using the image the target advertises as the lease thumbnail") + return found +} + +// FromTarget asks the target application which image represents it and returns +// that as an absolute URL, or an empty string when it advertises none. +// +// Only absolute http(s) URLs are taken. The Open Graph protocol calls for one, +// metadata.thumbnail is an absolute URL by contract, and accepting a relative +// reference would mean resolving it against the hostname some relay will serve +// the lease at -- coupling a card image to relay selection for a value the app +// was supposed to state outright. +// +// targetAddr must be the bare host:port the tunnel dials, and only "/" on it is +// ever requested. +func FromTarget(ctx context.Context, targetAddr string) (string, error) { + targetAddr, err := dialTarget(targetAddr) + if err != nil { + return "", err + } + + pageURL := &url.URL{Scheme: "http", Host: targetAddr, Path: "/"} + refs, err := declaredImageRefs(ctx, pageURL) + if err != nil { + return "", err + } + + for _, ref := range refs { + if absolute := absoluteImageURL(ref); absolute != "" { + return absolute, nil + } + } + return "", nil +} + +// dialTarget resolves the address exactly as sdk.Expose will. +// +// Validating it separately here looked safer and was wrong: the CLI accepts a +// port on its own, `sdk.Expose` normalizes that afterwards, and a resolver that +// insisted on host:port silently skipped discovery for `portal expose 3000`. +// Sharing the normalization also keeps the guarantees -- NormalizeTargetAddr +// rejects a URL carrying a path, query or fragment -- in one place instead of +// two that can drift. +func dialTarget(raw string) (string, error) { + addr, err := utils.NormalizeLoopbackTarget(raw) + if err != nil { + return "", fmt.Errorf("target %q is not usable: %w", raw, err) + } + if strings.TrimSpace(addr) == "" { + return "", fmt.Errorf("no target address") + } + // Rebuilt from the parsed parts rather than reused, so the request URL + // cannot carry anything but a host and port. + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "", fmt.Errorf("target %q is not a host:port address: %w", raw, err) + } + if _, err := strconv.Atoi(port); err != nil { + return "", fmt.Errorf("target %q has a non-numeric port", raw) + } + return net.JoinHostPort(host, port), nil +} + +// absoluteImageURL returns ref when it is already an absolute http(s) URL. +// +// data: and javascript: parse as absolute too, so the scheme is checked rather +// than assumed: this value ends up in an on the dashboard. +func absoluteImageURL(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + parsed, err := url.Parse(ref) + if err != nil || !parsed.IsAbs() { + return "" + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "" + } + // IsAbs only means "has a scheme". "https:card.png" and "http:/card.png" + // satisfy it with no authority at all, and a browser would resolve either + // against the dashboard rather than fetching the image the app meant. + if parsed.Host == "" { + return "" + } + return parsed.String() +} + +func declaredImageRefs(ctx context.Context, pageURL *url.URL) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/html") + + client := utils.NewHTTPClient(utils.WithHTTPTimeout(fetchTimeout)) + // The target's front page is the whole request. A redirect would send this + // somewhere the operator did not name, so it is returned as a response and + // rejected by the status check below rather than followed. + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("target returned %d", resp.StatusCode) + } + + return parseDeclaredImageRefs(io.LimitReader(resp.Body, htmlReadLimit)), nil +} + +// parseDeclaredImageRefs collects image references in preference order: +// og:image, then twitter:image, then apple-touch-icon. +func parseDeclaredImageRefs(r io.Reader) []string { + var og, twitter, icons []string + + tokenizer := html.NewTokenizer(r) + for { + switch tokenizer.Next() { + case html.ErrorToken: + return append(append(og, twitter...), icons...) + case html.StartTagToken, html.SelfClosingTagToken: + token := tokenizer.Token() + switch token.Data { + case "meta": + var property, name, content string + for _, attr := range token.Attr { + switch strings.ToLower(attr.Key) { + case "property": + property = strings.ToLower(attr.Val) + case "name": + name = strings.ToLower(attr.Val) + case "content": + content = attr.Val + } + } + if content == "" { + continue + } + switch { + case property == "og:image", property == "og:image:secure_url": + og = append(og, content) + case name == "twitter:image", name == "twitter:image:src": + twitter = append(twitter, content) + } + case "link": + var rel, href string + for _, attr := range token.Attr { + switch strings.ToLower(attr.Key) { + case "rel": + rel = strings.ToLower(attr.Val) + case "href": + href = attr.Val + } + } + if href == "" { + continue + } + for _, value := range strings.Fields(rel) { + if value == "apple-touch-icon" || value == "apple-touch-icon-precomposed" || value == "icon" { + icons = append(icons, href) + break + } + } + case "body": + // Everything worth reading lives in the head. + return append(append(og, twitter...), icons...) + } + } + } +} diff --git a/cmd/portal-tunnel/thumbnail/thumbnail_test.go b/cmd/portal-tunnel/thumbnail/thumbnail_test.go new file mode 100644 index 00000000..d6ebbb8d --- /dev/null +++ b/cmd/portal-tunnel/thumbnail/thumbnail_test.go @@ -0,0 +1,267 @@ +package thumbnail + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// targetServing returns the address of a target that answers "/" with doc. +func targetServing(t *testing.T, doc string) (addr string, requests *atomic.Int64) { + t.Helper() + requests = &atomic.Int64{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(doc)) + })) + t.Cleanup(server.Close) + return strings.TrimPrefix(server.URL, "http://"), requests +} + +const declaringDoc = ` + +` + +func TestParseDeclaredImageRefsPrefersOpenGraph(t *testing.T) { + doc := ` + + + + ` + + refs := parseDeclaredImageRefs(strings.NewReader(doc)) + + want := []string{"/og.png", "/twitter.png", "/icon.png"} + if len(refs) != len(want) { + t.Fatalf("refs = %v, want %v", refs, want) + } + for i := range want { + if refs[i] != want[i] { + t.Fatalf("refs = %v, want %v", refs, want) + } + } +} + +func TestParseDeclaredImageRefsStopsAtBody(t *testing.T) { + doc := ` + ` + + refs := parseDeclaredImageRefs(strings.NewReader(doc)) + + if len(refs) != 1 || refs[0] != "/og.png" { + t.Fatalf("refs = %v, want only the head image", refs) + } +} + +func TestFromTargetKeepsAbsoluteURL(t *testing.T) { + addr, _ := targetServing(t, declaringDoc) + + got, err := FromTarget(context.Background(), addr) + if err != nil { + t.Fatalf("from target: %v", err) + } + if got != "https://cdn.example.com/card.png" { + t.Fatalf("thumbnail = %q, want the declared absolute URL", got) + } +} + +// Open Graph calls for an absolute URL. Resolving a relative one would mean +// picking a relay and a lease hostname, which couples a card image to relay +// selection for a value the app was supposed to state outright. +func TestFromTargetSkipsRelativeReferences(t *testing.T) { + addr, _ := targetServing(t, ` + + `) + + got, err := FromTarget(context.Background(), addr) + if err != nil { + t.Fatalf("from target: %v", err) + } + if got != "" { + t.Fatalf("thumbnail = %q, want empty for a relative reference", got) + } +} + +func TestFromTargetFallsBackThroughPreferenceOrder(t *testing.T) { + addr, _ := targetServing(t, ` + + + + `) + + got, err := FromTarget(context.Background(), addr) + if err != nil { + t.Fatalf("from target: %v", err) + } + if got != "https://cdn.example.com/twitter.png" { + t.Fatalf("thumbnail = %q, want the twitter image ahead of the icon", got) + } +} + +// The value goes straight into an on the dashboard. data: and +// javascript: parse as absolute URLs and would otherwise pass an "is it +// absolute" test. +func TestFromTargetRejectsNonHTTPSchemes(t *testing.T) { + addr, _ := targetServing(t, ` + + + `) + + got, err := FromTarget(context.Background(), addr) + if err != nil { + t.Fatalf("from target: %v", err) + } + if got != "" { + t.Fatalf("thumbnail = %q, want empty", got) + } +} + +func TestFromTargetAcceptsAPageThatAdvertisesNothing(t *testing.T) { + addr, _ := targetServing(t, `plainhi`) + + got, err := FromTarget(context.Background(), addr) + if err != nil { + t.Fatalf("advertising no image is not an error: %v", err) + } + if got != "" { + t.Fatalf("thumbnail = %q, want empty", got) + } +} + +func TestFromTargetReportsAnUnreachableTarget(t *testing.T) { + if _, err := FromTarget(context.Background(), "127.0.0.1:1"); err == nil { + t.Fatal("an unreachable target returned no error") + } +} + +// The address must be the bare host:port the tunnel dials. Accepting a URL +// would turn this into "fetch any path on any host". +func TestFromTargetRejectsNonHostPortTargets(t *testing.T) { + for _, target := range []string{ + "http://127.0.0.1:8080/admin", + "127.0.0.1", + "user:pass@127.0.0.1:8080", + "127.0.0.1:http", + "", + } { + if _, err := FromTarget(context.Background(), target); err == nil { + t.Errorf("target %q was accepted", target) + } + } +} + +// A redirect would send this somewhere the operator did not name, so it is +// rejected rather than followed. +func TestFromTargetDoesNotFollowRedirects(t *testing.T) { + var elsewhere atomic.Int64 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + elsewhere.Add(1) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(declaringDoc)) + })) + t.Cleanup(other.Close) + + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+"/", http.StatusFound) + })) + t.Cleanup(redirector.Close) + + _, err := FromTarget(context.Background(), strings.TrimPrefix(redirector.URL, "http://")) + if err == nil { + t.Fatal("a redirect was followed instead of rejected") + } + if n := elsewhere.Load(); n != 0 { + t.Fatalf("redirect target received %d requests, want 0", n) + } +} + +// The opt-in is the whole contract with the operator: without it the target is +// not touched at all. +func TestResolveWithoutOptInMakesNoRequest(t *testing.T) { + addr, requests := targetServing(t, declaringDoc) + + if got := Resolve(context.Background(), "", addr, false); got != "" { + t.Fatalf("thumbnail = %q, want empty without the opt-in", got) + } + if n := requests.Load(); n != 0 { + t.Fatalf("target received %d requests without the opt-in, want 0", n) + } +} + +// An explicit --thumbnail is the operator's answer; discovery must not second +// guess it, and must not spend a request finding that out. +func TestResolveKeepsAnExplicitValue(t *testing.T) { + addr, requests := targetServing(t, declaringDoc) + + got := Resolve(context.Background(), "https://example.com/chosen.png", addr, true) + if got != "https://example.com/chosen.png" { + t.Fatalf("thumbnail = %q, want the explicitly configured value", got) + } + if n := requests.Load(); n != 0 { + t.Fatalf("target received %d requests despite an explicit thumbnail, want 0", n) + } +} + +func TestResolveUsesTheTargetWhenAsked(t *testing.T) { + addr, requests := targetServing(t, declaringDoc) + + got := Resolve(context.Background(), "", addr, true) + if got != "https://cdn.example.com/card.png" { + t.Fatalf("thumbnail = %q, want the image the target advertises", got) + } + if n := requests.Load(); n != 1 { + t.Fatalf("target received %d requests, want exactly 1", n) + } +} + +// A missing card image is not a reason to refuse to serve. +func TestResolveSurvivesAnUnreachableTarget(t *testing.T) { + if got := Resolve(context.Background(), "", "127.0.0.1:1", true); got != "" { + t.Fatalf("thumbnail = %q, want empty", got) + } +} + +// The CLI accepts a bare port and sdk.Expose normalizes it afterwards, so a +// resolver that insisted on host:port skipped discovery for `portal expose 3000`. +func TestFromTargetAcceptsThePortOnlyFormTheCLIAccepts(t *testing.T) { + addr, requests := targetServing(t, declaringDoc) + _, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split test server address: %v", err) + } + + for _, target := range []string{port, ":" + port} { + got, err := FromTarget(context.Background(), target) + if err != nil { + t.Fatalf("target %q: %v", target, err) + } + if got != "https://cdn.example.com/card.png" { + t.Fatalf("target %q gave %q, want the declared image", target, got) + } + } + if n := requests.Load(); n != 2 { + t.Fatalf("target received %d requests, want 2", n) + } +} + +// url.Parse calls these absolute because they carry a scheme, but neither has +// an authority, so a browser would resolve them against the dashboard. +func TestFromTargetRejectsSchemeOnlyReferences(t *testing.T) { + addr, _ := targetServing(t, ` + + + `) + + got, err := FromTarget(context.Background(), addr) + if err != nil { + t.Fatalf("from target: %v", err) + } + if got != "" { + t.Fatalf("thumbnail = %q, want empty for a reference with no host", got) + } +} diff --git a/docs/src/routes/cli-reference/+page.md b/docs/src/routes/cli-reference/+page.md index bd530c06..a02dec41 100644 --- a/docs/src/routes/cli-reference/+page.md +++ b/docs/src/routes/cli-reference/+page.md @@ -97,7 +97,8 @@ not supported. | `--name` | string | auto | Public hostname prefix, one DNS label | | `--description` | string | | Service description metadata | | `--tags` | string | | Service tags metadata, comma-separated | -| `--thumbnail` | string | | Service thumbnail URL metadata | +| `--thumbnail` | string | | Service thumbnail URL metadata, as an absolute `http://` or `https://` URL | +| `--thumbnail-from-target` | bool | `false` | When `--thumbnail` is empty, use the first **absolute** image URL the target advertises: `og:image`, then `twitter:image`, then an icon link. Relative references are skipped, since Open Graph calls for an absolute URL and `metadata.thumbnail` is one by contract. Only `/` on the tunnel's own target is read, redirects are not followed, and the chosen URL is logged at startup | | `--owner` | string | | Service owner metadata | | `--hide` | bool | `false` | Hide service from relay listing screens | | `--x402-pay-to` | string | | Payment recipient address for this tunnel | diff --git a/docs/src/routes/portal-agent/+page.md b/docs/src/routes/portal-agent/+page.md index 31cffce8..98933fb3 100644 --- a/docs/src/routes/portal-agent/+page.md +++ b/docs/src/routes/portal-agent/+page.md @@ -221,6 +221,7 @@ Common fields: | `ech` | Enable ECH hostname privacy for TLS stream tunnels; defaults to `false` | | `ban_mitm` | Ban relays when the TLS self-probe detects termination; defaults to warning-only | | `description`, `tags`, `owner`, `thumbnail`, `hide` | Public relay metadata | +| `thumbnail_from_target` | Fill an empty `thumbnail` with the first absolute image URL the target advertises: `og:image`, then `twitter:image`, then an icon link | | `x402_pay_to` | Payment recipient for paid HTTP routes | | `x402_testnet` | Use Sui testnet when `x402_network` is omitted | | `x402_network` | Optional Sui or Casper CAIP-2 network |