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 = `