feat(local-agent): TLS skip opt-in for self-signed stage + exchange envelope 對齊
- 新增 VISIONA_INSECURE_SKIP_TLS_VERIFY(DEV/TEST ONLY、須明確 opt-in "true"): pairing exchange HTTP client、tunnel WSS dialer、設定頁 TestConnection 三路徑 共用 TLSConfigForDial(含 ALPN 釘 http/1.1);NewApp 唯一 env 讀取點注入欄位 - exchangeResponse 對齊雲端 /api/pairing/exchange success envelope (account/relay_url 選填 fallback 保留、舊頂層格式回歸防護) - .gitignore:.env.stage* + !.env.stage.example + *.pptx(堵 secrets 誤入) - start-agent.sh(新增):public 模式 export skip env + 預檢 curl https 帶 -k - 測試:自簽 TLS server 行為級(預設拒絕驗 x509 / 開啟通過)+ opt-in 規則 11+5 案例 + TestConnection 3 測試;go build/vet/test 3 packages 全綠 - review:2 輪通過(.autoflow/05-implementation/review/tls-skip-uncommitted-batch-review.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
276910854e
commit
3d30fdc580
15
.gitignore
vendored
15
.gitignore
vendored
@ -52,7 +52,14 @@ local-tool/visiona-local/payload/
|
|||||||
.env.dev
|
.env.dev
|
||||||
.env.dev.generated
|
.env.dev.generated
|
||||||
# stage 環境(docker-compose.stage.yml)— 不進 git,由人工放到 stage host
|
# stage 環境(docker-compose.stage.yml)— 不進 git,由人工放到 stage host
|
||||||
.env.stage
|
# 用 .env.stage*(而非精確匹配)涵蓋人工備份檔(如 .env.stage.backup-YYYYMMDD-*),
|
||||||
|
# 這些備份含 stage secrets,一個 git add -A 就會進 git(進了就沒救)
|
||||||
|
.env.stage*
|
||||||
|
# 範本檔不含 secrets,明確 re-include 進 git
|
||||||
|
!.env.stage.example
|
||||||
|
|
||||||
|
# 簡報 / office 文件不進 git(repo 內出現的多半是內部討論素材,可能含內部資訊)
|
||||||
|
*.pptx
|
||||||
|
|
||||||
# Claude Code 本地 session / memory,不入 git
|
# Claude Code 本地 session / memory,不入 git
|
||||||
.claude/
|
.claude/
|
||||||
@ -60,9 +67,13 @@ local-tool/visiona-local/payload/
|
|||||||
# Autoflow Agent(由 autoflow-agent init 自動產生)
|
# Autoflow Agent(由 autoflow-agent init 自動產生)
|
||||||
# 整包 ignore(progress.md / review 報告 / 個人筆記皆 per-branch)
|
# 整包 ignore(progress.md / review 報告 / 個人筆記皆 per-branch)
|
||||||
# 共享文件(PRD / 設計 / 架構 / 交付)走 docs/ 進 git
|
# 共享文件(PRD / 設計 / 架構 / 交付)走 docs/ 進 git
|
||||||
.autoflow/
|
|
||||||
graphify-out/
|
graphify-out/
|
||||||
|
|
||||||
# sub-agent 工作 log(per-branch、不進 git)
|
# sub-agent 工作 log(per-branch、不進 git)
|
||||||
.autoflow-logs/
|
.autoflow-logs/
|
||||||
**/.autoflow-logs/
|
**/.autoflow-logs/
|
||||||
|
|
||||||
|
.autoflow/
|
||||||
|
.autoflow/CLAUDE.md.backup.*
|
||||||
|
.autoflow/.backups/
|
||||||
|
.mcp.json
|
||||||
|
|||||||
123
local-agent/start-agent.sh
Executable file
123
local-agent/start-agent.sh
Executable file
@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# visionA Agent 本地啟動腳本(公網正式路徑 demo 驗證用)
|
||||||
|
#
|
||||||
|
# 用途:在本機起 agent GUI,走公網邊界反代(stage-9527.innovedus.com:9527)
|
||||||
|
# 連 tunnel,驗證「網管開通邊界反代 WS 轉發」是否生效 + 雲端 device online 顯示。
|
||||||
|
#
|
||||||
|
# ⚠️ 實測重點:Wails app 不能用 `go run .`(會 panic: missing build tags)。
|
||||||
|
# 必須先用 `make wails-macos` build 成 .app(需先裝 wails CLI),本腳本直接開 .app。
|
||||||
|
# 這個腳本只設環境變數 + 開既有 build 好的 .app,不改任何 agent 原始碼。
|
||||||
|
#
|
||||||
|
# 前置(首次):
|
||||||
|
# go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
# cd local-agent && make wails-macos
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./start-agent.sh # 走公網正式路徑(預設)
|
||||||
|
# ./start-agent.sh internal # 走內網 demo 路徑(ws://192.168.0.130:9527,需 VPN)
|
||||||
|
#
|
||||||
|
# 起來後:在 GUI 配對頁貼上瀏覽器拿到的配對碼(vAc_ 開頭),狀態頁看 online。
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── 腳本所在目錄(讓從任何地方呼叫都能定位 agent)──
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP="$SCRIPT_DIR/visiona-agent/build/bin/visiona-agent.app"
|
||||||
|
APP_BIN="$APP/Contents/MacOS/visiona-agent"
|
||||||
|
|
||||||
|
# ── 選路徑:public(預設,公網正式)/ internal(內網 demo)──
|
||||||
|
MODE="${1:-public}"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
public)
|
||||||
|
HOST="stage-9527.innovedus.com:9527"
|
||||||
|
CLOUD_API_URL="https://$HOST"
|
||||||
|
RELAY_URL="wss://$HOST/tunnel/connect"
|
||||||
|
WS_SCHEME="https"
|
||||||
|
;;
|
||||||
|
internal)
|
||||||
|
HOST="192.168.0.130:9527"
|
||||||
|
CLOUD_API_URL="http://$HOST"
|
||||||
|
RELAY_URL="ws://$HOST/tunnel/connect"
|
||||||
|
WS_SCHEME="http"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "用法:$0 [public|internal]" >&2
|
||||||
|
echo " public = 公網正式路徑 wss://stage-9527.innovedus.com:9527(預設)" >&2
|
||||||
|
echo " internal = 內網 demo 路徑 ws://192.168.0.130:9527(需 VPN)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
echo " visionA Agent 啟動($MODE 路徑)"
|
||||||
|
echo "════════════════════════════════════════════════════════"
|
||||||
|
echo " Cloud API : $CLOUD_API_URL"
|
||||||
|
echo " Relay URL : $RELAY_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 前置檢查:.app 存在嗎?──
|
||||||
|
if [ ! -x "$APP_BIN" ]; then
|
||||||
|
echo "❌ 找不到 build 好的 app:$APP_BIN" >&2
|
||||||
|
echo " 請先 build(首次):" >&2
|
||||||
|
echo " go install github.com/wailsapp/wails/v2/cmd/wails@latest" >&2
|
||||||
|
echo " cd \"$SCRIPT_DIR\" && PATH=\"\$(go env GOPATH)/bin:\$PATH\" make wails-macos" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 預檢:開 GUI 前先測邊界反代 WS 握手(省得白貼配對碼)
|
||||||
|
# 回 101 = WS 轉發通;400 = 邊界反代還在剝 header(網管沒設對);
|
||||||
|
# 444 = Host 沒帶對;逾時 = 防火牆/DNS。
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
echo "──▶ 預檢:測 $HOST 的 WS 握手 ..."
|
||||||
|
# https(stage 自簽憑證)時帶 -k:預檢只測可達性,不驗憑證;
|
||||||
|
# 否則 TLS 握手直接失敗回 000,被誤判成「防火牆/DNS 問題」誤導排查。
|
||||||
|
CURL_INSECURE=""
|
||||||
|
if [ "$WS_SCHEME" = "https" ]; then
|
||||||
|
CURL_INSECURE="-k"
|
||||||
|
fi
|
||||||
|
WS_CODE="$(curl -s $CURL_INSECURE -o /dev/null -w '%{http_code}' -m 10 -N \
|
||||||
|
-H 'Connection: Upgrade' \
|
||||||
|
-H 'Upgrade: websocket' \
|
||||||
|
-H 'Sec-WebSocket-Version: 13' \
|
||||||
|
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
|
||||||
|
"$WS_SCHEME://$HOST/tunnel/connect" 2>/dev/null || echo "000")"
|
||||||
|
|
||||||
|
case "$WS_CODE" in
|
||||||
|
101) echo " ✅ 101 — 邊界反代 WS 轉發已生效,tunnel 可通。" ;;
|
||||||
|
401) echo " ✅ 401(WS header 有過、卡在 token 驗證)— WS 轉發 OK,屬正常(配對後帶 token)。" ;;
|
||||||
|
400)
|
||||||
|
echo " ❌ 400 — 邊界反代還在剝 WS upgrade header(唯一根因未解)。"
|
||||||
|
echo " → 請網管在公網入口 proxy 開 WS 轉發(h1.1 + Upgrade/Connection header)。"
|
||||||
|
echo " 5 秒後仍會續開 GUI(Ctrl-C 中止)。"; sleep 5 ;;
|
||||||
|
444)
|
||||||
|
echo " ⚠️ 444 — Host 白名單擋掉(邊界反代沒帶對 Host: ${HOST})。5 秒後續開(Ctrl-C 中止)。"; sleep 5 ;;
|
||||||
|
000)
|
||||||
|
echo " ⚠️ 連線逾時/拒絕 — 防火牆沒放行 :9527 或 DNS 解錯(internal 請確認 VPN)。"
|
||||||
|
echo " 5 秒後續開(Ctrl-C 中止)。"; sleep 5 ;;
|
||||||
|
*) echo " ⚠️ 收到 HTTP ${WS_CODE}(非預期)。5 秒後續開(Ctrl-C 中止)。"; sleep 5 ;;
|
||||||
|
esac
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 設環境變數(優先級:env > config.yaml > 內建預設)──
|
||||||
|
export VISIONA_CLOUD_API_URL="$CLOUD_API_URL"
|
||||||
|
export VISIONA_RELAY_URL="$RELAY_URL"
|
||||||
|
|
||||||
|
# public 模式:stage 用自簽憑證,agent 需明確 opt-in 跳過 TLS 驗證
|
||||||
|
# (pairing exchange + tunnel WSS + 設定頁「測試連線」三條路徑共用此開關),
|
||||||
|
# 否則全部 x509 失敗,與本腳本的 demo 目的自相矛盾。
|
||||||
|
# ⚠️ DEV / DEMO ONLY — 正式環境絕不可設此變數。
|
||||||
|
if [ "$MODE" = "public" ]; then
|
||||||
|
export VISIONA_INSECURE_SKIP_TLS_VERIFY=true
|
||||||
|
echo "⚠️ 已設 VISIONA_INSECURE_SKIP_TLS_VERIFY=true(stage 自簽憑證用;跳過 TLS 驗證,僅 demo 用)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 直接執行 .app 內的 binary(保留 stdout/stderr 在終端看 log;env 有帶進去)──
|
||||||
|
echo "──▶ 啟動 GUI(${APP_BIN})..."
|
||||||
|
echo " 起來後:配對頁貼上瀏覽器拿到的配對碼(vAc_ 開頭)→ 狀態頁看 online。"
|
||||||
|
echo " (關 GUI 視窗即結束 agent)"
|
||||||
|
echo ""
|
||||||
|
exec "$APP_BIN"
|
||||||
@ -121,7 +121,25 @@ func (a *App) Pair(pairingToken string) error {
|
|||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
return a.tunnelManager.Pair(ctx, pairingToken)
|
err := a.tunnelManager.Pair(ctx, pairingToken)
|
||||||
|
if err != nil {
|
||||||
|
// Debug 缺口修補:把 exchange 失敗的實際原因(含 TLS / network error 細節)
|
||||||
|
// 寫進 wails.log,否則排查得翻雲端 log 才知道請求根本沒送達。
|
||||||
|
// ⚠️ 不 log pairing token 全文(敏感);只記前綴供對照。
|
||||||
|
a.appLog("tunnel: Pair failed (token=%s): %v", maskPairingToken(pairingToken), err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskPairingToken 產生 pairing token 的遮蔽顯示字串,供 log 使用。
|
||||||
|
// pairing token 格式為 vAc_ + 32 hex(見 tunnel.ValidatePairingToken),敏感不可全記。
|
||||||
|
// 規則:保留 "vAc_" 前綴 + 前 4 hex,其餘以 "…" 取代;非預期格式只記長度避免洩漏。
|
||||||
|
func maskPairingToken(token string) string {
|
||||||
|
const prefix = "vAc_"
|
||||||
|
if !strings.HasPrefix(token, prefix) || len(token) < len(prefix)+4 {
|
||||||
|
return fmt.Sprintf("<malformed len=%d>", len(token))
|
||||||
|
}
|
||||||
|
return prefix + token[len(prefix):len(prefix)+4] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unpair 清除本地 Session Token 並斷開 tunnel。
|
// Unpair 清除本地 Session Token 並斷開 tunnel。
|
||||||
@ -255,6 +273,9 @@ func (a *App) TestConnection(relayURL string) TestResult {
|
|||||||
if relayURL == "" {
|
if relayURL == "" {
|
||||||
return TestResult{OK: false, Reason: "URL is empty"}
|
return TestResult{OK: false, Reason: "URL is empty"}
|
||||||
}
|
}
|
||||||
|
// 只檢查 ws:// / wss:// 前綴,刻意不強制要求 path:relay base URL(裸 host、無 path)
|
||||||
|
// 是合法輸入。實際 tunnel 連線的 /tunnel/connect path 由 client.connect()(normalizeRelayURL)
|
||||||
|
// 在 Dial 前兜底補上,不在此驗證層要求。
|
||||||
if !strings.HasPrefix(relayURL, "ws://") && !strings.HasPrefix(relayURL, "wss://") {
|
if !strings.HasPrefix(relayURL, "ws://") && !strings.HasPrefix(relayURL, "wss://") {
|
||||||
return TestResult{OK: false, Reason: fmt.Sprintf("URL must start with ws:// or wss:// (got %q)", relayURL)}
|
return TestResult{OK: false, Reason: fmt.Sprintf("URL must start with ws:// or wss:// (got %q)", relayURL)}
|
||||||
}
|
}
|
||||||
@ -264,6 +285,13 @@ func (a *App) TestConnection(relayURL string) TestResult {
|
|||||||
|
|
||||||
dialer := websocket.Dialer{
|
dialer := websocket.Dialer{
|
||||||
HandshakeTimeout: 5 * time.Second,
|
HandshakeTimeout: 5 * time.Second,
|
||||||
|
// 與 tunnel client 主路徑(internal/tunnel/client.go connect())共用同一份
|
||||||
|
// TLS 設定:ALPN 釘死 http/1.1(否則經會挑 h2 的反代時誤報 bad handshake)
|
||||||
|
// + insecure skip 開關同源(a.insecureSkipTLSVerify 由 NewApp 讀 env 一次,
|
||||||
|
// ⚠️ DEV / TEST ONLY;WARNING log 由 app.go tryStartTunnel 統一印)。
|
||||||
|
// 沒對齊的後果:stage 自簽環境「測試連線」永遠 x509 失敗,誤導使用者以為
|
||||||
|
// relay 不通,但 tunnel 本體其實連得上。
|
||||||
|
TLSClientConfig: tunnel.TLSConfigForDial(a.insecureSkipTLSVerify),
|
||||||
}
|
}
|
||||||
headers := http.Header{}
|
headers := http.Header{}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|||||||
@ -14,12 +14,16 @@ package main
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"visiona-agent/internal/agentconfig"
|
"visiona-agent/internal/agentconfig"
|
||||||
"visiona-agent/internal/tunnel"
|
"visiona-agent/internal/tunnel"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newBindingTestApp 建立一個沒有 Wails runtime 的 App,只帶 AB7-AB10 會用到的元件。
|
// newBindingTestApp 建立一個沒有 Wails runtime 的 App,只帶 AB7-AB10 會用到的元件。
|
||||||
@ -128,6 +132,77 @@ func TestTestConnection_InputValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newSelfSignedWSServer 起一個自簽憑證的 TLS server,收到請求就完成 WebSocket
|
||||||
|
// upgrade 後立即關閉(TestConnection 只測握手,不發 frame)。
|
||||||
|
// 回傳 wss:// URL。
|
||||||
|
func newSelfSignedWSServer(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return // upgrade 失敗(非 WS 請求);TestConnection 端會收到 dial error
|
||||||
|
}
|
||||||
|
_ = c.Close()
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return "wss://" + strings.TrimPrefix(srv.URL, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTestConnection_SecureByDefaultRejectsSelfSigned 驗證預設(未 opt-in skip)時,
|
||||||
|
// TestConnection 對自簽憑證 server 應以 TLS 驗證失敗收場(production-safe default)。
|
||||||
|
// 比照 tunnel package 的行為級測試模式(TestExchangerSecureByDefaultRejectsSelfSigned)。
|
||||||
|
func TestTestConnection_SecureByDefaultRejectsSelfSigned(t *testing.T) {
|
||||||
|
wssURL := newSelfSignedWSServer(t)
|
||||||
|
a := &App{} // insecureSkipTLSVerify 預設 false
|
||||||
|
got := a.TestConnection(wssURL)
|
||||||
|
if got.OK {
|
||||||
|
t.Fatal("TestConnection should fail against self-signed server when skip disabled")
|
||||||
|
}
|
||||||
|
// 失敗原因必須是憑證驗證(x509),不是其他網路錯誤——確保真的走到 TLS 驗證。
|
||||||
|
if !strings.Contains(got.Reason, "x509") && !strings.Contains(got.Reason, "certificate") {
|
||||||
|
t.Errorf("Reason = %q, want TLS certificate verification failure (x509)", got.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTestConnection_InsecureSkipTLSVerifyAllowsSelfSigned 驗證開關開時,
|
||||||
|
// TestConnection 對自簽憑證 server 應握手成功(stage demo 情境;Major-1 修復的行為)。
|
||||||
|
func TestTestConnection_InsecureSkipTLSVerifyAllowsSelfSigned(t *testing.T) {
|
||||||
|
wssURL := newSelfSignedWSServer(t)
|
||||||
|
a := &App{insecureSkipTLSVerify: true}
|
||||||
|
got := a.TestConnection(wssURL)
|
||||||
|
if !got.OK {
|
||||||
|
t.Fatalf("TestConnection with skip enabled should succeed against self-signed server, got reason=%q", got.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewApp_InsecureSkipTLSVerifyOptIn 驗證 NewApp(唯一 env 讀取點)遵守
|
||||||
|
// opt-in 規則:只有明確 "true" 才開,unset / false / 拼錯字都維持安全預設。
|
||||||
|
// (完整 11 案例的規則測試在 tunnel.IsInsecureSkipTLSVerify 的單元測試;
|
||||||
|
// 這裡只守「NewApp 有正確接上同一份判斷」。)
|
||||||
|
func TestNewApp_InsecureSkipTLSVerifyOptIn(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"unset(安全預設)", "", false},
|
||||||
|
{"false", "false", false},
|
||||||
|
{"拼錯字 truthy", "truthy", false},
|
||||||
|
{"明確 opt-in true", "true", true},
|
||||||
|
{"大小寫不敏感 TRUE", "TRUE", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv(tunnel.InsecureSkipTLSVerifyEnvVar, tc.env)
|
||||||
|
a := NewApp()
|
||||||
|
if a.insecureSkipTLSVerify != tc.want {
|
||||||
|
t.Errorf("NewApp().insecureSkipTLSVerify = %v, want %v (env=%q)", a.insecureSkipTLSVerify, tc.want, tc.env)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResetAllSettings_ClearsConfigAndToken(t *testing.T) {
|
func TestResetAllSettings_ClearsConfigAndToken(t *testing.T) {
|
||||||
a := newBindingTestApp(t)
|
a := newBindingTestApp(t)
|
||||||
// 先寫入非預設 settings + 一個 token
|
// 先寫入非預設 settings + 一個 token
|
||||||
@ -262,3 +337,50 @@ func TestGetRecentLogs_WithoutBufferReturnsEmpty(t *testing.T) {
|
|||||||
t.Errorf("GetRecentLogs without buffer = %d items; want 0", len(got))
|
t.Errorf("GetRecentLogs without buffer = %d items; want 0", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMaskPairingToken 驗證 pairing token 的 log 遮罩。
|
||||||
|
//
|
||||||
|
// 這是安全相關 helper:遮罩錯就會把敏感的 pairing token 全文寫進 wails.log。
|
||||||
|
// 規則(見 maskPairingToken):
|
||||||
|
// - 正常 token(vAc_ + 32 hex)→ "vAc_" + 前 4 hex + "…"(其餘以 "…" 取代)
|
||||||
|
// - 過短 / 非 vAc_ 前綴 / 空字串 → "<malformed len=N>"(只記長度、不記內容)
|
||||||
|
func TestMaskPairingToken(t *testing.T) {
|
||||||
|
const fullToken = "vAc_0123456789abcdef0123456789abcdef" // vAc_ + 32 hex
|
||||||
|
// suffix 是「不該出現在遮罩結果」的尾段(前 4 hex 之後的部分)。
|
||||||
|
const leakSuffix = "456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"normal token", fullToken, "vAc_0123…"},
|
||||||
|
{"min valid length (vAc_ + 4 hex)", "vAc_0123", "vAc_0123…"},
|
||||||
|
{"too short (vAc_ + 3 hex)", "vAc_012", "<malformed len=7>"},
|
||||||
|
{"empty string", "", "<malformed len=0>"},
|
||||||
|
{"wrong prefix (session token)", "vAs_0123456789abcdef0123456789abcdef", "<malformed len=36>"},
|
||||||
|
{"no prefix", "0123456789abcdef0123456789abcdef", "<malformed len=32>"},
|
||||||
|
{"prefix only", "vAc_", "<malformed len=4>"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := maskPairingToken(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("maskPairingToken(%q) = %q; want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 額外的安全不變量:正常 token 的遮罩結果絕不能包含 token 全文或其尾段。
|
||||||
|
masked := maskPairingToken(fullToken)
|
||||||
|
if strings.Contains(masked, fullToken) {
|
||||||
|
t.Errorf("masked output %q must not contain the full token", masked)
|
||||||
|
}
|
||||||
|
if strings.Contains(masked, leakSuffix) {
|
||||||
|
t.Errorf("masked output %q must not leak the token suffix %q", masked, leakSuffix)
|
||||||
|
}
|
||||||
|
// 正確前綴必須保留(供對照)。
|
||||||
|
if !strings.HasPrefix(masked, "vAc_") {
|
||||||
|
t.Errorf("masked output %q should keep the vAc_ prefix for correlation", masked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -148,6 +148,13 @@ type App struct {
|
|||||||
// 由 startup() 建立;Manager 透過 Config.TokenStore 使用它。
|
// 由 startup() 建立;Manager 透過 Config.TokenStore 使用它。
|
||||||
// 拆成獨立欄位是為了讓 test / reset 操作能直接戳 store(不用繞 Manager)。
|
// 拆成獨立欄位是為了讓 test / reset 操作能直接戳 store(不用繞 Manager)。
|
||||||
tokenStore tunnel.TokenStore
|
tokenStore tunnel.TokenStore
|
||||||
|
|
||||||
|
// ⚠️ DEV / TEST ONLY:跳過 TLS 憑證驗證(連自簽憑證的 stage / 測試環境用)。
|
||||||
|
// 由 NewApp() 從 VISIONA_INSECURE_SKIP_TLS_VERIFY 讀一次(唯一讀取點,規則見
|
||||||
|
// tunnel.IsInsecureSkipTLSVerify),之後 tryStartTunnel(exchange + tunnel WSS)
|
||||||
|
// 與 TestConnection binding(設定頁「測試連線」)都共用這個欄位,避免規則飄移。
|
||||||
|
// 正式環境絕不可開(中間人攻擊可竊聽 / 竄改流量)。
|
||||||
|
insecureSkipTLSVerify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 建立 App 實例。
|
// NewApp 建立 App 實例。
|
||||||
@ -168,6 +175,10 @@ func NewApp() *App {
|
|||||||
// R5-5a:沒插硬體就顯示空白狀態(由 UI 處理),一律真實硬體路徑
|
// R5-5a:沒插硬體就顯示空白狀態(由 UI 處理),一律真實硬體路徑
|
||||||
return &App{
|
return &App{
|
||||||
pythonMode: mode,
|
pythonMode: mode,
|
||||||
|
// ⚠️ DEV / TEST ONLY 開關的唯一 env 讀取點(詳見 App struct 欄位註解)。
|
||||||
|
// 在 NewApp 讀(而非 tryStartTunnel)是為了讓 TestConnection 在 tunnel
|
||||||
|
// 未啟動(如 RelayURL 未設定、early return)時也拿得到同一份判斷。
|
||||||
|
insecureSkipTLSVerify: tunnel.IsInsecureSkipTLSVerify(os.Getenv(tunnel.InsecureSkipTLSVerifyEnvVar)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -389,8 +400,19 @@ func (a *App) tryStartTunnel() {
|
|||||||
if cloudAPIURL == "" {
|
if cloudAPIURL == "" {
|
||||||
cloudAPIURL = "https://api.visionA.cloud" // placeholder;mock 模式下用不到
|
cloudAPIURL = "https://api.visionA.cloud" // placeholder;mock 模式下用不到
|
||||||
}
|
}
|
||||||
|
// ⚠️ DEV / TEST ONLY:跳過 TLS 憑證驗證(連自簽憑證的 stage / 測試環境用)。
|
||||||
|
// production-safe default = false;必須明確設 VISIONA_INSECURE_SKIP_TLS_VERIFY=true 才開。
|
||||||
|
// 開時 exchange http client + tunnel WSS dialer + TestConnection 都會跳過憑證驗證。
|
||||||
|
// 值由 NewApp() 讀 env 一次寫入 a.insecureSkipTLSVerify(唯一讀取點),這裡只消費。
|
||||||
|
// 正式環境絕不可開(中間人攻擊可竊聽 / 竄改流量)。
|
||||||
|
insecureSkipTLSVerify := a.insecureSkipTLSVerify
|
||||||
|
if insecureSkipTLSVerify {
|
||||||
|
a.appLog("tunnel: ⚠️ TLS verification DISABLED (VISIONA_INSECURE_SKIP_TLS_VERIFY=true) — DEV/TEST ONLY, never use in production")
|
||||||
|
}
|
||||||
|
|
||||||
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
|
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
|
||||||
exchanger.MockMode = mockMode
|
exchanger.MockMode = mockMode
|
||||||
|
exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify
|
||||||
|
|
||||||
// Startup log 明確標示當前模式;mock=true 時用 WARN 級字眼讓 dev 一眼看到。
|
// Startup log 明確標示當前模式;mock=true 時用 WARN 級字眼讓 dev 一眼看到。
|
||||||
if mockMode {
|
if mockMode {
|
||||||
@ -419,6 +441,8 @@ func (a *App) tryStartTunnel() {
|
|||||||
Exchanger: exchanger,
|
Exchanger: exchanger,
|
||||||
ReconnectMode: reconnectMode,
|
ReconnectMode: reconnectMode,
|
||||||
TokenStore: a.tokenStore, // AB7:encrypted file;nil 時 Manager fallback 到 in-memory
|
TokenStore: a.tokenStore, // AB7:encrypted file;nil 時 Manager fallback 到 in-memory
|
||||||
|
// ⚠️ DEV / TEST ONLY:tunnel WSS 撥號跳過 TLS 驗證(連自簽憑證 stage 用)。
|
||||||
|
InsecureSkipTLSVerify: insecureSkipTLSVerify,
|
||||||
// Logger:TODO Phase 1 改用結構化 logger
|
// Logger:TODO Phase 1 改用結構化 logger
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
68
local-agent/visiona-agent/build/darwin/Info.dev.plist
Normal file
68
local-agent/visiona-agent/build/darwin/Info.dev.plist
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{.Name}}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,9 +1,9 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[78565,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ClientPageRoot"]
|
2:I[78565,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ClientPageRoot"]
|
||||||
3:I[52683,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/d6dfeddbc30b2115.js"],"default"]
|
3:I[52683,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f04ca8af3a2f14a5.js"],"default"]
|
||||||
6:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
6:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
||||||
7:"$Sreact.suspense"
|
7:"$Sreact.suspense"
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/d6dfeddbc30b2115.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/f04ca8af3a2f14a5.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"loading":null,"isPartial":false}
|
||||||
4:{}
|
4:{}
|
||||||
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
|
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
|
||||||
8:null
|
8:null
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"LocaleProvider"]
|
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"LocaleProvider"]
|
||||||
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"LocaleSync"]
|
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"LocaleSync"]
|
||||||
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"ThemeProvider"]
|
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"ThemeProvider"]
|
||||||
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"TooltipProvider"]
|
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"TooltipProvider"]
|
||||||
6:I[3097,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
6:I[3097,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
7:I[99299,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
7:I[99299,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"Toaster"]
|
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"Toaster"]
|
||||||
9:I[78565,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ClientPageRoot"]
|
9:I[78565,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ClientPageRoot"]
|
||||||
a:I[52683,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/d6dfeddbc30b2115.js"],"default"]
|
a:I[52683,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f04ca8af3a2f14a5.js"],"default"]
|
||||||
d:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
d:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
||||||
e:"$Sreact.suspense"
|
e:"$Sreact.suspense"
|
||||||
10:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
10:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
||||||
12:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
12:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
||||||
14:I[16153,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
14:I[16153,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"P":null,"b":"bNfP9AtF7RvuNYXedUUKm","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/8c0a22761cbf2865.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/45d5f77c4ae14bfd.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L9",null,{"Component":"$a","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@b","$@c"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/d6dfeddbc30b2115.js","async":true,"nonce":"$undefined"}]],["$","$Ld",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$L10",null,{"children":"$L11"}],["$","div",null,{"hidden":true,"children":["$","$L12",null,{"children":["$","$e",null,{"name":"Next.Metadata","children":"$L13"}]}]}],null]}],false]],"m":"$undefined","G":["$14",[]],"S":true}
|
0:{"P":null,"b":"xUG58KEr_UmI6n37ubQud","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/6c9a7a09adf93ec8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/da91cad579900bca.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L9",null,{"Component":"$a","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@b","$@c"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/f04ca8af3a2f14a5.js","async":true,"nonce":"$undefined"}]],["$","$Ld",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$L10",null,{"children":"$L11"}],["$","div",null,{"hidden":true,"children":["$","$L12",null,{"children":["$","$e",null,{"name":"Next.Metadata","children":"$L13"}]}]}],null]}],false]],"m":"$undefined","G":["$14",[]],"S":true}
|
||||||
b:{}
|
b:{}
|
||||||
c:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
|
c:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
|
||||||
11:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
11:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
|
|||||||
@ -2,4 +2,4 @@
|
|||||||
2:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
2:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
||||||
3:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
3:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
||||||
4:"$Sreact.suspense"
|
4:"$Sreact.suspense"
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]}]}]}],null]}],"loading":null,"isPartial":false}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]}]}]}],null]}],"loading":null,"isPartial":false}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"LocaleProvider"]
|
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"LocaleProvider"]
|
||||||
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"LocaleSync"]
|
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"LocaleSync"]
|
||||||
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"ThemeProvider"]
|
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"ThemeProvider"]
|
||||||
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"TooltipProvider"]
|
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"TooltipProvider"]
|
||||||
6:I[3097,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
6:I[3097,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
7:I[99299,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
7:I[99299,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"Toaster"]
|
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"Toaster"]
|
||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/8c0a22761cbf2865.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/45d5f77c4ae14bfd.js","async":true}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","template":["$","$L7",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],"loading":null,"isPartial":false}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/6c9a7a09adf93ec8.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/da91cad579900bca.js","async":true}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","template":["$","$L7",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],"loading":null,"isPartial":false}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,18 +1,18 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleProvider"]
|
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleProvider"]
|
||||||
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleSync"]
|
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleSync"]
|
||||||
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ThemeProvider"]
|
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ThemeProvider"]
|
||||||
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"TooltipProvider"]
|
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"TooltipProvider"]
|
||||||
6:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
6:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
7:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
7:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"Toaster"]
|
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"Toaster"]
|
||||||
9:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
9:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
||||||
a:"$Sreact.suspense"
|
a:"$Sreact.suspense"
|
||||||
c:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
c:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
||||||
e:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
e:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
||||||
10:I[16153,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
10:I[16153,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"P":null,"b":"bNfP9AtF7RvuNYXedUUKm","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/8c0a22761cbf2865.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/45d5f77c4ae14bfd.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/f81ac11fa6af3b6b.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10","$undefined"],"S":true}
|
0:{"P":null,"b":"xUG58KEr_UmI6n37ubQud","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/6c9a7a09adf93ec8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/da91cad579900bca.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/f81ac11fa6af3b6b.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10","$undefined"],"S":true}
|
||||||
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
b:null
|
b:null
|
||||||
f:[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]
|
f:[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
2:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
||||||
3:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
3:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
||||||
4:"$Sreact.suspense"
|
4:"$Sreact.suspense"
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]}]}]}],null]}],"loading":null,"isPartial":false}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]}]}]}],null]}],"loading":null,"isPartial":false}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleProvider"]
|
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleProvider"]
|
||||||
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleSync"]
|
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleSync"]
|
||||||
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ThemeProvider"]
|
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ThemeProvider"]
|
||||||
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"TooltipProvider"]
|
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"TooltipProvider"]
|
||||||
6:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
6:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
7:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
7:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"Toaster"]
|
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"Toaster"]
|
||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/8c0a22761cbf2865.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/45d5f77c4ae14bfd.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/f81ac11fa6af3b6b.js","async":true}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","template":["$","$L7",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],"loading":null,"isPartial":false}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/6c9a7a09adf93ec8.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/da91cad579900bca.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/f81ac11fa6af3b6b.js","async":true}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","template":["$","$L7",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],"loading":null,"isPartial":false}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
2:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
||||||
3:"$Sreact.suspense"
|
3:"$Sreact.suspense"
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"loading":null,"isPartial":false}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"loading":null,"isPartial":false}
|
||||||
4:null
|
4:null
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
2:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
3:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
3:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"loading":null,"isPartial":false}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"loading":null,"isPartial":false}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"buildId":"bNfP9AtF7RvuNYXedUUKm","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"/_not-found","paramType":null,"paramKey":"/_not-found","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
0:{"buildId":"xUG58KEr_UmI6n37ubQud","tree":{"name":"","paramType":null,"paramKey":"","hasRuntimePrefetch":false,"slots":{"children":{"name":"/_not-found","paramType":null,"paramKey":"/_not-found","hasRuntimePrefetch":false,"slots":{"children":{"name":"__PAGE__","paramType":null,"paramKey":"__PAGE__","hasRuntimePrefetch":false,"slots":null,"isRootLayout":false}},"isRootLayout":false}},"isRootLayout":true},"staleTime":300}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,18 +1,18 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleProvider"]
|
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleProvider"]
|
||||||
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleSync"]
|
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"LocaleSync"]
|
||||||
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ThemeProvider"]
|
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ThemeProvider"]
|
||||||
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"TooltipProvider"]
|
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"TooltipProvider"]
|
||||||
6:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
6:I[3097,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
7:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
7:I[99299,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"Toaster"]
|
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"Toaster"]
|
||||||
9:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
9:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
||||||
a:"$Sreact.suspense"
|
a:"$Sreact.suspense"
|
||||||
c:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
c:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
||||||
e:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
e:I[36345,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
||||||
10:I[16153,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
10:I[16153,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"P":null,"b":"bNfP9AtF7RvuNYXedUUKm","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/8c0a22761cbf2865.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/45d5f77c4ae14bfd.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/f81ac11fa6af3b6b.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10","$undefined"],"S":true}
|
0:{"P":null,"b":"xUG58KEr_UmI6n37ubQud","c":["","_not-found",""],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/6c9a7a09adf93ec8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/da91cad579900bca.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/f81ac11fa6af3b6b.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:0:props:children:1:props:children:props:children:props:children:1:props:children:props:children:0:props:notFound:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],null,["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,false]},null,false,false]},null,false,false],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$Lc",null,{"children":"$Ld"}],["$","div",null,{"hidden":true,"children":["$","$Le",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$Lf"}]}]}],null]}],false]],"m":"$undefined","G":["$10","$undefined"],"S":true}
|
||||||
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
b:null
|
b:null
|
||||||
f:[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]
|
f:[["$","title","0",{"children":"visionA Agent"}],["$","meta","1",{"name":"description","content":"本機桌面代理(local agent / tunnel bridge),讓 Kneron 邊緣裝置安全連上 visionA 雲端。"}]]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,20 +1,20 @@
|
|||||||
1:"$Sreact.fragment"
|
1:"$Sreact.fragment"
|
||||||
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"LocaleProvider"]
|
2:I[44674,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"LocaleProvider"]
|
||||||
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"LocaleSync"]
|
3:I[88918,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"LocaleSync"]
|
||||||
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"ThemeProvider"]
|
4:I[27423,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"ThemeProvider"]
|
||||||
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"TooltipProvider"]
|
5:I[46798,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"TooltipProvider"]
|
||||||
6:I[3097,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
6:I[3097,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
7:I[99299,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
7:I[99299,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js"],"Toaster"]
|
8:I[13354,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js"],"Toaster"]
|
||||||
9:I[78565,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ClientPageRoot"]
|
9:I[78565,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ClientPageRoot"]
|
||||||
a:I[52683,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/45d5f77c4ae14bfd.js","/_next/static/chunks/d6dfeddbc30b2115.js"],"default"]
|
a:I[52683,["/_next/static/chunks/594c88e889e6e4db.js","/_next/static/chunks/da91cad579900bca.js","/_next/static/chunks/f04ca8af3a2f14a5.js"],"default"]
|
||||||
d:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
d:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"OutletBoundary"]
|
||||||
e:"$Sreact.suspense"
|
e:"$Sreact.suspense"
|
||||||
10:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
10:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"ViewportBoundary"]
|
||||||
12:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
12:I[36345,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"MetadataBoundary"]
|
||||||
14:I[16153,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
14:I[16153,["/_next/static/chunks/f81ac11fa6af3b6b.js"],"default"]
|
||||||
:HL["/_next/static/chunks/8c0a22761cbf2865.css","style"]
|
:HL["/_next/static/chunks/6c9a7a09adf93ec8.css","style"]
|
||||||
0:{"P":null,"b":"bNfP9AtF7RvuNYXedUUKm","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/8c0a22761cbf2865.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/45d5f77c4ae14bfd.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L9",null,{"Component":"$a","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@b","$@c"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/d6dfeddbc30b2115.js","async":true,"nonce":"$undefined"}]],["$","$Ld",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$L10",null,{"children":"$L11"}],["$","div",null,{"hidden":true,"children":["$","$L12",null,{"children":["$","$e",null,{"name":"Next.Metadata","children":"$L13"}]}]}],null]}],false]],"m":"$undefined","G":["$14",[]],"S":true}
|
0:{"P":null,"b":"xUG58KEr_UmI6n37ubQud","c":["",""],"q":"","i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/6c9a7a09adf93ec8.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/594c88e889e6e4db.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/da91cad579900bca.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"zh-Hant","suppressHydrationWarning":true,"children":["$","body",null,{"className":"min-h-dvh","children":["$","$L2",null,{"children":[["$","$L3",null,{}],["$","$L4",null,{"children":["$","$L5",null,{"delayDuration":0,"children":[["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}],["$","$L8",null,{"richColors":true,"closeButton":true,"position":"top-right"}]]}]}]]}]}]}]]}],{"children":[["$","$1","c",{"children":[["$","$L9",null,{"Component":"$a","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@b","$@c"]}}],[["$","script","script-0",{"src":"/_next/static/chunks/f04ca8af3a2f14a5.js","async":true,"nonce":"$undefined"}]],["$","$Ld",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],{},null,false,false]},null,false,false],["$","$1","h",{"children":[null,["$","$L10",null,{"children":"$L11"}],["$","div",null,{"hidden":true,"children":["$","$L12",null,{"children":["$","$e",null,{"name":"Next.Metadata","children":"$L13"}]}]}],null]}],false]],"m":"$undefined","G":["$14",[]],"S":true}
|
||||||
b:{}
|
b:{}
|
||||||
c:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
|
c:"$0:f:0:1:1:children:0:props:children:0:props:serverProvidedParams:params"
|
||||||
11:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
11:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
|
|||||||
@ -26,6 +26,7 @@ package tunnel
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@ -71,6 +72,11 @@ type Client struct {
|
|||||||
token string
|
token string
|
||||||
localAddr string // 本機 server 位址,例:127.0.0.1:3721
|
localAddr string // 本機 server 位址,例:127.0.0.1:3721
|
||||||
|
|
||||||
|
// insecureSkipTLSVerify = true 時,撥 WSS 用的 websocket.Dialer 會跳過 TLS
|
||||||
|
// 憑證驗證。⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的
|
||||||
|
// stage / 測試 relay。由 Manager 從 VISIONA_INSECURE_SKIP_TLS_VERIFY 傳入。
|
||||||
|
insecureSkipTLSVerify bool
|
||||||
|
|
||||||
logger Logger
|
logger Logger
|
||||||
|
|
||||||
// Hooks 讓外層 Manager 觀察 session lifecycle(AB5)。
|
// Hooks 讓外層 Manager 觀察 session lifecycle(AB5)。
|
||||||
@ -130,6 +136,15 @@ func (c *Client) SetHooks(h ClientHooks) {
|
|||||||
c.hooks = h
|
c.hooks = h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInsecureSkipTLSVerify 設定撥 WSS 時是否跳過 TLS 憑證驗證。
|
||||||
|
//
|
||||||
|
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的 stage / 測試 relay。
|
||||||
|
// 必須在 Start() 前呼叫;run goroutine 啟動後改值不保證生效。
|
||||||
|
// 由 Manager 從 VISIONA_INSECURE_SKIP_TLS_VERIFY 傳入。
|
||||||
|
func (c *Client) SetInsecureSkipTLSVerify(skip bool) {
|
||||||
|
c.insecureSkipTLSVerify = skip
|
||||||
|
}
|
||||||
|
|
||||||
// Start 在背景啟動 tunnel 連線迴圈,失敗時以指數退避重連。
|
// Start 在背景啟動 tunnel 連線迴圈,失敗時以指數退避重連。
|
||||||
// 只能呼叫一次;重複呼叫行為未定義。
|
// 只能呼叫一次;重複呼叫行為未定義。
|
||||||
func (c *Client) Start() {
|
func (c *Client) Start() {
|
||||||
@ -184,9 +199,64 @@ func (c *Client) run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// relayConnectPath 是 relay 端 tunnel 連線的固定 endpoint path。
|
||||||
|
//
|
||||||
|
// 架構上 relay base URL 不含 path(exchange 回的 relay_url / agentconfig 預設值都是裸
|
||||||
|
// host),實際 WS endpoint 在 /tunnel/connect。normalizeRelayURL 負責兜底補上。
|
||||||
|
const relayConnectPath = "/tunnel/connect"
|
||||||
|
|
||||||
|
// normalizeRelayURL 確保 relay URL 帶上 /tunnel/connect path。
|
||||||
|
//
|
||||||
|
// 為什麼需要:relay URL 有三個來源(env VISIONA_RELAY_URL / exchange 回的 relay_url /
|
||||||
|
// agentconfig DefaultRelayURL),其中後兩者是裸 host(無 path)。若直接 Dial 裸 host,
|
||||||
|
// path 會是 "/",被 relay 前面的 nginx location / 導去 Next.js frontend → 非 WS endpoint
|
||||||
|
// → "websocket: bad handshake"。此函式在連線前統一補 path。
|
||||||
|
//
|
||||||
|
// 冪等規則(補不補的判斷):
|
||||||
|
// - path 為空 或 只有 "/" → 補上 /tunnel/connect
|
||||||
|
// - path 已是非根路徑(使用者 env 帶了完整 URL,含已是 /tunnel/connect)→ 尊重既有、不動
|
||||||
|
// (避免重複加成 /tunnel/connect/tunnel/connect)
|
||||||
|
//
|
||||||
|
// robust 處理:保留既有 query(?token= 在呼叫端另外附加);trailing slash 視為「只有 /」
|
||||||
|
// 補 path;URL 無法 parse 時原樣回傳(讓呼叫端的 url.Parse 自己報錯,不在此吞錯)。
|
||||||
|
func normalizeRelayURL(relayURL string) string {
|
||||||
|
u, err := url.Parse(relayURL)
|
||||||
|
if err != nil {
|
||||||
|
return relayURL
|
||||||
|
}
|
||||||
|
if u.Path == "" || u.Path == "/" {
|
||||||
|
u.Path = relayConnectPath
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSConfigForDial 建立撥 WSS 用的 *tls.Config。
|
||||||
|
//
|
||||||
|
// 核心:NextProtos 強制只談 "http/1.1"(ALPN)。
|
||||||
|
//
|
||||||
|
// 為什麼正式 + insecure 兩種情況都要:WebSocket 升級依賴 HTTP/1.1 的 hop-by-hop
|
||||||
|
// header(Connection: Upgrade / Upgrade: websocket)。若 ALPN 留空,公網反向代理
|
||||||
|
// (nginx 等)會自由選 HTTP/2;而 RFC 7540 禁止 h2 攜帶這些 hop-by-hop header,
|
||||||
|
// 反代會合法剝除 → backend 收不到 upgrade → "websocket: bad handshake"。
|
||||||
|
// 把 ALPN 釘死 http/1.1 即可確保升級 header 不被 h2 規則剝除。
|
||||||
|
//
|
||||||
|
// skipVerify = true 時額外跳過 TLS 憑證驗證(⚠️ DEV / TEST ONLY,連自簽憑證 stage 用;
|
||||||
|
// 正式環境絕不可開;WARNING log 由 app.go 啟動時統一印一次)。
|
||||||
|
//
|
||||||
|
// Export 原因:app 層的 TestConnection binding(設定頁「測試連線」按鈕)是第三條
|
||||||
|
// TLS 撥號路徑,必須與 tunnel client 主路徑共用同一份 TLS 設定(ALPN + skip 同源),
|
||||||
|
// 避免「測試連線失敗但 tunnel 其實連得上」的規則飄移。
|
||||||
|
func TLSConfigForDial(skipVerify bool) *tls.Config {
|
||||||
|
return &tls.Config{
|
||||||
|
NextProtos: []string{"http/1.1"},
|
||||||
|
InsecureSkipVerify: skipVerify, //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// connect 建立一次 tunnel session 並阻塞直到 session 關閉。
|
// connect 建立一次 tunnel session 並阻塞直到 session 關閉。
|
||||||
func (c *Client) connect() error {
|
func (c *Client) connect() error {
|
||||||
u, err := url.Parse(c.relayURL)
|
// 兜底補 /tunnel/connect path(relay base URL 不含 path;見 normalizeRelayURL)。
|
||||||
|
u, err := url.Parse(normalizeRelayURL(c.relayURL))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -198,7 +268,12 @@ func (c *Client) connect() error {
|
|||||||
|
|
||||||
c.logger.Printf("[tunnel] connecting to %s", u.Host)
|
c.logger.Printf("[tunnel] connecting to %s", u.Host)
|
||||||
|
|
||||||
dialer := websocket.DefaultDialer
|
// 複製一份 DefaultDialer(不污染 package 全域),覆寫 TLSClientConfig:
|
||||||
|
// 不管 insecure 與否,都強制 ALPN 只談 http/1.1(見 TLSConfigForDial 註解)。
|
||||||
|
d := *websocket.DefaultDialer
|
||||||
|
d.TLSClientConfig = TLSConfigForDial(c.insecureSkipTLSVerify)
|
||||||
|
dialer := &d
|
||||||
|
|
||||||
conn, _, err := dialer.Dial(u.String(), nil)
|
conn, _, err := dialer.Dial(u.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package tunnel
|
package tunnel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -57,3 +59,218 @@ func TestKeepAliveIntervalDefault(t *testing.T) {
|
|||||||
t.Errorf("KeepAliveInterval = %v, want 10s (tunnel.md §4.2 M-5)", KeepAliveInterval)
|
t.Errorf("KeepAliveInterval = %v, want 10s (tunnel.md §4.2 M-5)", KeepAliveInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClientInsecureSkipTLSVerifyDefaultFalse 驗證 NewClient 預設不跳過 TLS 驗證
|
||||||
|
// (production-safe default)。
|
||||||
|
func TestClientInsecureSkipTLSVerifyDefaultFalse(t *testing.T) {
|
||||||
|
c := NewClient("wss://relay/tunnel/connect", "tok", "127.0.0.1:1234", nil)
|
||||||
|
if c.insecureSkipTLSVerify {
|
||||||
|
t.Error("NewClient should default insecureSkipTLSVerify=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientSetInsecureSkipTLSVerify 驗證 setter 正確翻轉欄位。
|
||||||
|
func TestClientSetInsecureSkipTLSVerify(t *testing.T) {
|
||||||
|
c := NewClient("wss://relay/tunnel/connect", "tok", "127.0.0.1:1234", nil)
|
||||||
|
c.SetInsecureSkipTLSVerify(true)
|
||||||
|
if !c.insecureSkipTLSVerify {
|
||||||
|
t.Error("SetInsecureSkipTLSVerify(true) should set field true")
|
||||||
|
}
|
||||||
|
c.SetInsecureSkipTLSVerify(false)
|
||||||
|
if c.insecureSkipTLSVerify {
|
||||||
|
t.Error("SetInsecureSkipTLSVerify(false) should set field false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManagerPropagatesInsecureSkipTLSVerify 驗證 Manager.Config.InsecureSkipTLSVerify
|
||||||
|
// 會經 newClientLocked 傳到 Client。
|
||||||
|
func TestManagerPropagatesInsecureSkipTLSVerify(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
skip bool
|
||||||
|
}{
|
||||||
|
{"enabled", true},
|
||||||
|
{"disabled", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
m := NewManager(Config{
|
||||||
|
RelayURL: "wss://relay/tunnel/connect",
|
||||||
|
SessionToken: "vAs_" + repeat64,
|
||||||
|
LocalAddr: "127.0.0.1:1234",
|
||||||
|
InsecureSkipTLSVerify: tc.skip,
|
||||||
|
})
|
||||||
|
m.mu.Lock()
|
||||||
|
c := m.newClientLocked()
|
||||||
|
m.mu.Unlock()
|
||||||
|
if c.insecureSkipTLSVerify != tc.skip {
|
||||||
|
t.Errorf("client.insecureSkipTLSVerify = %v, want %v", c.insecureSkipTLSVerify, tc.skip)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat64 是 64 個 'a',組成合法長度的假 session token(vAs_ + 64 hex)。
|
||||||
|
const repeat64 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
||||||
|
// TestTLSConfigForDialForcesHTTP11ALPN 驗證撥 WSS 的 TLS config 強制 ALPN http/1.1,
|
||||||
|
// 且 insecure 開 / 關 兩種分支都有設 NextProtos。
|
||||||
|
//
|
||||||
|
// 動機:ALPN 留空 → 公網反代選 HTTP/2 → h2 剝除 Connection:Upgrade → bad handshake。
|
||||||
|
// 釘死 http/1.1 才能讓 WebSocket 升級 header 不被剝。
|
||||||
|
func TestTLSConfigForDialForcesHTTP11ALPN(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
skipVerify bool
|
||||||
|
}{
|
||||||
|
{"正式分支(不 skip TLS 驗證)", false},
|
||||||
|
{"insecure 分支(skip TLS 驗證)", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg := TLSConfigForDial(tc.skipVerify)
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("TLSConfigForDial returned nil")
|
||||||
|
}
|
||||||
|
// NextProtos 必須正好是 ["http/1.1"](強制 h1.1,不留空讓反代選 h2)
|
||||||
|
if len(cfg.NextProtos) != 1 || cfg.NextProtos[0] != "http/1.1" {
|
||||||
|
t.Errorf("NextProtos = %v, want [\"http/1.1\"]", cfg.NextProtos)
|
||||||
|
}
|
||||||
|
// InsecureSkipVerify 應反映入參(ALPN 修正不得改動 skip 邏輯本身)
|
||||||
|
if cfg.InsecureSkipVerify != tc.skipVerify {
|
||||||
|
t.Errorf("InsecureSkipVerify = %v, want %v", cfg.InsecureSkipVerify, tc.skipVerify)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRelayURL 驗證 relay URL 兜底補 /tunnel/connect path 的邏輯。
|
||||||
|
//
|
||||||
|
// 動機:exchange 回的 relay_url / agentconfig 預設值是裸 host(無 path),直接 Dial
|
||||||
|
// path=/ 會被 nginx 導去 frontend → bad handshake。normalizeRelayURL 補 path;
|
||||||
|
// 但對已含非根 path 的 URL 必須冪等(不重複加)。
|
||||||
|
func TestNormalizeRelayURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "base 無 path(stage 實際情境)",
|
||||||
|
in: "wss://stage-9527.innovedus.com:9527",
|
||||||
|
want: "wss://stage-9527.innovedus.com:9527/tunnel/connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "base path = /(trailing slash)",
|
||||||
|
in: "wss://stage-9527.innovedus.com:9527/",
|
||||||
|
want: "wss://stage-9527.innovedus.com:9527/tunnel/connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已含 /tunnel/connect(冪等、不重複加)",
|
||||||
|
in: "wss://relay.visionA.cloud/tunnel/connect",
|
||||||
|
want: "wss://relay.visionA.cloud/tunnel/connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已含其他非根 path(尊重既有)",
|
||||||
|
in: "wss://relay.example.com/custom/path",
|
||||||
|
want: "wss://relay.example.com/custom/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ws(非 TLS)裸 host 也補",
|
||||||
|
in: "ws://127.0.0.1:8080",
|
||||||
|
want: "ws://127.0.0.1:8080/tunnel/connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "agentconfig 預設值(裸 host)",
|
||||||
|
in: "wss://relay.visionA.cloud",
|
||||||
|
want: "wss://relay.visionA.cloud/tunnel/connect",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "base 無 path 但帶既有 query(query 保留、補 path)",
|
||||||
|
in: "wss://host:9527?foo=bar",
|
||||||
|
want: "wss://host:9527/tunnel/connect?foo=bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "base path=/ 帶既有 query",
|
||||||
|
in: "wss://host:9527/?foo=bar",
|
||||||
|
want: "wss://host:9527/tunnel/connect?foo=bar",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := normalizeRelayURL(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("normalizeRelayURL(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRelayURLThenTokenAppended 驗證 normalize 後,connect() 附加 ?token=
|
||||||
|
// 的邏輯仍能正確把 token 加在最終 URL(path 已補好、token query 正確)。
|
||||||
|
//
|
||||||
|
// 複製 connect() 的 URL 組裝步驟(normalize → parse → set token query)以驗證最終形狀,
|
||||||
|
// 不實際 Dial。
|
||||||
|
func TestNormalizeRelayURLThenTokenAppended(t *testing.T) {
|
||||||
|
const token = "vAs_" + repeat64
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
relay string
|
||||||
|
wantPath string
|
||||||
|
wantToken string
|
||||||
|
wantHasPath bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "裸 host → 補 path 且 token 正確",
|
||||||
|
relay: "wss://stage-9527.innovedus.com:9527",
|
||||||
|
wantPath: "/tunnel/connect",
|
||||||
|
wantToken: token,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "已含 path → 不重複加且 token 正確",
|
||||||
|
relay: "wss://relay.visionA.cloud/tunnel/connect",
|
||||||
|
wantPath: "/tunnel/connect",
|
||||||
|
wantToken: token,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "裸 host 帶既有 query → path 補上、既有 query + token 並存",
|
||||||
|
relay: "wss://host:9527?foo=bar",
|
||||||
|
wantPath: "/tunnel/connect",
|
||||||
|
wantToken: token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// 複製 connect() 的組裝邏輯
|
||||||
|
u, err := url.Parse(normalizeRelayURL(tc.relay))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if token != "" {
|
||||||
|
q.Set("token", token)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
if u.Path != tc.wantPath {
|
||||||
|
t.Errorf("path = %q, want %q", u.Path, tc.wantPath)
|
||||||
|
}
|
||||||
|
if got := u.Query().Get("token"); got != tc.wantToken {
|
||||||
|
t.Errorf("token query = %q, want %q", got, tc.wantToken)
|
||||||
|
}
|
||||||
|
// 最終 URL 字串應同時含 path 與 token=
|
||||||
|
final := u.String()
|
||||||
|
if !strings.Contains(final, tc.wantPath) {
|
||||||
|
t.Errorf("final URL %q missing path %q", final, tc.wantPath)
|
||||||
|
}
|
||||||
|
if !strings.Contains(final, "token=") {
|
||||||
|
t.Errorf("final URL %q missing token= query", final)
|
||||||
|
}
|
||||||
|
// 防回歸:絕不可出現重複 path
|
||||||
|
if strings.Contains(final, "/tunnel/connect/tunnel/connect") {
|
||||||
|
t.Errorf("final URL %q has duplicated /tunnel/connect path", final)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -100,6 +100,12 @@ type Config struct {
|
|||||||
|
|
||||||
// ReconnectMode 控制 max retry 耗盡後的行為(預設 Auto)。
|
// ReconnectMode 控制 max retry 耗盡後的行為(預設 Auto)。
|
||||||
ReconnectMode ReconnectMode
|
ReconnectMode ReconnectMode
|
||||||
|
|
||||||
|
// InsecureSkipTLSVerify = true 時,tunnel client 撥 WSS 會跳過 TLS 憑證驗證。
|
||||||
|
//
|
||||||
|
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的 stage / 測試 relay。
|
||||||
|
// 由 app.go 從 VISIONA_INSECURE_SKIP_TLS_VERIFY 讀入後傳入,見 IsInsecureSkipTLSVerify。
|
||||||
|
InsecureSkipTLSVerify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// 預設值。
|
// 預設值。
|
||||||
@ -318,6 +324,8 @@ func (m *Manager) startLocked(ctx context.Context) error {
|
|||||||
// newClientLocked 建立 Client 並掛 hooks。必須持有 m.mu。
|
// newClientLocked 建立 Client 並掛 hooks。必須持有 m.mu。
|
||||||
func (m *Manager) newClientLocked() *Client {
|
func (m *Manager) newClientLocked() *Client {
|
||||||
c := NewClient(m.cfg.RelayURL, m.cfg.SessionToken, m.cfg.LocalAddr, m.cfg.Logger)
|
c := NewClient(m.cfg.RelayURL, m.cfg.SessionToken, m.cfg.LocalAddr, m.cfg.Logger)
|
||||||
|
// ⚠️ DEV / TEST ONLY:把跳過 TLS 驗證的開關傳給 client(連自簽憑證 stage 用)。
|
||||||
|
c.SetInsecureSkipTLSVerify(m.cfg.InsecureSkipTLSVerify)
|
||||||
c.SetHooks(ClientHooks{
|
c.SetHooks(ClientHooks{
|
||||||
OnDialAttempt: func(attempt int) {
|
OnDialAttempt: func(attempt int) {
|
||||||
m.onDialAttempt(attempt)
|
m.onDialAttempt(attempt)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ package tunnel
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -45,6 +46,30 @@ func IsPairingMockOptIn(envValue string) bool {
|
|||||||
return strings.EqualFold(envValue, "true")
|
return strings.EqualFold(envValue, "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InsecureSkipTLSVerifyEnvVar 是控制「跳過 TLS 憑證驗證」的環境變數名。
|
||||||
|
//
|
||||||
|
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。
|
||||||
|
//
|
||||||
|
// 用途:連線到使用自簽憑證的 stage / 測試環境(例如 stage 用 self-signed cert,
|
||||||
|
// curl 需要 -k 才連得上)。Go 預設 client 遇自簽憑證會直接 x509 驗證失敗,
|
||||||
|
// 導致 pairing exchange 與 tunnel WSS 連線都無法建立。
|
||||||
|
//
|
||||||
|
// 預設行為(production-safe):unset / 任何 ≠ "true" 的值 → 維持安全的 TLS 驗證。
|
||||||
|
// 必須明確設成 "true"(不分大小寫)才會跳過驗證。
|
||||||
|
const InsecureSkipTLSVerifyEnvVar = "VISIONA_INSECURE_SKIP_TLS_VERIFY"
|
||||||
|
|
||||||
|
// IsInsecureSkipTLSVerify 根據環境變數判斷是否要跳過 TLS 憑證驗證。
|
||||||
|
//
|
||||||
|
// 規則比照 IsPairingMockOptIn(必須明確 opt-in):
|
||||||
|
// - "true"(不分大小寫)→ true
|
||||||
|
// - 其他任何值(unset / "false" / "1" / 拼錯字 / 前導空白 / 空字串)→ false
|
||||||
|
//
|
||||||
|
// ⚠️ 回傳 true 代表進入不安全模式(中間人攻擊可竊聽 / 竄改流量),僅供 dev/test。
|
||||||
|
// 抽出來的目的:app.go、exchanger、tunnel client 共用同一份判斷邏輯,避免規則飄移。
|
||||||
|
func IsInsecureSkipTLSVerify(envValue string) bool {
|
||||||
|
return strings.EqualFold(envValue, "true")
|
||||||
|
}
|
||||||
|
|
||||||
// ErrInvalidTokenFormat 表示 Pairing Token 不符合 vAc_ + 32 hex 格式。
|
// ErrInvalidTokenFormat 表示 Pairing Token 不符合 vAc_ + 32 hex 格式。
|
||||||
var ErrInvalidTokenFormat = errors.New("invalid pairing token format (expected vAc_ + 32 hex)")
|
var ErrInvalidTokenFormat = errors.New("invalid pairing token format (expected vAc_ + 32 hex)")
|
||||||
|
|
||||||
@ -89,19 +114,55 @@ type exchangeRequest struct {
|
|||||||
PairingToken string `json:"pairing_token"`
|
PairingToken string `json:"pairing_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// exchangeResponse 對齊雛形雲端 handler 回傳欄位。
|
// exchangeResponse 對齊雲端 /api/pairing/exchange 的成功 envelope(api/errors.go WriteSuccess):
|
||||||
// 雛形 handler 只回 session_token + expires_at;account / relay_url 視為選填
|
//
|
||||||
// (Phase 1 才有;沒有時 Manager 用既有 Config fallback)。
|
// {
|
||||||
|
// "success": true,
|
||||||
|
// "data": {
|
||||||
|
// "session_token": "vAs_...",
|
||||||
|
// "account": "...@visionA.local",
|
||||||
|
// "relay_url": "wss://...",
|
||||||
|
// "expires_at": "2026-07-21T00:00:00Z"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ⚠️ 歷史 contract drift:早期 agent 假設 session_token 在頂層(雛形註解寫「對齊雛形
|
||||||
|
// 雲端 handler」),但雲端後來統一改用 SuccessBody envelope(success + data),
|
||||||
|
// payload 全在 data. 底下。直接解頂層會讓 session_token 永遠為空、報
|
||||||
|
// "exchange response missing session_token"。此 struct 已對齊現行 envelope。
|
||||||
type exchangeResponse struct {
|
type exchangeResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data exchangeResponseData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeResponseData 是成功 envelope 的 data payload。
|
||||||
|
// 對齊雲端 api.PairingExchangeResponse 的 json 欄位。
|
||||||
|
type exchangeResponseData struct {
|
||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
Account string `json:"account,omitempty"`
|
Account string `json:"account,omitempty"`
|
||||||
RelayURL string `json:"relay_url,omitempty"`
|
RelayURL string `json:"relay_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// exchangeErrorResponse 對齊雛形雲端 401 body: { "code": "token_invalid" | ... }。
|
// exchangeErrorResponse 對齊雲端的錯誤 envelope(api/errors.go WriteError):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "success": false,
|
||||||
|
// "error": { "code": "INVALID_PAIRING_TOKEN", "message": "...", "request_id": "..." }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ⚠️ 同樣的 contract drift:早期 agent 假設 401 body 為頂層 { "code": "token_invalid" },
|
||||||
|
// 但雲端用 ErrorBody envelope,code 在 error. 底下、且為大寫常數(見
|
||||||
|
// api.ErrCodeInvalidPairingToken 等)。此 struct + mapExchangeErrorCode 已對齊現行格式。
|
||||||
type exchangeErrorResponse struct {
|
type exchangeErrorResponse struct {
|
||||||
Code string `json:"code"`
|
Success bool `json:"success"`
|
||||||
|
Error exchangeErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeErrorDetail 是錯誤 envelope 的 error payload。
|
||||||
|
type exchangeErrorDetail struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PairingExchanger 介面讓 Manager 在測試時能注入 fake(避免真的打 HTTP)。
|
// PairingExchanger 介面讓 Manager 在測試時能注入 fake(避免真的打 HTTP)。
|
||||||
@ -130,6 +191,15 @@ type HTTPPairingExchanger struct {
|
|||||||
// MockRelayURL 可覆寫 mock 模式下回傳的 relay URL;空值時不帶,讓 Manager
|
// MockRelayURL 可覆寫 mock 模式下回傳的 relay URL;空值時不帶,讓 Manager
|
||||||
// fallback 用 Config.RelayURL。
|
// fallback 用 Config.RelayURL。
|
||||||
MockRelayURL string
|
MockRelayURL string
|
||||||
|
|
||||||
|
// InsecureSkipTLSVerify = true 時,exchange 用的 http.Client 會跳過 TLS 憑證
|
||||||
|
// 驗證(tls.Config{InsecureSkipVerify: true})。
|
||||||
|
//
|
||||||
|
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的 stage / 測試環境。
|
||||||
|
// 由 VISIONA_INSECURE_SKIP_TLS_VERIFY 控制,見 IsInsecureSkipTLSVerify。
|
||||||
|
// 僅在 Client == nil(走預設 client 建構)時生效;若呼叫者注入自訂 Client,
|
||||||
|
// 以該 Client 自帶的 Transport 為準。
|
||||||
|
InsecureSkipTLSVerify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPPairingExchanger 建立一個生產預設實例。
|
// NewHTTPPairingExchanger 建立一個生產預設實例。
|
||||||
@ -178,6 +248,19 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
|
|||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 10 * time.Second}
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
}
|
}
|
||||||
|
if e.InsecureSkipTLSVerify && client.Transport == nil {
|
||||||
|
// ⚠️ DEV / TEST ONLY:跳過 TLS 憑證驗證,供連自簽憑證的 stage。
|
||||||
|
// 正式環境絕不可開。WARNING log 由 app.go 啟動時統一印一次。
|
||||||
|
//
|
||||||
|
// 注意:只在 client.Transport == nil(含 NewHTTPPairingExchanger 的預設 client)
|
||||||
|
// 時覆寫,避免踩掉呼叫者注入的自訂 Transport;且不直接改 e.Client(用區域複本),
|
||||||
|
// 不影響傳入的共享 client。
|
||||||
|
c := *client
|
||||||
|
c.Transport = &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
|
||||||
|
}
|
||||||
|
client = &c
|
||||||
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(exchangeRequest{PairingToken: pairingToken})
|
body, err := json.Marshal(exchangeRequest{PairingToken: pairingToken})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -205,18 +288,20 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
|
|||||||
if err := json.Unmarshal(respBody, &ok); err != nil {
|
if err := json.Unmarshal(respBody, &ok); err != nil {
|
||||||
return ExchangeResult{}, fmt.Errorf("decode exchange response: %w", err)
|
return ExchangeResult{}, fmt.Errorf("decode exchange response: %w", err)
|
||||||
}
|
}
|
||||||
if ok.SessionToken == "" {
|
// session_token 在 envelope 的 data. 底下(見 exchangeResponse 註解)。
|
||||||
|
if ok.Data.SessionToken == "" {
|
||||||
return ExchangeResult{}, errors.New("exchange response missing session_token")
|
return ExchangeResult{}, errors.New("exchange response missing session_token")
|
||||||
}
|
}
|
||||||
return ExchangeResult{
|
return ExchangeResult{
|
||||||
SessionToken: ok.SessionToken,
|
SessionToken: ok.Data.SessionToken,
|
||||||
Account: ok.Account,
|
Account: ok.Data.Account,
|
||||||
RelayURL: ok.RelayURL,
|
RelayURL: ok.Data.RelayURL,
|
||||||
}, nil
|
}, nil
|
||||||
case http.StatusUnauthorized:
|
case http.StatusUnauthorized:
|
||||||
var errResp exchangeErrorResponse
|
var errResp exchangeErrorResponse
|
||||||
_ = json.Unmarshal(respBody, &errResp)
|
_ = json.Unmarshal(respBody, &errResp)
|
||||||
return ExchangeResult{}, mapExchangeErrorCode(errResp.Code)
|
// error code 在 envelope 的 error. 底下(見 exchangeErrorResponse 註解)。
|
||||||
|
return ExchangeResult{}, mapExchangeErrorCode(errResp.Error.Code)
|
||||||
case http.StatusNotFound:
|
case http.StatusNotFound:
|
||||||
// AB11 尚未完成時的清楚訊號;訊息指引使用者往 mock_mode 或等 AB11。
|
// AB11 尚未完成時的清楚訊號;訊息指引使用者往 mock_mode 或等 AB11。
|
||||||
return ExchangeResult{}, fmt.Errorf("exchange endpoint not found (AB11 pending; set mock_mode or wait for backend deploy)")
|
return ExchangeResult{}, fmt.Errorf("exchange endpoint not found (AB11 pending; set mock_mode or wait for backend deploy)")
|
||||||
@ -225,15 +310,25 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapExchangeErrorCode 把雲端錯誤 envelope 的 error.code 映射成 agent 內部 sentinel error。
|
||||||
|
//
|
||||||
|
// code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等):
|
||||||
|
// - INVALID_PAIRING_TOKEN → ErrTokenInvalid
|
||||||
|
// - PAIRING_TOKEN_EXPIRED → ErrTokenExpired
|
||||||
|
// - PAIRING_TOKEN_USED → ErrTokenUsed
|
||||||
|
// - PAIRING_TOKEN_REVOKED → ErrTokenRevoked
|
||||||
|
//
|
||||||
|
// ⚠️ contract drift 修正:早期 agent 比對的是 token_invalid 等小寫值(與雲端不符),
|
||||||
|
// 導致所有 401 都落到 default 分支、前端拿不到正確的 error code 顯示對應 UI 文案。
|
||||||
func mapExchangeErrorCode(code string) error {
|
func mapExchangeErrorCode(code string) error {
|
||||||
switch code {
|
switch code {
|
||||||
case "token_invalid":
|
case "INVALID_PAIRING_TOKEN":
|
||||||
return ErrTokenInvalid
|
return ErrTokenInvalid
|
||||||
case "token_expired":
|
case "PAIRING_TOKEN_EXPIRED":
|
||||||
return ErrTokenExpired
|
return ErrTokenExpired
|
||||||
case "token_used":
|
case "PAIRING_TOKEN_USED":
|
||||||
return ErrTokenUsed
|
return ErrTokenUsed
|
||||||
case "token_revoked":
|
case "PAIRING_TOKEN_REVOKED":
|
||||||
return ErrTokenRevoked
|
return ErrTokenRevoked
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w (code=%q)", ErrTokenInvalid, code)
|
return fmt.Errorf("%w (code=%q)", ErrTokenInvalid, code)
|
||||||
|
|||||||
@ -17,8 +17,8 @@ func TestValidatePairingToken(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"vAc_0123456789abcdef0123456789abcdef", false},
|
{"vAc_0123456789abcdef0123456789abcdef", false},
|
||||||
{"vAc_ffffffffffffffffffffffffffffffff", false},
|
{"vAc_ffffffffffffffffffffffffffffffff", false},
|
||||||
{"vAs_0123456789abcdef0123456789abcdef", true}, // wrong prefix
|
{"vAs_0123456789abcdef0123456789abcdef", true}, // wrong prefix
|
||||||
{"vAc_0123456789abcdef0123456789abcde", true}, // 31 hex
|
{"vAc_0123456789abcdef0123456789abcde", true}, // 31 hex
|
||||||
{"vAc_0123456789abcdef0123456789abcdef0", true}, // 33 hex
|
{"vAc_0123456789abcdef0123456789abcdef0", true}, // 33 hex
|
||||||
{"vAc_ABCDEF0123456789ABCDEF0123456789", true}, // uppercase
|
{"vAc_ABCDEF0123456789ABCDEF0123456789", true}, // uppercase
|
||||||
{"vAc_ghijklmnopqrstuvwxyz0123456789ab", true}, // non-hex
|
{"vAc_ghijklmnopqrstuvwxyz0123456789ab", true}, // non-hex
|
||||||
@ -98,10 +98,14 @@ func TestExchangeRealSuccess(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// 雲端真實格式:{success:true, data:{...}}(api/errors.go WriteSuccess envelope)。
|
||||||
_ = json.NewEncoder(w).Encode(exchangeResponse{
|
_ = json.NewEncoder(w).Encode(exchangeResponse{
|
||||||
SessionToken: "vAs_" + strings.Repeat("a", 60) + "beef",
|
Success: true,
|
||||||
Account: "real@visionA.cloud",
|
Data: exchangeResponseData{
|
||||||
RelayURL: "wss://relay.visionA.cloud/tunnel/connect",
|
SessionToken: "vAs_" + strings.Repeat("a", 60) + "beef",
|
||||||
|
Account: "real@visionA.cloud",
|
||||||
|
RelayURL: "wss://relay.visionA.cloud/tunnel/connect",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@ -117,23 +121,84 @@ func TestExchangeRealSuccess(t *testing.T) {
|
|||||||
if r.Account != "real@visionA.cloud" {
|
if r.Account != "real@visionA.cloud" {
|
||||||
t.Errorf("Account = %q", r.Account)
|
t.Errorf("Account = %q", r.Account)
|
||||||
}
|
}
|
||||||
|
if r.RelayURL != "wss://relay.visionA.cloud/tunnel/connect" {
|
||||||
|
t.Errorf("RelayURL = %q", r.RelayURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExchangeRealRawEnvelope 用手寫 JSON 字串(複製 visionA-backend WriteSuccess 的
|
||||||
|
// 真實輸出形狀)驗證 agent 能正確從 data. 那層解出欄位,不依賴 agent 自己的 struct
|
||||||
|
// 編碼(避免 struct 改錯時測試跟著錯、形成假綠)。
|
||||||
|
func TestExchangeRealRawEnvelope(t *testing.T) {
|
||||||
|
const rawBody = `{"success":true,"data":{"session_token":"vAs_deadbeef","account":"raw@visionA.local","relay_url":"wss://raw.relay/tunnel/connect","expires_at":"2026-07-21T00:00:00Z"}}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(rawBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange against raw cloud envelope: %v", err)
|
||||||
|
}
|
||||||
|
if r.SessionToken != "vAs_deadbeef" {
|
||||||
|
t.Errorf("SessionToken = %q, want vAs_deadbeef (from data.session_token)", r.SessionToken)
|
||||||
|
}
|
||||||
|
if r.Account != "raw@visionA.local" {
|
||||||
|
t.Errorf("Account = %q, want raw@visionA.local (from data.account)", r.Account)
|
||||||
|
}
|
||||||
|
if r.RelayURL != "wss://raw.relay/tunnel/connect" {
|
||||||
|
t.Errorf("RelayURL = %q, want from data.relay_url", r.RelayURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeRealTopLevelSessionTokenRejected 是回歸防護:確認 agent 不會誤接
|
||||||
|
// 「session_token 在頂層」的舊格式(contract drift 前的假設)。雲端不再回這種格式,
|
||||||
|
// 若 agent 又退回解頂層,這個測試會抓到(會解不到 data.session_token → 報 missing)。
|
||||||
|
func TestExchangeRealTopLevelSessionTokenRejected(t *testing.T) {
|
||||||
|
const legacyBody = `{"session_token":"vAs_toplevel","account":"old@x"}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(legacyBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when session_token is only at top level (legacy format no longer accepted)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing session_token") {
|
||||||
|
t.Errorf("err = %v, want 'missing session_token'", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeReal401Codes 驗證 agent 能從雲端錯誤 envelope({success:false, error:{code}})
|
||||||
|
// 解出大寫 error code(INVALID_PAIRING_TOKEN 等,對齊 api.ErrCode* 常數)→ 映射成
|
||||||
|
// 對應 sentinel error。
|
||||||
func TestExchangeReal401Codes(t *testing.T) {
|
func TestExchangeReal401Codes(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
code string
|
code string // 雲端大寫常數(api.ErrCodeInvalidPairingToken 等)
|
||||||
wantErr error
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{"token_invalid", ErrTokenInvalid},
|
{"INVALID_PAIRING_TOKEN", ErrTokenInvalid},
|
||||||
{"token_expired", ErrTokenExpired},
|
{"PAIRING_TOKEN_EXPIRED", ErrTokenExpired},
|
||||||
{"token_used", ErrTokenUsed},
|
{"PAIRING_TOKEN_USED", ErrTokenUsed},
|
||||||
{"token_revoked", ErrTokenRevoked},
|
{"PAIRING_TOKEN_REVOKED", ErrTokenRevoked},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.code, func(t *testing.T) {
|
t.Run(tc.code, func(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(401)
|
w.WriteHeader(401)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"code": tc.code})
|
// 雲端真實格式:{success:false, error:{code, message, request_id}}。
|
||||||
|
_ = json.NewEncoder(w).Encode(exchangeErrorResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: exchangeErrorDetail{
|
||||||
|
Code: tc.code,
|
||||||
|
Message: "pairing token rejected",
|
||||||
|
},
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
ex := NewHTTPPairingExchanger(srv.URL)
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
@ -145,6 +210,44 @@ func TestExchangeReal401Codes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExchangeReal401RawEnvelope 用手寫 JSON(複製 WriteError 真實輸出)驗證 401
|
||||||
|
// envelope 解析,不依賴 agent struct 編碼。
|
||||||
|
func TestExchangeReal401RawEnvelope(t *testing.T) {
|
||||||
|
const rawErr = `{"success":false,"error":{"code":"PAIRING_TOKEN_EXPIRED","message":"pairing token expired","request_id":"req_123"}}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
_, _ = w.Write([]byte(rawErr))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
|
||||||
|
if !errors.Is(err, ErrTokenExpired) {
|
||||||
|
t.Errorf("err = %v, want ErrTokenExpired (from error.code=PAIRING_TOKEN_EXPIRED)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeReal401TopLevelCodeRejected 回歸防護:舊格式(頂層小寫 code)不應再被
|
||||||
|
// 接受。雲端不回這種格式;若 agent 退回解頂層 token_invalid,會落到 default 分支
|
||||||
|
// (仍是 ErrTokenInvalid,但帶 code=""),用 error 訊息含空 code 確認沒解到。
|
||||||
|
func TestExchangeReal401TopLevelCodeRejected(t *testing.T) {
|
||||||
|
const legacyErr = `{"code":"token_expired"}`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
_, _ = w.Write([]byte(legacyErr))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
|
||||||
|
// 頂層 code 解不到 error.code → 落 default(fmt 包 ErrTokenInvalid + code="")。
|
||||||
|
// 重點:不應被誤判成 ErrTokenExpired。
|
||||||
|
if errors.Is(err, ErrTokenExpired) {
|
||||||
|
t.Errorf("legacy top-level code should NOT map to ErrTokenExpired; got %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrTokenInvalid) {
|
||||||
|
t.Errorf("unmatched code should fall back to ErrTokenInvalid; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIsPairingMockOptIn 驗證 Fix-A3:mock 模式必須明確 opt-in。
|
// TestIsPairingMockOptIn 驗證 Fix-A3:mock 模式必須明確 opt-in。
|
||||||
//
|
//
|
||||||
// 預設規則(任何不是明確 "true" 的值)一律走真實 exchange,避免「使用者忘設環境
|
// 預設規則(任何不是明確 "true" 的值)一律走真實 exchange,避免「使用者忘設環境
|
||||||
@ -177,6 +280,85 @@ func TestIsPairingMockOptIn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsInsecureSkipTLSVerify 驗證跳過 TLS 驗證的 env 解析(必須明確 opt-in)。
|
||||||
|
//
|
||||||
|
// 預設規則(任何不是明確 "true" 的值)一律維持安全的 TLS 驗證,避免使用者拼錯字
|
||||||
|
// 或殘留環境變數誤入不安全模式。
|
||||||
|
func TestIsInsecureSkipTLSVerify(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
env string
|
||||||
|
want bool
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{"", false, "unset → 安全驗證(production-safe default)"},
|
||||||
|
{"true", true, "明確 true → skip"},
|
||||||
|
{"True", true, "大小寫不敏感"},
|
||||||
|
{"TRUE", true, "全大寫"},
|
||||||
|
{"false", false, "明確 false → 安全驗證"},
|
||||||
|
{"False", false, "false 大小寫不敏感"},
|
||||||
|
{"1", false, "1 不是 true → 安全驗證(避免拼錯字啟用)"},
|
||||||
|
{"yes", false, "yes 不是 true → 安全驗證"},
|
||||||
|
{"on", false, "on 不是 true → 安全驗證"},
|
||||||
|
{" true", false, "前導空白 → 不識別為 true(規則嚴格)"},
|
||||||
|
{"truthy", false, "包含 true 的字串不算"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
got := IsInsecureSkipTLSVerify(tc.env)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("IsInsecureSkipTLSVerify(%q) = %v, want %v", tc.env, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangerInsecureSkipTLSVerifyAppliesToClient 驗證開關開時,預設建構的
|
||||||
|
// http.Client 的 Transport.TLSClientConfig.InsecureSkipVerify == true。
|
||||||
|
func TestExchangerInsecureSkipTLSVerifyAppliesToClient(t *testing.T) {
|
||||||
|
// 開關開:用一個會拒絕未知 CA 的 TLS server,確認沒有 x509 錯誤即代表已跳過驗證。
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// 雲端真實格式:{success:true, data:{...}}。
|
||||||
|
_ = json.NewEncoder(w).Encode(exchangeResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: exchangeResponseData{
|
||||||
|
SessionToken: "vAs_" + strings.Repeat("a", 64),
|
||||||
|
Account: "skip@visionA.local",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// httptest TLS server 用自簽憑證,預設 client 會 x509 失敗;開關開應成功。
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
ex.InsecureSkipTLSVerify = true
|
||||||
|
r, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange with InsecureSkipTLSVerify=true should succeed against self-signed server, got: %v", err)
|
||||||
|
}
|
||||||
|
if r.Account != "skip@visionA.local" {
|
||||||
|
t.Errorf("Account = %q", r.Account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangerSecureByDefaultRejectsSelfSigned 驗證開關關(預設)時,連自簽憑證
|
||||||
|
// server 會因 TLS 驗證失敗而報 network error(不會跳過驗證)。
|
||||||
|
func TestExchangerSecureByDefaultRejectsSelfSigned(t *testing.T) {
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL) // InsecureSkipTLSVerify 預設 false
|
||||||
|
_, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected TLS verification error against self-signed server when skip disabled")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrExchangeNetwork) {
|
||||||
|
t.Errorf("err = %v, want wrapped ErrExchangeNetwork (TLS x509 failure)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExchangeReal404HintsMockMode(t *testing.T) {
|
func TestExchangeReal404HintsMockMode(t *testing.T) {
|
||||||
// 模擬 AB11 尚未上線時 endpoint 不存在的情境
|
// 模擬 AB11 尚未上線時 endpoint 不存在的情境
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user