Skip to content

feat(tunnel): add named tunnel support for custom domains - #85

Open
mahdiwafy wants to merge 98 commits into
Vanszs:mainfrom
mahdiwafy:feat/named-tunnel-support
Open

feat(tunnel): add named tunnel support for custom domains#85
mahdiwafy wants to merge 98 commits into
Vanszs:mainfrom
mahdiwafy:feat/named-tunnel-support

Conversation

@mahdiwafy

Copy link
Copy Markdown

Summary

Add support for named tunnels (custom domains) in addition to the existing quick tunnel (). This allows operators to expose VansRouter on their own hostname (e.g., ) instead of a random subdomain.

Changes

1. Dual Auth Modes

  • Token mode (): Uses flag, no credentials file needed
  • Credentials file mode ( + optional ): Uses tunnel UUID + credentials JSON file (same pattern as host cloudflared)

2. Required Config

    • The custom domain (must have CNAME to proxied)

3. Technical Fixes

  • Ingress rules: Added mandatory ingress config mapping hostname → local port (without this, cloudflared exits immediately)
  • Argument ordering: and must come BEFORE subcommand (cloudflared v2026.7.3+)
  • intentionalKill reset: After , reset the flag so genuine startup failures surface real errors instead of being masked as "cloudflared killed"
  • Quick tunnel fix: Also applied the intentionalKill reset to for consistency

4. API Changes

  • now returns and correct for named tunnels
  • Exported from tunnel index

Verification

Tested end-to-end on production:

  • ✅ Tunnel enables successfully via with CLI token
  • ✅ Cloudflared registers 4 connections to edge (QUIC)
  • ✅ Custom domain () reachable via Cloudflare edge
  • ✅ returns 200 OK with valid API key via custom domain
  • ✅ Existing domain () still works
  • ✅ Both auth modes functional (credentials file mode tested)

Deployment Notes

For production use with credentials file mode:

The credentials file should contain from or Incorrect Usage: flag needs an argument: -cred-file

NAME:
cloudflared tunnel token - Fetch the credentials token for an existing tunnel (by name or UUID) that allows to run it

USAGE:
cloudflared tunnel [tunnel command options] token [subcommand options] TUNNEL

DESCRIPTION:
cloudflared tunnel token will fetch the credentials token for a given tunnel (by its name or UUID), which is then used to run the tunnel. This command fails if the tunnel does not exist or has been deleted. Use the flag cloudflared tunnel token --cred-file /my/path/file.json TUNNEL to output the token to the credentials JSON file. Note: this command only works for Tunnels created since cloudflared version 2022.3.0

TUNNEL COMMAND OPTIONS:
--config value Specifies a config file in YAML format. (default: "/home/mahdiwafy/.cloudflared/config.yml")
--origincert value Path to the certificate generated for your origin when you run cloudflared login. [$TUNNEL_ORIGIN_CERT]
--autoupdate-freq value Autoupdate frequency. Default is 24h0m0s. (default: 24h0m0s)
--no-autoupdate Disable periodic check for updates, restarting the server with the new version. (default: false) [$NO_AUTOUPDATE]
--no-prechecks Skip connectivity pre-checks at startup. (default: false) [$TUNNEL_NO_PRECHECKS]
--metrics value Listen address for metrics reporting. If no address is passed cloudflared will try to bind to [localhost:20241 localhost:20242 localhost:20243 localhost:20244 localhost:20245].
If all are unavailable, a random port will be used. Note that when running cloudflared from an virtual
environment the default address binds to all interfaces, hence, it is important to isolate the host
and virtualized host network stacks from each other (default: "localhost:0") [$TUNNEL_METRICS]
--pidfile value Write the application's PID to this file after first successful connection. [$TUNNEL_PIDFILE]
--loglevel value Application logging level {debug, info, warn, error, fatal}. At debug level cloudflared will log request URL, method, protocol, content length, as well as, all request and response headers. This can expose sensitive information in your logs. (default: "info") [$TUNNEL_LOGLEVEL]
--transport-loglevel value, --proto-loglevel value Transport logging level(previously called protocol logging level) {debug, info, warn, error, fatal} (default: "info") [$TUNNEL_PROTO_LOGLEVEL, $TUNNEL_TRANSPORT_LOGLEVEL]
--logfile value Save application log to this file for reporting issues. [$TUNNEL_LOGFILE]
--log-directory value Save application log to this directory for reporting issues. [$TUNNEL_LOGDIRECTORY]
--trace-output value Name of trace output file, generated when cloudflared stops. [$TUNNEL_TRACE_OUTPUT]
--output value Output format for the logs (default, json) (default: "default") [$TUNNEL_MANAGEMENT_OUTPUT, $TUNNEL_LOG_OUTPUT]

SUBCOMMAND OPTIONS:
--credentials-file value, --cred-file value Filepath at which to read/write the tunnel credentials [$TUNNEL_CRED_FILE]
--help, -h show help (default: false)
.

mahdiwafy and others added 30 commits July 22, 2026 12:07
…+ usageMetadata) in parseSSEToOpenAIResponse

The parseSSEToOpenAIResponse function only understood OpenAI SSE format
(choices[0].delta.content, root-level usage). Antigravity/Gemini SSE
chunks use a different schema:

  data: {"response":{"candidates":[{...}],"usageMetadata":{...}}}

- Text content from response.candidates[0].content.parts[].text
- Reasoning from .parts[].thought
- Finish reason from candidate.finishReason
- Usage from response.usageMetadata (promptTokenCount, candidatesTokenCount,
  thoughtsTokenCount, totalTokenCount)

Also moves the OpenAI usage extraction before the AG block so the
existing path is preserved for OpenAI-formatted chunks.
…age tracking

Tool-call-only turns (agentic coding clients like Claude Code/Kiro sending
100+ tools) stream their real output entirely through delta.tool_calls
(and delta.partial_json for Claude tool_use), with delta.content and
delta.reasoning_content empty. totalContentLength in createSSEStream()
only summed content/reasoning length, so these turns left it stuck at 0.

That starved both usage fallbacks:
- estimateUsage() (provider sent no/invalid usage at all)
- the new hasZeroCompletionWithContent()/fixZeroCompletionUsage() path
  (provider sent completion_tokens: 0 despite real output)

Net effect: usage logs showed "out=0" for most tool-heavy turns even
though the client received a large tool_calls payload — matching the
live claude-sonnet-5 report on VansRouter (a5a5b570 account/Shiteru).

Changes:
- stream.js: sum delta.tool_calls[].function.name/arguments length (both
  PASSTHROUGH and TRANSLATE modes) and delta.partial_json (Claude tool_use)
  into totalContentLength.
- usageTracking.js: add hasZeroCompletionWithContent() and
  fixZeroCompletionUsage() to patch a provider-reported zero completion
  count without discarding its (usually accurate) prompt_tokens.
- Wire the new fallback into all 4 usage-decision sites in stream.js.

Verified: reproduced the exact bug shape in a new integration test
(in=166912, completion_tokens: 0 finish chunk, pure tool_calls output) —
now estimates a non-zero completion count instead of logging 0. Deployed
the fix live to the running container and confirmed in production logs:
claude-sonnet-5 tool-heavy turns now log out=44/56/322/etc instead of a
long run of out=0.

Separately observed (NOT fixed by this change, needs its own
investigation): a distinct pattern of genuinely empty completions
(response.content === "[Empty streaming response]", no tool_calls
either) from the Shiteru-backed claude-sonnet-5 route once prompt size
exceeds ~130k tokens — confirmed via requestDetails table, not a usage-
counting artifact.
…ojectId persistence, DuckDuckGo browser profile

- ModelRow: empty draft on Save now deletes the alias instead of silently no-op
- allowedModels: isModelAllowed enforces global list even without apiKeyInfo;
  appendUserAliasEntries surfaces bare alias names; cache invalidated on
  alias/disabled mutations
- v1/models: alias entries pass through with owned_by=alias
- chat.js + chatCore + stream/nonStreaming: echo client-facing model id
  (alias) back in /v1/chat/completions response.model and SSE chunks
- projectId: cache TTL 30d bound to Google account not OAuth token;
  skip re-fetch when DB already has projectId (seedProjectIdCache)
- settingsRepo + systemInject + chatCore: injectDefaultSystemPrompt only
  when request has no system message yet
- DuckDuckGo: undici Agent (HTTP/2) + stable Chrome 131 header set
  (Sec-CH-UA client hints, full Accept)、 replacing the bot UA
- registry/index.js: register duckduckgo provider
- duckduckgo.png icon
…h label)

validate/route.js had two `case "agentrouter":` labels in the same
switch. JS only runs the first match, so agentrouter connections were
silently validated by the generic glm/kimi/minimax block (bare
x-api-key + anthropic-version, no headers) instead of
validateAgentRouterConnection()'s Claude-CLI spoof headers.

AgentRouter gates on client fingerprint and returns 401
'unauthorized client detected' for any request missing the spoof
headers/UA — which the dashboard's Add API Key flow then reported as
'Invalid API key' even for a valid, unrestricted key.

Removed agentrouter from the shared fallthrough case so it reaches
the dedicated block. Verified live against the real AgentRouter API:
the old (buggy) header set gets a genuine 401 from the same key that
gets a non-401/403 response through validateAgentRouterConnection().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atalog

NVIDIA NIM chat/completions returned errors for several registry models:

- z-ai/glm-5.1 → HTTP 410 Gone (EOL 2026-07-02). This is the primary
  failure users hit — the model is globally retired.
- moonshotai/kimi-k2.6 (+ none/low/medium/high thinking variants) → HTTP 404
  "not found for account": catalog-listed but not served on standard NIM
  developer keys.

suggestedModels also carried dead ids:
- z-ai/glm-5.1 (410 EOL), qwen/qwen3.5-397b-a17b (404 account),
  qwen/qwen3.5-122b-a10b and mistralai/mistral-large-3-675b-instruct-2512
  (absent from /v1/models entirely).

Replaced with models verified against integrate.api.nvidia.com (present in
/v1/models and returning 200 on a real completion): GLM 5.2, DeepSeek V4
Pro/Flash, MiniMax M2.7/M3, GPT-OSS 120B/20B, Llama 4 Maverick, Step 3.7
Flash, Nemotron 3 Ultra/Super 120B, Nemotron Super 49B v1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…atalog

NVIDIA NIM chat/completions returned errors for several registry models:

- z-ai/glm-5.1 → HTTP 410 Gone (EOL 2026-07-02). This is the primary
  failure users hit — the model is globally retired.
- moonshotai/kimi-k2.6 (+ none/low/medium/high thinking variants) → HTTP 404
  "not found for account": catalog-listed but not served on standard NIM
  developer keys.

suggestedModels also carried dead ids:
- z-ai/glm-5.1 (410 EOL), qwen/qwen3.5-397b-a17b (404 account),
  qwen/qwen3.5-122b-a10b and mistralai/mistral-large-3-675b-instruct-2512
  (absent from /v1/models entirely).

Replaced with models verified against integrate.api.nvidia.com (present in
/v1/models and returning 200 on a real completion): GLM 5.2, DeepSeek V4
Pro/Flash, MiniMax M2.7/M3, GPT-OSS 120B/20B, Llama 4 Maverick, Step 3.7
Flash, Nemotron 3 Ultra/Super 120B, Nemotron Super 49B v1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wasm32 fallback was in the main dependencies block with a pinned 4.3.0
version, causing npm install to fail with EBADPLATFORM on x64 hosts (and
any non-wasm32 CPU). Move it to optionalDependencies mirroring the
better-sqlite3 pattern and bump the spec to ^4.3.3 to match the resolved
@tailwindcss/oxide version. Drop the non-standard comment_better_sqlite3
metadata key while here.
Hardcoded dev origins forced every contributor into the same localhost
setup. Read VANS_ALLOWED_DEV_ORIGINS (comma-separated) with the prior
127.0.0.1,localhost as the default. Prod is unaffected — Next 16
ignores allowedDevOrigins outside dev mode.
Race: resolvePrivilegedUserId().then(...) was scheduled without await in
transformRequest, so the first request always went out without the
x-gemini-api-privileged-user-id header (the .then microtask could not
drain before buildHeaders ran synchronously). Fix:
  - Prewarm the cache at module load so it is ready before any request.
  - Make transformRequest await resolvePrivilegedUserId() so the header
    is present on the very first request. Await is safe because
    BaseExecutor.execute now wraps transformRequest in
    await Promise.resolve(...), keeping the sync-return contract for
    every other executor.

Mutation: stripOpenAILeakFields/applyIncludeReasoningToGemini mutated the
input body in place, leaving loggers, error DB saves, and the 401/403
retry path reading a stripped body instead of the client's original.
Fix: structuredClone(body) at the start of transformRequest so the
original translatedBody stays pristine for downstream consumers.
…branch

webSearchInject.js (125 lines) was never imported by chatCore, chat, or
any executor — the live web-search path lives inline in chat.js. Drop
the orphan module. Simplify the redundant provider.id === 'duckduckgo'
check in search/index.js (registry already sets executor: 'duckduckgo').
Remove the unused bodyHasReasoning variable in chat.js that nothing read.
The /^[a-zA-Z0-9_.\-]+$/ regex and its hint strings were copied across
two API routes and the dashboard page. Extract COMBO_NAME_REGEX,
COMBO_NAME_HINT, COMBO_ALIAS_HINT into src/shared/constants/comboValidation.js.
Behavior unchanged: same validation, same error messages, same status codes.
Catch-block console.log calls in API routes wrote errors to stdout
instead of stderr, mixing diagnostics with normal output. Convert them
to console.error across providers/[id]/models, v1beta/models
([GEMINI_NATIVE] telemetry), keys, models/alias, models/disabled,
proxy-pools/[id], and cli-tools/claude-settings. Remove the stray
console.log('Deno deployUrl:', deployUrl) in proxy-pools/deno-deploy
that leaked every freshly-minted relay URL to stdout.
When PUT /api/keys/[id] received an allowedModels/allowedProviders/
allowedCombos/allowedKinds value that was neither null nor an array
(string, object, number), it silently coerced to null — meaning
'all allowed' — masking caller bugs as over-permissive defaults.
Default unexpected types to [] (deny-all) for all four ACL fields,
matching the documented contract: null = all, [] = none, [...] = specific.
The system-prompt save handler swallowed all failures silently (empty
catch), leaving users with no indication the save failed. Reuse the
file's existing {type, message} status pattern to surface errors next
to the Save button. Remove the unused isDark destructure from
useTheme() (never referenced in the file).
The chromium executablePath was pinned to /home/vanszs/.cache/
ms-playwright/chromium-1228/chrome-linux64/chrome — one contributor's
absolute home path that fails on every other machine. Read
VANS_ZCODE_CHROMIUM_PATH from the environment; pass undefined to let
playwright-core use its own channel discovery when the env var is unset.
The dev prewarm script fell back to '123456' when VANS_PREWARM_PASSWORD
was unset, silently authenticating with the default dashboard password
in any environment that forgot to set the env var. Fail closed: if the
env var is missing, print a clear error and exit non-zero so the
operator sets it explicitly.
The per-process _binCache/_pyCache/_extrasCache never exposed an
invalidation path, so installing headroom-ai while the server was
running left the dashboard reporting 'not installed' until process
restart. Export invalidateHeadroomCaches() so the install endpoint can
reset the probes after a successful install.
Extend the shared COMBO_NAME_REGEX to ComboFormModal and the
media-providers combo edit page, the two remaining copies of the
/^[a-zA-Z0-9_.\-]+$/ regex that were protected by the first DRY pass.
Behavior unchanged.
The /api/models response is always { models: [...] } by convention, so
the Array.isArray(data) fallback at line 526 was dead code reachable
only if the endpoint changed shape. Remove it. Restore 'No providers'
capitalization to match the adjacent 'No models' badge.
The commit that converted login-page strings from inline Bahasa to
inline English left Indonesian-cookie users seeing English, because
no corresponding keys existed in public/i18n/literals/id.json and the
runtime DOM translator only matches known English source strings. Wrap
the placeholder, dispatch error fallbacks, button label, and OIDC
fallback in translate() from @/i18n/runtime (attributes and
state-driven strings are not reached by the MutationObserver-driven
DOM walker). Add the missing English keys to id.json, using the
pre-commit Bahasa strings as the Indonesian translations.
claude.ai/oauth/authorize rejects our redirect_uri
(https://api.bevansatria.my.id/callback) with "Redirect URI ... is not
supported by client" because it isn't on the whitelist for the public
Claude Code client_id. Switch to the same redirect Anthropic's own
Claude Code CLI uses (https://platform.claude.com/oauth/code/callback),
which is whitelisted.

Since that redirect lands on Anthropic's domain instead of our own
/callback route, the popup can never postMessage/BroadcastChannel the
code back to us — force manual-paste mode for the claude provider and
accept Anthropic's bare `code#state` callback format (not a full URL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Claude Code (cc) provider's model list stopped at Opus 4.8 and was
missing the entire Claude 5 generation — even though the CLI defaults in
cliTools.js already point at cc/claude-fable-5 and cc/claude-sonnet-5.

Add the three generally-available Claude 5 models (per Anthropic's model
docs), keeping 4.x as legacy entries:
  - claude-fable-5  ($10/$50, 1M ctx, adaptive thinking always-on)
  - claude-opus-5   ($5/$25,  1M ctx, adaptive thinking)
  - claude-sonnet-5 ($3/$15,  1M ctx, adaptive thinking)

Also add exact capabilities entries for claude-opus-5 / claude-fable-5
(so the generic *claude* pattern doesn't downgrade them to claude-budget
thinking) and canonical pricing for claude-opus-5 / claude-sonnet-5.
claude-mythos-5 is intentionally omitted — it is invitation-only
(Project Glasswing) and not generally accessible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match the official Claude Code OAuth client scope set by requesting
user:sessions:claude_code and user:mcp_servers in addition to the existing
profile/inference scopes. Tokens minted without these scopes can connect
for basic OAuth but fail Claude Code session/usage endpoints or org policy
checks.

Also send the same Claude CLI fingerprint headers to the OAuth usage
endpoint that we already use for Claude Code inference. When Anthropic
rejects OAuth for an organization, surface an explicit org-policy message
instead of falling back to the misleading legacy admin-permissions text.

Note that oauth_not_allowed_for_organization is still an Anthropic org
policy decision; users must reconnect after this change to mint a fresh
scoped token, and accounts in orgs that disable OAuth still need an org
admin or a different Claude account.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mahdiwafy added 30 commits July 30, 2026 11:39
Adds tracking-based weekly usage buckets alongside the existing 5H windows:
- New antigravityWeeklyTracker module: counts request rows in usageHistory
  for the last 7 days, grouped into Gemini / Claude+GPT buckets
- getAntigravityUsage accepts connectionId and attaches gemini_weekly +
  claude_gpt_weekly buckets with estimated totals (4000 req/week)
- parseQuotaData forwards isWeekly flag; QuotaTable shows a 'Weekly'
  badge and tilde-prefixed reset countdown for weekly rows
…tdown

- parseQuotaData skips raw_gemini/raw_claude internal payloads that were
  rendering as misleading 0/∞ rows (Gemini 3.5 Flash (High) / GPT-OSS)
- Weekly reset now starts from each bucket's first use + 7 days,
  matching Antigravity's behavior where the weekly window countdown
  begins on first request (same principle as the 5H window)
Adds tracking-based weekly usage buckets alongside the existing 5H windows:
- New antigravityWeeklyTracker module: counts request rows in usageHistory
  for the last 7 days, grouped into Gemini / Claude+GPT buckets
- getAntigravityUsage accepts connectionId and attaches gemini_weekly +
  claude_gpt_weekly buckets with estimated totals (4000 req/week)
- parseQuotaData forwards isWeekly flag; QuotaTable shows a 'Weekly'
  badge and tilde-prefixed reset countdown for weekly rows
…tdown

- parseQuotaData skips raw_gemini/raw_claude internal payloads that were
  rendering as misleading 0/∞ rows (Gemini 3.5 Flash (High) / GPT-OSS)
- Weekly reset now starts from each bucket's first use + 7 days,
  matching Antigravity's behavior where the weekly window countdown
  begins on first request (same principle as the 5H window)
…tion_call + dedupe [DONE] in passthrough

- test-models: resolve alias from provider node prefix (st, atmo, relayn) instead of raw UUID providerId — fixes 404 'model not found' for all custom provider nodes
- stream: strip empty legacy function_call {name:'',arguments:''} deltas that cause client loops (Shiteru sends these on final chunk)
- stream: skip upstream [DONE] in passthrough so flush handler emits exactly one terminator
- Support both token-based (TUNNEL_TOKEN) and credentials-file-based (TUNNEL_CRED_FILE + TUNNEL_ID) auth modes
- Add proper ingress config generation for named tunnels (required for routing)
- Fix cloudflared argument ordering: --config and --no-autoupdate must come BEFORE 'run' subcommand
- Fix intentionalKill flag reset after killCloudflared() to surface real startup errors
- Update getTunnelStatus() to include 'named' flag and correct publicUrl for named mode
- Export spawnNamedTunnel from tunnel index

Verified end-to-end: enables tunnel on custom domain (vr.mahdiwafy.my.id), Cloudflare edge reachability confirmed, /v1/models API returns 200 OK with valid API key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants