From a411fd300c9ca92bdaa67184cbc1a388a80ea3b2 Mon Sep 17 00:00:00 2001 From: s-shimizu-clpl <137996353+s-shimizu-clpl@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:12:13 +0900 Subject: [PATCH 1/3] [client] Add Japanese (ja) UI translation (#6790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe your changes Adds a Japanese (ja) locale for the desktop UI, following the procedure in `client/ui/i18n/TRANSLATING.md`. - New `client/ui/i18n/locales/ja/common.json` — all 441 keys from the `en` source bundle, `message` only, same key order. - Registered in `client/ui/i18n/locales/_index.json`: `{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}`. Translation notes: - Placeholders (`{version}`, `{count}`, `{name}`, `{remaining}`, ...), `\n`, trailing `…`/`...`, the leading space in `notify.update.enforcedSuffix`, and the `` inline-link tags are all preserved verbatim. - Brands kept as-is: NetBird, WireGuard® (® preserved), Rosenpass, GitHub, NetBird Cloud. Acronyms kept: SSO, DNS, IP/IPv6, ACL, SSH, JWT, TTL, SFTP, MTU, PSK, LAN, P2P, ICE, IdP. - Formal register (です・ます); short labels for buttons/tray; quoted UI labels use Japanese brackets 「」. No code changes are required: the React frontend auto-loads every `locales/*/common.json` via `import.meta.glob`, the tray/Go side embeds the tree via `//go:embed all:i18n/locales`, and the language picker lists whatever `_index.json` declares. ### Verification - `go test ./client/ui/i18n/` passes. - JSON valid; key set and order identical to `en`; no `description` fields; no empty messages; placeholder/`\n` counts match `en`. - Built the Windows UI and confirmed the General, Network, Security, and Troubleshooting settings tabs plus the main window render correctly in Japanese (language picker shows "日本語 (Japanese)"), with no text truncation, overflow, or leaked placeholders. ## Issue ticket number and link N/A ## Stack ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — adding a shipped UI locale is the documented extension path in `client/ui/i18n/TRANSLATING.md`. > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change — UI translation strings only; no user-facing product docs are affected. ### Docs PR URL (required if "docs added" is checked) N/A 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- View with Codesmith Autofix with Codesmith Need help on this PR? Tag /codesmith with what you need. Autofix is disabled. ## Summary by CodeRabbit * **New Features** * Added Japanese as a supported interface language. * **Localization** * Provided full Japanese translations for the UI, covering navigation, settings, onboarding, desktop/tray notifications, connection and status messaging, troubleshooting, update/about screens, and authentication/error dialogs. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Eduard Gert --- client/ui/i18n/locales/_index.json | 3 +- client/ui/i18n/locales/ja/common.json | 1325 +++++++++++++++++++++++++ 2 files changed, 1327 insertions(+), 1 deletion(-) create mode 100644 client/ui/i18n/locales/ja/common.json diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 58b5c484f..419358d36 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -8,6 +8,7 @@ {"code": "fr", "displayName": "Français", "englishName": "French"}, {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, - {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"} + {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} ] } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json new file mode 100644 index 000000000..cd54bce17 --- /dev/null +++ b/client/ui/i18n/locales/ja/common.json @@ -0,0 +1,1325 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "未接続" + }, + "tray.status.daemonUnavailable": { + "message": "実行されていません" + }, + "tray.status.error": { + "message": "エラー" + }, + "tray.status.connected": { + "message": "接続済み" + }, + "tray.status.connecting": { + "message": "接続中" + }, + "tray.status.needsLogin": { + "message": "ログインが必要" + }, + "tray.status.loginFailed": { + "message": "ログインに失敗しました" + }, + "tray.status.sessionExpired": { + "message": "セッションが期限切れ" + }, + "tray.session.expiresIn": { + "message": "セッションはあと{remaining}で期限切れ" + }, + "tray.session.unit.lessThanMinute": { + "message": "1分未満" + }, + "tray.session.unit.minute": { + "message": "1分" + }, + "tray.session.unit.minutes": { + "message": "{count}分" + }, + "tray.session.unit.hour": { + "message": "1時間" + }, + "tray.session.unit.hours": { + "message": "{count}時間" + }, + "tray.session.unit.day": { + "message": "1日" + }, + "tray.session.unit.days": { + "message": "{count}日" + }, + "tray.menu.open": { + "message": "NetBird を開く" + }, + "tray.menu.connect": { + "message": "接続" + }, + "tray.menu.disconnect": { + "message": "切断" + }, + "tray.menu.exitNode": { + "message": "出口ノード" + }, + "tray.menu.networks": { + "message": "リソース" + }, + "tray.menu.profiles": { + "message": "プロファイル" + }, + "tray.menu.manageProfiles": { + "message": "プロファイルの管理" + }, + "tray.menu.settings": { + "message": "設定..." + }, + "tray.menu.debugBundle": { + "message": "デバッグバンドルを作成" + }, + "tray.menu.about": { + "message": "ヘルプとサポート" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "ドキュメント" + }, + "tray.menu.troubleshoot": { + "message": "トラブルシューティング" + }, + "tray.menu.downloadLatest": { + "message": "最新バージョンをダウンロード" + }, + "tray.menu.installVersion": { + "message": "バージョン {version} をインストール" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "デーモン: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird を終了" + }, + "notify.daemonOutdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "notify.daemonOutdated.body": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "notify.update.title": { + "message": "NetBird の更新が利用可能" + }, + "notify.update.body": { + "message": "NetBird {version} が利用可能です。" + }, + "notify.update.enforcedSuffix": { + "message": "管理者がこの更新を必須にしています。" + }, + "notify.error.title": { + "message": "エラー" + }, + "notify.error.connect": { + "message": "接続に失敗しました" + }, + "notify.error.disconnect": { + "message": "切断に失敗しました" + }, + "notify.error.switchProfile": { + "message": "{profile} への切り替えに失敗しました" + }, + "notify.error.exitNode": { + "message": "出口ノード {name} の更新に失敗しました" + }, + "notify.sessionExpired.title": { + "message": "NetBird セッションが期限切れ" + }, + "notify.sessionExpired.body": { + "message": "NetBird セッションの有効期限が切れました。もう一度ログインしてください。" + }, + "notify.sessionWarning.title": { + "message": "まもなくセッションが期限切れ" + }, + "notify.sessionWarning.body": { + "message": "NetBird セッションはあと{remaining}で期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "NetBird セッションはまもなく期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.extend": { + "message": "今すぐ延長" + }, + "notify.sessionWarning.dismiss": { + "message": "閉じる" + }, + "notify.sessionWarning.failed": { + "message": "NetBird セッションの延長に失敗しました" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird セッションを延長しました" + }, + "notify.sessionWarning.successBody": { + "message": "セッションが更新されました。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "セッション期限が拒否されました" + }, + "notify.sessionDeadlineRejected.body": { + "message": "サーバーが無効なセッション期限を送信しました。もう一度サインインしてください。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 設定が更新されました" + }, + "notify.mdm.policyApplied.body": { + "message": "NetBird の構成が IT ポリシーによって更新されました。" + }, + "common.cancel": { + "message": "キャンセル" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "変更を保存" + }, + "common.saving": { + "message": "保存中…" + }, + "common.close": { + "message": "閉じる" + }, + "common.copy": { + "message": "コピー" + }, + "common.togglePasswordVisibility": { + "message": "パスワードの表示を切り替え" + }, + "common.increase": { + "message": "増やす" + }, + "common.decrease": { + "message": "減らす" + }, + "common.delete": { + "message": "削除" + }, + "common.create": { + "message": "作成" + }, + "common.add": { + "message": "追加" + }, + "common.remove": { + "message": "削除" + }, + "common.refresh": { + "message": "更新" + }, + "common.loading": { + "message": "読み込み中…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "結果が見つかりませんでした" + }, + "common.noResults.description": { + "message": "結果が見つかりませんでした。別の検索語を試すか、フィルターを変更してください。" + }, + "notConnected.title": { + "message": "未接続" + }, + "notConnected.description": { + "message": "ピア、ネットワークリソース、出口ノードの詳細情報を表示するには、まず NetBird に接続してください。" + }, + "connect.status.disconnected": { + "message": "未接続" + }, + "connect.status.connecting": { + "message": "接続中..." + }, + "connect.status.connected": { + "message": "接続済み" + }, + "connect.status.disconnecting": { + "message": "切断中..." + }, + "connect.status.daemonUnavailable": { + "message": "デーモンが利用できません" + }, + "connect.status.loginRequired": { + "message": "ログインが必要" + }, + "connect.error.loginTitle": { + "message": "ログインに失敗しました" + }, + "connect.error.connectTitle": { + "message": "接続に失敗しました" + }, + "connect.error.disconnectTitle": { + "message": "切断に失敗しました" + }, + "nav.peers.title": { + "message": "ピア" + }, + "nav.peers.description": { + "message": "{total}台中{connected}台接続中" + }, + "nav.resources.title": { + "message": "リソース" + }, + "nav.resources.description": { + "message": "{total}件中{active}件有効" + }, + "nav.exitNode.title": { + "message": "出口ノード" + }, + "nav.exitNode.none": { + "message": "未使用" + }, + "nav.exitNode.using": { + "message": "{name} 経由" + }, + "header.openSettings": { + "message": "設定を開く" + }, + "header.togglePanel": { + "message": "サイドパネルを切り替え" + }, + "profile.selector.loading": { + "message": "読み込み中..." + }, + "profile.selector.noProfile": { + "message": "プロファイルなし" + }, + "profile.selector.searchPlaceholder": { + "message": "名前でプロファイルを検索..." + }, + "profile.selector.emptyTitle": { + "message": "プロファイルが見つかりません" + }, + "profile.selector.emptyDescription": { + "message": "別の検索語を試すか、新しいプロファイルを作成してください。" + }, + "profile.selector.newProfile": { + "message": "新しいプロファイル" + }, + "profile.selector.moreOptions": { + "message": "その他のオプション" + }, + "profile.selector.deregister": { + "message": "登録解除" + }, + "profile.selector.delete": { + "message": "削除" + }, + "profile.selector.switchTo": { + "message": "このプロファイルに切り替え" + }, + "profile.selector.edit": { + "message": "編集" + }, + "profile.edit.title": { + "message": "プロファイルを編集" + }, + "profile.edit.submit": { + "message": "変更を保存" + }, + "profile.dialog.title": { + "message": "プロファイル名を入力" + }, + "profile.dialog.nameLabel": { + "message": "プロファイル名" + }, + "profile.dialog.description": { + "message": "分かりやすいプロファイル名を設定してください。" + }, + "profile.dialog.placeholder": { + "message": "例: 仕事" + }, + "profile.dialog.submit": { + "message": "プロファイルを追加" + }, + "profile.dialog.required": { + "message": "プロファイル名を入力してください(例: 仕事、自宅)" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud または独自のサーバーを使用します。" + }, + "profile.dialog.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのままプロファイルを追加してください。" + }, + "header.menu.settings": { + "message": "設定..." + }, + "header.menu.defaultView": { + "message": "デフォルト表示" + }, + "header.menu.advancedView": { + "message": "詳細表示" + }, + "header.menu.updateAvailable": { + "message": "更新が利用可能" + }, + "header.menu.open": { + "message": "メニューを開く" + }, + "header.profile.switch": { + "message": "プロファイルを切り替え" + }, + "connect.toggle.label": { + "message": "NetBird 接続を切り替え" + }, + "connect.localIp.label": { + "message": "ローカル IP アドレス" + }, + "common.search": { + "message": "検索" + }, + "common.filter": { + "message": "フィルター" + }, + "exitNodes.dropdown.trigger": { + "message": "出口ノードを選択" + }, + "peers.row.label": { + "message": "{name} の詳細を開く、{status}" + }, + "peers.dialog.title": { + "message": "ピアの詳細" + }, + "networks.row.toggle": { + "message": "{name} を切り替え" + }, + "networks.bulk.label": { + "message": "表示中のすべてのリソースを切り替え" + }, + "profile.switch.title": { + "message": "プロファイルを「{name}」に切り替えますか?" + }, + "profile.switch.message": { + "message": "プロファイルを切り替えてもよろしいですか?\n現在のプロファイルは切断されます。" + }, + "profile.switch.confirm": { + "message": "確認" + }, + "profile.deregister.title": { + "message": "プロファイル「{name}」の登録を解除しますか?" + }, + "profile.deregister.message": { + "message": "このプロファイルの登録を解除してもよろしいですか?\n再度使用するにはログインが必要になります。" + }, + "profile.deregister.confirm": { + "message": "登録解除" + }, + "profile.delete.title": { + "message": "プロファイル「{name}」を削除しますか?" + }, + "profile.delete.message": { + "message": "このプロファイルを削除してもよろしいですか?\nこの操作は取り消せません。" + }, + "profile.delete.disabledActive": { + "message": "使用中のプロファイルは削除できません。削除する前に別のプロファイルに切り替えてください。" + }, + "profile.delete.disabledDefault": { + "message": "デフォルトのプロファイルは削除できません。" + }, + "profile.error.switchTitle": { + "message": "プロファイルの切り替えに失敗しました" + }, + "profile.error.deregisterTitle": { + "message": "プロファイルの登録解除に失敗しました" + }, + "profile.error.deleteTitle": { + "message": "プロファイルの削除に失敗しました" + }, + "profile.error.createTitle": { + "message": "プロファイルの作成に失敗しました" + }, + "profile.error.editTitle": { + "message": "プロファイルの編集に失敗しました" + }, + "profile.error.loadTitle": { + "message": "プロファイルの読み込みに失敗しました" + }, + "profile.dropdown.activeProfile": { + "message": "使用中のプロファイル" + }, + "profile.dropdown.switchProfile": { + "message": "プロファイルを切り替え" + }, + "profile.dropdown.noEmail": { + "message": "その他" + }, + "profile.dropdown.addProfile": { + "message": "プロファイルを追加" + }, + "profile.dropdown.manageProfiles": { + "message": "プロファイルの管理" + }, + "profile.dropdown.settings": { + "message": "設定" + }, + "settings.profiles.section.profiles": { + "message": "プロファイル" + }, + "settings.profiles.intro": { + "message": "仕事用と個人用のアカウント、あるいは異なる管理サーバーなど、複数の NetBird ID を並行して管理できます。以下でプロファイルの追加、登録解除、削除ができます。" + }, + "settings.profiles.addProfile": { + "message": "プロファイルを追加" + }, + "settings.profiles.active": { + "message": "使用中" + }, + "settings.profiles.emptyTitle": { + "message": "プロファイルがありません" + }, + "settings.profiles.emptyDescription": { + "message": "NetBird 管理サーバーに接続するプロファイルを作成してください。" + }, + "settings.error.loadTitle": { + "message": "設定の読み込みに失敗しました" + }, + "settings.error.saveTitle": { + "message": "設定の保存に失敗しました" + }, + "settings.error.debugBundleTitle": { + "message": "デバッグバンドルの作成に失敗しました" + }, + "settings.nav.label": { + "message": "設定セクション" + }, + "settings.tabs.general": { + "message": "一般" + }, + "settings.tabs.network": { + "message": "ネットワーク" + }, + "settings.tabs.security": { + "message": "セキュリティ" + }, + "settings.tabs.profiles": { + "message": "プロファイル" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "詳細設定" + }, + "settings.tabs.troubleshooting": { + "message": "トラブルシューティング" + }, + "settings.tabs.about": { + "message": "情報" + }, + "settings.tabs.updateAvailable": { + "message": "更新が利用可能" + }, + "settings.general.section.general": { + "message": "一般" + }, + "settings.general.section.connection": { + "message": "接続" + }, + "settings.general.connectOnStartup.label": { + "message": "起動時に接続" + }, + "settings.general.connectOnStartup.help": { + "message": "サービスの起動時に自動的に接続を確立します。" + }, + "settings.general.notifications.label": { + "message": "デスクトップ通知" + }, + "settings.general.notifications.help": { + "message": "新しい更新や接続イベントに関するデスクトップ通知を表示します。" + }, + "settings.general.autostart.label": { + "message": "ログイン時に NetBird UI を起動" + }, + "settings.general.autostart.help": { + "message": "ログイン時に NetBird インターフェースを自動的に起動します。これはグラフィカルインターフェースにのみ影響し、バックグラウンドサービスには影響しません。" + }, + "settings.general.autostart.errorTitle": { + "message": "自動起動の変更に失敗しました" + }, + "settings.general.language.label": { + "message": "表示言語" + }, + "settings.general.language.help": { + "message": "NetBird インターフェースの言語を選択します。" + }, + "settings.general.language.search": { + "message": "言語を検索…" + }, + "settings.general.language.empty": { + "message": "一致する言語がありません。" + }, + "settings.general.management.label": { + "message": "管理サーバー" + }, + "settings.general.management.help": { + "message": "NetBird Cloud または自身のセルフホスト管理サーバーに接続します。変更するとクライアントが再接続します。" + }, + "settings.general.management.cloud": { + "message": "クラウド" + }, + "settings.general.management.selfHosted": { + "message": "セルフホスト" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "settings.general.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのまま保存してください。" + }, + "settings.general.management.switchCloudTitle": { + "message": "NetBird Cloud に切り替えますか?" + }, + "settings.general.management.switchCloudMessage": { + "message": "セルフホストサーバーが切断されます。\n再度ログインが必要になる場合があります。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "クラウドに切り替え" + }, + "settings.network.section.connectivity": { + "message": "ネットワーク接続" + }, + "settings.network.section.routingDns": { + "message": "ルーティングとDNS" + }, + "settings.network.monitor.label": { + "message": "ネットワーク変更時に再接続" + }, + "settings.network.monitor.help": { + "message": "ネットワークを監視し、Wi-Fiの切り替え、イーサネットの変更、スリープからの復帰などの変化時に自動的に再接続します。" + }, + "settings.network.dns.label": { + "message": "DNSを有効にする" + }, + "settings.network.dns.help": { + "message": "NetBird が管理する DNS 設定をホストのリゾルバに適用します。" + }, + "settings.network.clientRoutes.label": { + "message": "クライアントルートを有効にする" + }, + "settings.network.clientRoutes.help": { + "message": "他のピアからルートを受け入れ、そのネットワークに到達できるようにします。" + }, + "settings.network.serverRoutes.label": { + "message": "サーバールートを有効にする" + }, + "settings.network.serverRoutes.help": { + "message": "このホストのローカルルートを他のピアにアドバタイズします。" + }, + "settings.network.ipv6.label": { + "message": "IPv6を有効にする" + }, + "settings.network.ipv6.help": { + "message": "NetBird オーバーレイネットワークで IPv6 アドレッシングを使用します。" + }, + "settings.security.section.firewall": { + "message": "ファイアウォール" + }, + "settings.security.section.encryption": { + "message": "暗号化" + }, + "settings.security.blockInbound.label": { + "message": "受信トラフィックをブロック" + }, + "settings.security.blockInbound.help": { + "message": "このデバイスおよびこのデバイスがルーティングするネットワークへの、ピアからの要求されていない接続を拒否します。送信トラフィックには影響しません。" + }, + "settings.security.blockLan.label": { + "message": "LANアクセスをブロック" + }, + "settings.security.blockLan.help": { + "message": "このデバイスがピアのトラフィックをルーティングする際に、ピアがローカルネットワークやそのデバイスに到達できないようにします。" + }, + "settings.security.rosenpass.label": { + "message": "量子耐性を有効にする" + }, + "settings.security.rosenpass.help": { + "message": "WireGuard® に加えて Rosenpass によるポスト量子鍵交換を追加します。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "寛容モードを有効にする" + }, + "settings.security.rosenpassPermissive.help": { + "message": "量子耐性に対応していないピアへの接続を許可します。" + }, + "settings.ssh.section.server": { + "message": "サーバー" + }, + "settings.ssh.section.capabilities": { + "message": "機能" + }, + "settings.ssh.section.authentication": { + "message": "認証" + }, + "settings.ssh.server.label": { + "message": "SSHサーバーを有効にする" + }, + "settings.ssh.server.help": { + "message": "このホストで NetBird SSH サーバーを実行し、他のピアが接続できるようにします。" + }, + "settings.ssh.root.label": { + "message": "rootログインを許可" + }, + "settings.ssh.root.help": { + "message": "ピアが root ユーザーとしてサインインできるようにします。無効にすると非特権アカウントが必要になります。" + }, + "settings.ssh.sftp.label": { + "message": "SFTPを許可" + }, + "settings.ssh.sftp.help": { + "message": "ネイティブの SFTP または SCP クライアントを使用してファイルを安全に転送します。" + }, + "settings.ssh.localForward.label": { + "message": "ローカルポート転送" + }, + "settings.ssh.localForward.help": { + "message": "接続するピアが、このホストから到達可能なサービスへローカルポートをトンネリングできるようにします。" + }, + "settings.ssh.remoteForward.label": { + "message": "リモートポート転送" + }, + "settings.ssh.remoteForward.help": { + "message": "接続するピアが、このホスト上のポートを自身のマシンに公開できるようにします。" + }, + "settings.ssh.jwt.label": { + "message": "JWT認証を有効にする" + }, + "settings.ssh.jwt.help": { + "message": "各 SSH セッションを IdP に対して検証し、ユーザー ID と監査を行います。無効にするとネットワークの ACL ポリシーのみに依存します。IdP が利用できない場合に便利です。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWTキャッシュTTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "発信 SSH 接続で再度認証を求めるまでに、このクライアントが JWT をキャッシュする期間です。0 に設定するとキャッシュを無効にし、接続ごとに認証します。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "インターフェース" + }, + "settings.advanced.section.security": { + "message": "セキュリティ" + }, + "settings.advanced.interfaceName.label": { + "message": "名前" + }, + "settings.advanced.interfaceName.error": { + "message": "1〜15文字の英字、数字、ドット、ハイフン、アンダースコアを使用してください。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "「utun」に続けて数字で始まる必要があります(例: utun100)。" + }, + "settings.advanced.port.label": { + "message": "ポート" + }, + "settings.advanced.port.error": { + "message": "{min}〜{max}の範囲でポートを入力してください。" + }, + "settings.advanced.port.help": { + "message": "0 に設定すると、ランダムな空きポートが使用されます。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "{min}〜{max}の範囲で MTU 値を入力してください。" + }, + "settings.advanced.psk.label": { + "message": "事前共有鍵" + }, + "settings.advanced.psk.help": { + "message": "追加の対称暗号化のためのオプションの WireGuard PSK です。NetBird セットアップキーとは異なります。同じ事前共有鍵を使用するピアとのみ通信できます。" + }, + "settings.troubleshooting.section.title": { + "message": "デバッグバンドル" + }, + "settings.troubleshooting.anonymize.label": { + "message": "機密情報を匿名化" + }, + "settings.troubleshooting.anonymize.help": { + "message": "ログからパブリック IP アドレスと NetBird 以外のドメインを隠します。" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "システム情報を含める" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS、カーネル、ネットワークインターフェース、ルーティングテーブルを含めます。" + }, + "settings.troubleshooting.upload.label": { + "message": "バンドルを NetBird サーバーにアップロード" + }, + "settings.troubleshooting.upload.help": { + "message": "NetBird サポートと共有するためのアップロードキーを返します。" + }, + "settings.troubleshooting.trace.label": { + "message": "トレースログを有効にする" + }, + "settings.troubleshooting.trace.help": { + "message": "ログレベルを TRACE に引き上げ、その後元に戻します。" + }, + "settings.troubleshooting.capture.label": { + "message": "キャプチャセッション" + }, + "settings.troubleshooting.capture.help": { + "message": "再接続して待機し、問題を再現できるようにします。" + }, + "settings.troubleshooting.packets.label": { + "message": "ネットワークパケットをキャプチャ" + }, + "settings.troubleshooting.packets.help": { + "message": "キャプチャ期間中のネットワークトラフィックを .pcap として保存します。" + }, + "settings.troubleshooting.duration.label": { + "message": "キャプチャ時間" + }, + "settings.troubleshooting.duration.help": { + "message": "キャプチャセッションを実行する時間です。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分" + }, + "settings.troubleshooting.create": { + "message": "バンドルを作成" + }, + "settings.troubleshooting.progress.description": { + "message": "ログ、システムの詳細、接続状態を収集しています。通常はしばらくで完了します。完了するまで NetBird を使い続けても、設定を閉じても構いません。" + }, + "settings.troubleshooting.cancelling": { + "message": "キャンセル中…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "デバッグバンドルのアップロードに成功しました!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "バンドルを保存しました" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "下記のアップロードキーを NetBird サポート と共有してください。ローカルコピーもお使いのデバイスに保存されました。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "デバッグバンドルはローカルに保存されました。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "キーをコピー" + }, + "settings.troubleshooting.done.openFolder": { + "message": "フォルダを開く" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "ファイルの場所を開く" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "アップロードに失敗しました: {reason} バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "アップロードに失敗しました。バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird を再接続しています…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "デバッグログをキャプチャしています" + }, + "settings.troubleshooting.stage.bundling": { + "message": "デバッグバンドルを生成しています…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "NetBird にアップロードしています…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "キャンセル中…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[開発版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved." + }, + "settings.about.links.imprint": { + "message": "運営者情報" + }, + "settings.about.links.privacy": { + "message": "プライバシー" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "利用規約" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "フォーラム" + }, + "settings.about.community.documentation": { + "message": "ドキュメント" + }, + "settings.about.community.feedback": { + "message": "フィードバック" + }, + "update.banner.message": { + "message": "NetBird {version} をインストールする準備ができました。" + }, + "update.banner.later": { + "message": "後で" + }, + "update.banner.installNow": { + "message": "今すぐインストール" + }, + "update.card.versionAvailableDownload": { + "message": "バージョン {version} がダウンロード可能です。" + }, + "update.card.versionAvailableInstall": { + "message": "バージョン {version} がインストール可能です。" + }, + "update.card.whatsNew": { + "message": "新機能は?" + }, + "update.card.installNow": { + "message": "今すぐインストール" + }, + "update.card.getInstaller": { + "message": "ダウンロード" + }, + "update.card.autoCheckInterval": { + "message": "NetBird はバックグラウンドで更新を確認します。" + }, + "update.card.changelog": { + "message": "変更履歴" + }, + "update.card.onLatestVersion": { + "message": "最新バージョンを使用しています" + }, + "update.header.tooltip": { + "message": "更新が利用可能" + }, + "update.overlay.updatingVersion": { + "message": "NetBird を v{version} に更新しています" + }, + "update.overlay.updating": { + "message": "NetBird を更新しています" + }, + "update.overlay.description": { + "message": "新しいバージョンが利用可能で、インストール中です。更新が完了すると NetBird は自動的に再起動します。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新に時間がかかっています" + }, + "update.overlay.error.timeoutDescription": { + "message": "{target} のインストールに時間がかかりすぎ、完了しませんでした。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新が停止されました" + }, + "update.overlay.error.canceledDescription": { + "message": "{target} への更新は完了前にキャンセルされました。" + }, + "update.overlay.error.failTitle": { + "message": "更新をインストールできませんでした" + }, + "update.overlay.error.failDescription": { + "message": "{target} をインストールできませんでした。" + }, + "update.overlay.error.unknownMessage": { + "message": "不明なエラー" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新しいバージョン" + }, + "update.error.loadStateTitle": { + "message": "更新状態の読み込みに失敗しました" + }, + "update.error.triggerTitle": { + "message": "更新の開始に失敗しました" + }, + "update.page.versionLine": { + "message": "クライアントを次のバージョンに更新しています: {version}。" + }, + "update.page.versionLineGeneric": { + "message": "クライアントを更新しています。" + }, + "update.page.outdated": { + "message": "クライアントのバージョンが、管理サーバーで設定された自動更新バージョンより古くなっています。" + }, + "update.page.status.running": { + "message": "更新中" + }, + "update.page.status.timeout": { + "message": "更新がタイムアウトしました。もう一度お試しください。" + }, + "update.page.status.canceled": { + "message": "更新がキャンセルされました。" + }, + "update.page.status.failed": { + "message": "更新に失敗しました: {message}" + }, + "update.page.status.unknownError": { + "message": "不明な更新エラー" + }, + "update.page.failedTitle": { + "message": "更新に失敗しました" + }, + "update.page.timeoutMessage": { + "message": "更新がタイムアウトしました。" + }, + "update.page.dontClose": { + "message": "このウィンドウを閉じないでください。" + }, + "update.page.updating": { + "message": "更新中…" + }, + "update.page.complete": { + "message": "更新が完了しました" + }, + "update.page.failed": { + "message": "更新に失敗しました" + }, + "window.title.settings": { + "message": "設定" + }, + "window.title.signIn": { + "message": "サインイン" + }, + "window.title.sessionExpiration": { + "message": "セッションの期限切れ" + }, + "window.title.updating": { + "message": "更新中" + }, + "window.title.welcome": { + "message": "NetBird へようこそ" + }, + "window.title.error": { + "message": "エラー" + }, + "welcome.title": { + "message": "トレイの NetBird を確認してください" + }, + "welcome.description": { + "message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.continue": { + "message": "続ける" + }, + "welcome.back": { + "message": "戻る" + }, + "welcome.management.title": { + "message": "NetBird をセットアップ" + }, + "welcome.management.description": { + "message": "「続ける」をクリックして開始するか、独自の NetBird サーバーをお持ちの場合は「セルフホスト」を選択してください。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "当社のホスト型サービスを使用します。セットアップは不要です。" + }, + "welcome.management.selfHosted.title": { + "message": "セルフホスト" + }, + "welcome.management.selfHosted.description": { + "message": "独自の管理サーバーに接続します。" + }, + "welcome.management.urlLabel": { + "message": "管理サーバーのURL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "welcome.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLまたはネットワークを確認し、正しいことが確実な場合は続行してください。" + }, + "welcome.management.checking": { + "message": "確認中…" + }, + "browserLogin.title": { + "message": "ブラウザでログインを完了してください" + }, + "browserLogin.notSeeing": { + "message": "サインインを完了できるようブラウザのタブを開きました。表示されませんか?" + }, + "browserLogin.tryAgain": { + "message": "再試行" + }, + "browserLogin.openFailedTitle": { + "message": "ブラウザの起動に失敗しました" + }, + "sessionExpiration.title": { + "message": "まもなくセッションが期限切れになります" + }, + "sessionExpiration.titleLater": { + "message": "セッションが期限切れになります" + }, + "sessionExpiration.description": { + "message": "このデバイスはまもなく切断されます。ブラウザでのサインインで更新してください。" + }, + "sessionExpiration.descriptionLater": { + "message": "ブラウザでサインインすると、このデバイスがネットワークに接続されたままになります。" + }, + "sessionExpiration.stay": { + "message": "セッションを更新" + }, + "sessionExpiration.authenticate": { + "message": "認証" + }, + "sessionExpiration.logout": { + "message": "ログアウト" + }, + "sessionExpiration.expired": { + "message": "セッションが期限切れになりました" + }, + "sessionExpiration.expiredDescription": { + "message": "デバイスが切断されました。再接続するにはブラウザでサインインして認証してください。" + }, + "sessionExpiration.close": { + "message": "閉じる" + }, + "sessionExpiration.extendFailedTitle": { + "message": "セッションの延長に失敗しました" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "ログアウトに失敗しました" + }, + "peers.search.placeholder": { + "message": "名前または IP で検索" + }, + "peers.filter.all": { + "message": "すべて" + }, + "peers.filter.online": { + "message": "オンライン" + }, + "peers.filter.offline": { + "message": "オフライン" + }, + "peers.empty.title": { + "message": "利用可能なピアがありません" + }, + "peers.empty.description": { + "message": "利用可能なピアがないか、いずれのピアにもアクセス権がありません。" + }, + "peers.details.domain": { + "message": "ドメイン" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公開鍵" + }, + "peers.details.connection": { + "message": "接続" + }, + "peers.details.latency": { + "message": "レイテンシ" + }, + "peers.details.lastHandshake": { + "message": "最終ハンドシェイク" + }, + "peers.details.statusSince": { + "message": "最終接続更新" + }, + "peers.details.bytes": { + "message": "バイト" + }, + "peers.details.bytesSent": { + "message": "送信" + }, + "peers.details.bytesReceived": { + "message": "受信" + }, + "peers.details.localIce": { + "message": "ローカル ICE" + }, + "peers.details.remoteIce": { + "message": "リモート ICE" + }, + "peers.details.never": { + "message": "なし" + }, + "peers.details.justNow": { + "message": "たった今" + }, + "peers.details.refresh": { + "message": "更新" + }, + "peers.status.connected": { + "message": "接続済み" + }, + "peers.status.connecting": { + "message": "接続中" + }, + "peers.status.disconnected": { + "message": "未接続" + }, + "peers.details.relayAddress": { + "message": "リレー" + }, + "peers.details.networks": { + "message": "リソース" + }, + "peers.details.relayed": { + "message": "リレー経由" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass 有効" + }, + "networks.search.placeholder": { + "message": "ネットワークまたはドメインで検索" + }, + "networks.filter.all": { + "message": "すべて" + }, + "networks.filter.active": { + "message": "有効" + }, + "networks.filter.overlapping": { + "message": "重複" + }, + "networks.empty.title": { + "message": "利用可能なリソースがありません" + }, + "networks.empty.description": { + "message": "利用可能なネットワークリソースがないか、いずれのリソースにもアクセス権がありません。" + }, + "networks.selected": { + "message": "選択中" + }, + "networks.unselected": { + "message": "未選択" + }, + "networks.ips.heading": { + "message": "解決された IP" + }, + "networks.bulk.selectionCount": { + "message": "{total}件中{selected}件有効" + }, + "networks.bulk.enableAll": { + "message": "すべて有効化" + }, + "networks.bulk.disableAll": { + "message": "すべて無効化" + }, + "exitNodes.search.placeholder": { + "message": "出口ノードを検索" + }, + "exitNodes.none": { + "message": "なし" + }, + "exitNodes.empty.title": { + "message": "利用可能な出口ノードがありません" + }, + "exitNodes.empty.description": { + "message": "このピアと共有されている出口ノードはありません。" + }, + "exitNodes.card.title": { + "message": "出口ノード" + }, + "exitNodes.card.statusActive": { + "message": "有効" + }, + "exitNodes.card.statusInactive": { + "message": "無効" + }, + "exitNodes.dropdown.noneTitle": { + "message": "なし" + }, + "exitNodes.dropdown.noneDescription": { + "message": "出口ノードを使用しない直接接続" + }, + "quickActions.connect": { + "message": "接続" + }, + "quickActions.disconnect": { + "message": "切断" + }, + "daemon.unavailable.title": { + "message": "NetBird サービスが実行されていません" + }, + "daemon.unavailable.description": { + "message": "サービスが実行されると、アプリは自動的に再接続します。" + }, + "daemon.unavailable.docsLink": { + "message": "ドキュメント" + }, + "daemon.outdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "daemon.outdated.description": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "error.jwt_clock_skew": { + "message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。" + }, + "error.jwt_expired": { + "message": "サインイントークンの有効期限が切れました。もう一度サインインしてください。" + }, + "error.jwt_signature_invalid": { + "message": "サインインに失敗しました: トークンの署名が無効です。管理者にお問い合わせください。" + }, + "error.session_expired": { + "message": "セッションの有効期限が切れました。もう一度サインインしてください。" + }, + "error.invalid_setup_key": { + "message": "セットアップキーがないか、無効です。" + }, + "error.permission_denied": { + "message": "サインインがサーバーによって拒否されました。" + }, + "error.daemon_unreachable": { + "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" + }, + "error.unknown": { + "message": "操作に失敗しました。" + } +} From 3fb26d458e139f72a56fae80f7921fb1343f3432 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:13:40 +0900 Subject: [PATCH 2/3] [relay] Remove deprecated Hello handshake and gob token decode (#6783) --- combined/cmd/root.go | 2 +- relay/cmd/root.go | 2 +- relay/server/handshake.go | 84 +++--------------------- shared/relay/auth/allow/allow_all.go | 4 -- shared/relay/auth/hmac/token.go | 10 --- shared/relay/auth/hmac/validator.go | 33 ---------- shared/relay/auth/validator.go | 11 +--- shared/relay/messages/address/address.go | 21 ------ shared/relay/messages/auth/auth.go | 43 ------------ shared/relay/messages/message.go | 74 ++------------------- shared/relay/messages/message_test.go | 27 ++------ 11 files changed, 21 insertions(+), 290 deletions(-) delete mode 100644 shared/relay/auth/hmac/validator.go delete mode 100644 shared/relay/messages/address/address.go delete mode 100644 shared/relay/messages/auth/auth.go diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 31e0580fb..2b7956f11 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -226,7 +226,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool } hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) relayCfg := relayServer.Config{ Meter: s.metricsServer.Meter, diff --git a/relay/cmd/root.go b/relay/cmd/root.go index b1949ca11..4dd1e6236 100644 --- a/relay/cmd/root.go +++ b/relay/cmd/root.go @@ -173,7 +173,7 @@ func execute(cmd *cobra.Command, args []string) error { } hashedSecret := sha256.Sum256([]byte(cobraConfig.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) cfg := server.Config{ Meter: metricsServer.Meter, diff --git a/relay/server/handshake.go b/relay/server/handshake.go index 067888406..f064b3501 100644 --- a/relay/server/handshake.go +++ b/relay/server/handshake.go @@ -5,14 +5,8 @@ import ( "fmt" "time" - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/shared/relay/messages" - //nolint:staticcheck - "github.com/netbirdio/netbird/shared/relay/messages/address" - //nolint:staticcheck - authmsg "github.com/netbirdio/netbird/shared/relay/messages/auth" ) const ( @@ -23,55 +17,30 @@ const ( type Validator interface { Validate(any) error - // Deprecated: Use Validate instead. - ValidateHelloMsgType(any) error } -// preparedMsg contains the marshalled success response messages +// preparedMsg contains the marshalled success response message type preparedMsg struct { - responseHelloMsg []byte - responseAuthMsg []byte + responseAuthMsg []byte } func newPreparedMsg(instanceURL string) (*preparedMsg, error) { - rhm, err := marshalResponseHelloMsg(instanceURL) - if err != nil { - return nil, err - } - ram, err := messages.MarshalAuthResponse(instanceURL) if err != nil { return nil, fmt.Errorf("failed to marshal auth response msg: %w", err) } return &preparedMsg{ - responseHelloMsg: rhm, - responseAuthMsg: ram, + responseAuthMsg: ram, }, nil } -func marshalResponseHelloMsg(instanceURL string) ([]byte, error) { - addr := &address.Address{URL: instanceURL} - addrData, err := addr.Marshal() - if err != nil { - return nil, fmt.Errorf("failed to marshal response address: %w", err) - } - - //nolint:staticcheck - responseMsg, err := messages.MarshalHelloResponse(addrData) - if err != nil { - return nil, fmt.Errorf("failed to marshal hello response: %w", err) - } - return responseMsg, nil -} - type handshake struct { conn listener.Conn validator Validator preparedMsg *preparedMsg - handshakeMethodAuth bool - peerID *messages.PeerID + peerID *messages.PeerID } func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) { @@ -93,17 +62,11 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err return nil, fmt.Errorf("determine message type from %s: %w", h.conn.RemoteAddr(), err) } - var peerID *messages.PeerID - switch msgType { - //nolint:staticcheck - case messages.MsgTypeHello: - peerID, err = h.handleHelloMsg(buf) - case messages.MsgTypeAuth: - h.handshakeMethodAuth = true - peerID, err = h.handleAuthMsg(buf) - default: + if msgType != messages.MsgTypeAuth { return nil, fmt.Errorf("invalid message type %d from %s", msgType, h.conn.RemoteAddr()) } + + peerID, err := h.handleAuthMsg(buf) if err != nil { return peerID, err } @@ -112,46 +75,17 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err } func (h *handshake) handshakeResponse(ctx context.Context) error { - var responseMsg []byte - if h.handshakeMethodAuth { - responseMsg = h.preparedMsg.responseAuthMsg - } else { - responseMsg = h.preparedMsg.responseHelloMsg - } - - if _, err := h.conn.Write(ctx, responseMsg); err != nil { + if _, err := h.conn.Write(ctx, h.preparedMsg.responseAuthMsg); err != nil { return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err) } return nil } -func (h *handshake) handleHelloMsg(buf []byte) (*messages.PeerID, error) { - //nolint:staticcheck - peerID, authData, err := messages.UnmarshalHelloMsg(buf) - if err != nil { - return nil, fmt.Errorf("unmarshal hello message: %w", err) - } - - log.Warnf("peer %s (%s) is using deprecated initial message type", peerID, h.conn.RemoteAddr()) - - authMsg, err := authmsg.UnmarshalMsg(authData) - if err != nil { - return nil, fmt.Errorf("unmarshal auth message: %w", err) - } - - //nolint:staticcheck - if err := h.validator.ValidateHelloMsgType(authMsg.AdditionalData); err != nil { - return nil, fmt.Errorf("validate %s (%s): %w", peerID, h.conn.RemoteAddr(), err) - } - - return peerID, nil -} - func (h *handshake) handleAuthMsg(buf []byte) (*messages.PeerID, error) { rawPeerID, authPayload, err := messages.UnmarshalAuthMsg(buf) if err != nil { - return nil, fmt.Errorf("unmarshal hello message: %w", err) + return nil, fmt.Errorf("unmarshal auth message: %w", err) } if err := h.validator.Validate(authPayload); err != nil { diff --git a/shared/relay/auth/allow/allow_all.go b/shared/relay/auth/allow/allow_all.go index 2d30c59c9..3074b8b1d 100644 --- a/shared/relay/auth/allow/allow_all.go +++ b/shared/relay/auth/allow/allow_all.go @@ -8,7 +8,3 @@ type Auth struct { func (a *Auth) Validate(any) error { return nil } - -func (a *Auth) ValidateHelloMsgType(any) error { - return nil -} diff --git a/shared/relay/auth/hmac/token.go b/shared/relay/auth/hmac/token.go index 581b1d6fd..c908efbff 100644 --- a/shared/relay/auth/hmac/token.go +++ b/shared/relay/auth/hmac/token.go @@ -1,10 +1,8 @@ package hmac import ( - "bytes" "crypto/hmac" "encoding/base64" - "encoding/gob" "fmt" "hash" "strconv" @@ -18,14 +16,6 @@ type Token struct { Signature string } -func unmarshalToken(payload []byte) (Token, error) { - var creds Token - buffer := bytes.NewBuffer(payload) - decoder := gob.NewDecoder(buffer) - err := decoder.Decode(&creds) - return creds, err -} - // TimedHMAC generates a token with TTL and uses a pre-shared secret known to the relay server type TimedHMAC struct { secret string diff --git a/shared/relay/auth/hmac/validator.go b/shared/relay/auth/hmac/validator.go deleted file mode 100644 index b0b7542be..000000000 --- a/shared/relay/auth/hmac/validator.go +++ /dev/null @@ -1,33 +0,0 @@ -package hmac - -import ( - "crypto/sha256" - "fmt" - "time" - - log "github.com/sirupsen/logrus" -) - -type TimedHMACValidator struct { - *TimedHMAC -} - -func NewTimedHMACValidator(secret string, duration time.Duration) *TimedHMACValidator { - ta := NewTimedHMAC(secret, duration) - return &TimedHMACValidator{ - ta, - } -} - -func (a *TimedHMACValidator) Validate(credentials any) error { - b, ok := credentials.([]byte) - if !ok { - return fmt.Errorf("invalid credentials type") - } - c, err := unmarshalToken(b) - if err != nil { - log.Debugf("failed to unmarshal token: %s", err) - return err - } - return a.TimedHMAC.Validate(sha256.New, c) -} diff --git a/shared/relay/auth/validator.go b/shared/relay/auth/validator.go index 8e339bb2e..158b32e1b 100644 --- a/shared/relay/auth/validator.go +++ b/shared/relay/auth/validator.go @@ -1,28 +1,19 @@ package auth import ( - "time" - - auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" authv2 "github.com/netbirdio/netbird/shared/relay/auth/hmac/v2" ) type TimedHMACValidator struct { authenticatorV2 *authv2.Validator - authenticator *auth.TimedHMACValidator } -func NewTimedHMACValidator(secret []byte, duration time.Duration) *TimedHMACValidator { +func NewTimedHMACValidator(secret []byte) *TimedHMACValidator { return &TimedHMACValidator{ authenticatorV2: authv2.NewValidator(secret), - authenticator: auth.NewTimedHMACValidator(string(secret), duration), } } func (a *TimedHMACValidator) Validate(credentials any) error { return a.authenticatorV2.Validate(credentials) } - -func (a *TimedHMACValidator) ValidateHelloMsgType(credentials any) error { - return a.authenticator.Validate(credentials) -} diff --git a/shared/relay/messages/address/address.go b/shared/relay/messages/address/address.go deleted file mode 100644 index 707e73e55..000000000 --- a/shared/relay/messages/address/address.go +++ /dev/null @@ -1,21 +0,0 @@ -// Deprecated: This package is deprecated and will be removed in a future release. -package address - -import ( - "bytes" - "encoding/gob" - "fmt" -) - -type Address struct { - URL string -} - -func (addr *Address) Marshal() ([]byte, error) { - var buf bytes.Buffer - enc := gob.NewEncoder(&buf) - if err := enc.Encode(addr); err != nil { - return nil, fmt.Errorf("encode Address: %w", err) - } - return buf.Bytes(), nil -} diff --git a/shared/relay/messages/auth/auth.go b/shared/relay/messages/auth/auth.go deleted file mode 100644 index 9c2511f2f..000000000 --- a/shared/relay/messages/auth/auth.go +++ /dev/null @@ -1,43 +0,0 @@ -// Deprecated: This package is deprecated and will be removed in a future release. -package auth - -import ( - "bytes" - "encoding/gob" - "fmt" -) - -type Algorithm int - -const ( - AlgoUnknown Algorithm = iota - AlgoHMACSHA256 - AlgoHMACSHA512 -) - -func (a Algorithm) String() string { - switch a { - case AlgoHMACSHA256: - return "HMAC-SHA256" - case AlgoHMACSHA512: - return "HMAC-SHA512" - default: - return "Unknown" - } -} - -type Msg struct { - AuthAlgorithm Algorithm - AdditionalData []byte -} - -func UnmarshalMsg(data []byte) (*Msg, error) { - var msg *Msg - - buf := bytes.NewBuffer(data) - dec := gob.NewDecoder(buf) - if err := dec.Decode(&msg); err != nil { - return nil, fmt.Errorf("decode Msg: %w", err) - } - return msg, nil -} diff --git a/shared/relay/messages/message.go b/shared/relay/messages/message.go index 54671f5df..fdbf2a6e7 100644 --- a/shared/relay/messages/message.go +++ b/shared/relay/messages/message.go @@ -14,9 +14,10 @@ const ( CurrentProtocolVersion = 1 MsgTypeUnknown MsgType = 0 - // Deprecated: Use MsgTypeAuth instead. - MsgTypeHello = 1 - // Deprecated: Use MsgTypeAuthResponse instead. + // MsgTypeHello and MsgTypeHelloResponse are the removed legacy handshake + // message types. They are retained only to reserve wire values 1 and 2 so + // the values are never reused; the server rejects both. + MsgTypeHello = 1 MsgTypeHelloResponse = 2 MsgTypeTransport = 3 MsgTypeClose = 4 @@ -42,10 +43,6 @@ const ( offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth - // hello message - headerSizeHello = sizeOfMagicByte + peerIDSize - headerSizeHelloResp = 0 - // transport headerSizeTransport = peerIDSize offsetTransportID = sizeOfProtoHeader @@ -113,7 +110,6 @@ func DetermineClientMessageType(msg []byte) (MsgType, error) { msgType := MsgType(msg[1]) switch msgType { case - MsgTypeHello, MsgTypeAuth, MsgTypeTransport, MsgTypeClose, @@ -135,7 +131,6 @@ func DetermineServerMessageType(msg []byte) (MsgType, error) { msgType := MsgType(msg[1]) switch msgType { case - MsgTypeHelloResponse, MsgTypeAuthResponse, MsgTypeTransport, MsgTypeClose, @@ -148,67 +143,6 @@ func DetermineServerMessageType(msg []byte) (MsgType, error) { } } -// Deprecated: Use MarshalAuthMsg instead. -// MarshalHelloMsg initial hello message -// The Hello message is the first message sent by a client after establishing a connection with the Relay server. This -// message is used to authenticate the client with the server. The authentication is done using an HMAC method. -// The protocol does not limit to use HMAC, it can be any other method. If the authentication failed the server will -// close the network connection without any response. -func MarshalHelloMsg(peerID PeerID, additions []byte) ([]byte, error) { - msg := make([]byte, sizeOfProtoHeader+sizeOfMagicByte, sizeOfProtoHeader+headerSizeHello+len(additions)) - - msg[0] = byte(CurrentProtocolVersion) - msg[1] = byte(MsgTypeHello) - - copy(msg[sizeOfProtoHeader:sizeOfProtoHeader+sizeOfMagicByte], magicHeader) - - msg = append(msg, peerID[:]...) - msg = append(msg, additions...) - - return msg, nil -} - -// Deprecated: Use UnmarshalAuthMsg instead. -// UnmarshalHelloMsg extracts peerID and the additional data from the hello message. The Additional data is used to -// authenticate the client with the server. -func UnmarshalHelloMsg(msg []byte) (*PeerID, []byte, error) { - if len(msg) < sizeOfProtoHeader+headerSizeHello { - return nil, nil, ErrInvalidMessageLength - } - if !bytes.Equal(msg[sizeOfProtoHeader:sizeOfProtoHeader+sizeOfMagicByte], magicHeader) { - return nil, nil, errors.New("invalid magic header") - } - - peerID := PeerID(msg[sizeOfProtoHeader+sizeOfMagicByte : sizeOfProtoHeader+headerSizeHello]) - - return &peerID, msg[headerSizeHello:], nil -} - -// Deprecated: Use MarshalAuthResponse instead. -// MarshalHelloResponse creates a response message to the hello message. -// In case of success connection the server response with a Hello Response message. This message contains the server's -// instance URL. This URL will be used by choose the common Relay server in case if the peers are in different Relay -// servers. -func MarshalHelloResponse(additionalData []byte) ([]byte, error) { - msg := make([]byte, sizeOfProtoHeader, sizeOfProtoHeader+headerSizeHelloResp+len(additionalData)) - - msg[0] = byte(CurrentProtocolVersion) - msg[1] = byte(MsgTypeHelloResponse) - - msg = append(msg, additionalData...) - - return msg, nil -} - -// Deprecated: Use UnmarshalAuthResponse instead. -// UnmarshalHelloResponse extracts the additional data from the hello response message. -func UnmarshalHelloResponse(msg []byte) ([]byte, error) { - if len(msg) < sizeOfProtoHeader+headerSizeHelloResp { - return nil, ErrInvalidMessageLength - } - return msg, nil -} - // MarshalAuthMsg initial authentication message // The Auth message is the first message sent by a client after establishing a connection with the Relay server. This // message is used to authenticate the client with the server. The authentication is done using an HMAC method. diff --git a/shared/relay/messages/message_test.go b/shared/relay/messages/message_test.go index 59a89cad1..26a504063 100644 --- a/shared/relay/messages/message_test.go +++ b/shared/relay/messages/message_test.go @@ -4,28 +4,11 @@ import ( "testing" ) -func TestMarshalHelloMsg(t *testing.T) { - peerID := HashID("abdFAaBcawquEiCMzAabYosuUaGLtSNhKxz+") - msg, err := MarshalHelloMsg(peerID, nil) - if err != nil { - t.Fatalf("error: %v", err) - } - - msgType, err := DetermineClientMessageType(msg) - if err != nil { - t.Fatalf("error: %v", err) - } - - if msgType != MsgTypeHello { - t.Errorf("expected %d, got %d", MsgTypeHello, msgType) - } - - receivedPeerID, _, err := UnmarshalHelloMsg(msg) - if err != nil { - t.Fatalf("error: %v", err) - } - if receivedPeerID.String() != peerID.String() { - t.Errorf("expected %s, got %s", peerID, receivedPeerID) +func TestDetermineClientMessageTypeRejectsHello(t *testing.T) { + // The reserved legacy Hello message (type 1) must be rejected by the server. + msg := []byte{byte(CurrentProtocolVersion), byte(MsgTypeHello)} + if _, err := DetermineClientMessageType(msg); err == nil { + t.Fatalf("expected hello message type to be rejected") } } From d64e9542ebf334aebc71da666590b93241c33352 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 20 Jul 2026 14:45:50 +0200 Subject: [PATCH 3/3] [proxy] Bedrock cost-allocation metadata + per-provider metadata_disabled (#6791) --- .../modules/agentnetwork/catalog/catalog.go | 18 ++++ .../modules/agentnetwork/synthesizer.go | 10 ++- .../modules/agentnetwork/synthesizer_test.go | 88 +++++++++++++++++++ .../modules/agentnetwork/types/provider.go | 10 +++ .../agentnetwork/types/provider_test.go | 35 ++++++++ .../builtin/llm_identity_inject/factory.go | 5 ++ .../builtin/llm_identity_inject/middleware.go | 40 ++++++++- .../llm_identity_inject/middleware_test.go | 40 +++++++++ shared/management/http/api/openapi.yml | 9 ++ shared/management/http/api/types.gen.go | 6 ++ 10 files changed, 256 insertions(+), 5 deletions(-) diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index 962b30250..f82cffae6 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -197,6 +197,12 @@ type JSONMetadataInjection struct { // enforces a 128-char limit per value; oversized values are // truncated rather than failing the request. 0 disables the cap. MaxValueLength int + // Sanitize, when true, replaces characters outside the destination's + // accepted set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to a limited character + // class, so unsanitized group display names (e.g. containing spaces) would + // make Bedrock reject the request with 400. + Sanitize bool } // providers is the canonical list of supported Agent Network providers. @@ -329,6 +335,18 @@ var providers = []Provider{ {ID: "amazon.nova-lite", Label: "Amazon Nova Lite (Bedrock)", InputPer1k: 0.00006, OutputPer1k: 0.00024, ContextWindow: 300000}, {ID: "amazon.nova-micro", Label: "Amazon Nova Micro (Bedrock)", InputPer1k: 0.000035, OutputPer1k: 0.00014, ContextWindow: 128000}, }, + // Bedrock accepts a cost-allocation metadata header; stamp the caller's + // user + authorizing group so spend can be attributed in AWS Cost + // Management. Sanitized because Bedrock restricts the value character set. + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + }, }, { ID: "vertex_ai_api", diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go index 74ac91845..95fe91773 100644 --- a/management/internals/modules/agentnetwork/synthesizer.go +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -540,6 +540,7 @@ type identityInjectJSONMetadata struct { UserKey string `json:"user_key,omitempty"` GroupsKey string `json:"groups_key,omitempty"` MaxValueLength int `json:"max_value_length,omitempty"` + Sanitize bool `json:"sanitize,omitempty"` } // buildIdentityInjectConfigJSON walks the enabled providers and emits @@ -583,9 +584,11 @@ func buildIdentityInjectConfigJSON(providers []*types.Provider, groupIndex map[s func buildIdentityInjectRule(p *types.Provider, entry catalog.Provider) (identityInjectProvider, bool) { rule := identityInjectProvider{ProviderID: p.ID} // Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the - // shape silently when the catalog entry doesn't declare one — extras - // can still apply, see below. - if entry.IdentityInjection != nil { + // shape silently when the catalog entry doesn't declare one, or when the + // operator disabled metadata for this provider — extras can still apply, + // see below. MetadataDisabled suppresses only the identity dimensions + // (user + authorizing group), not the catalog's routing ExtraHeaders. + if !p.MetadataDisabled && entry.IdentityInjection != nil { switch { case entry.IdentityInjection.HeaderPair != nil: rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair) @@ -651,6 +654,7 @@ func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInject UserKey: userKey, GroupsKey: groupsKey, MaxValueLength: jm.MaxValueLength, + Sanitize: jm.Sanitize, } } diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go index 9d55bddf1..206d1d12a 100644 --- a/management/internals/modules/agentnetwork/synthesizer_test.go +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -698,6 +698,94 @@ func TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable(t *testing.T) "same fixed-schema guarantee for the groups dimension") } +// TestSynthesizeServices_IdentityInject_Bedrock pins Bedrock's cost-allocation +// metadata: a JSONMetadata shape emitting X-Amzn-Bedrock-Request-Metadata with +// the reserved user/group keys, sanitized to Bedrock's accepted charset. +func TestSynthesizeServices_IdentityInject_Bedrock(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata, "Bedrock uses the JSONMetadata shape for cost-allocation metadata") + assert.Nil(t, entry.HeaderPair, "shapes are mutually exclusive") + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", entry.JSONMetadata.Header, + "the caller identity lands in Bedrock's cost-allocation metadata header") + assert.Equal(t, "user", entry.JSONMetadata.UserKey) + assert.Equal(t, "group", entry.JSONMetadata.GroupsKey) + assert.True(t, entry.JSONMetadata.Sanitize, + "Bedrock restricts the metadata value charset, so values must be sanitized") +} + +// TestSynthesizeServices_MetadataDisabled_SuppressesInjection verifies the +// per-provider opt-out: a provider with MetadataDisabled set emits no +// identity-inject entry (Bedrock has no catalog ExtraHeaders, so the whole +// entry is dropped). +func TestSynthesizeServices_MetadataDisabled_SuppressesInjection(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.MetadataDisabled = true + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + assert.Empty(t, injectCfg.Providers, + "metadata_disabled must drop the provider's identity-inject entry") +} + // TestSynthesizeServices_IdentityInject_Vercel pins Vercel AI // Gateway's wiring: HeaderPair shape with fixed wire names dictated // by Vercel's Custom Reporting API (ai-reporting-user / diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go index 2e3195481..b3287168e 100644 --- a/management/internals/modules/agentnetwork/types/provider.go +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -51,6 +51,12 @@ type Provider struct { // private or self-signed certificate. The synthesiser propagates it into // the router route so the proxy dials that provider's upstream insecurely. SkipTLSVerification bool `gorm:"column:skip_tls_verification"` + // MetadataDisabled suppresses identity metadata injection for this provider. + // Metadata (the caller's user + authorizing group) is injected by default; + // when true the synthesiser omits the provider's identity-inject shape, so no + // user/group headers (e.g. Bedrock's X-Amzn-Bedrock-Request-Metadata) are + // stamped. Catalog ExtraHeaders (routing config) are unaffected. + MetadataDisabled bool `gorm:"column:metadata_disabled"` // SessionPrivateKey + SessionPublicKey are the ed25519 keypair the // synthesised reverse-proxy service uses to sign / verify session // JWTs after a successful OIDC handshake. Generated once on @@ -137,6 +143,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { if req.SkipTlsVerification != nil { p.SkipTLSVerification = *req.SkipTlsVerification } + if req.MetadataDisabled != nil { + p.MetadataDisabled = *req.MetadataDisabled + } // Identity-header overrides for catalogs flagged Customizable. // nil pointer = "field omitted on the wire" → leave the stored // value untouched (per the openapi description). Empty string is @@ -170,6 +179,7 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { Models: models, Enabled: p.Enabled, SkipTlsVerification: p.SkipTLSVerification, + MetadataDisabled: p.MetadataDisabled, CreatedAt: &created, UpdatedAt: &updated, } diff --git a/management/internals/modules/agentnetwork/types/provider_test.go b/management/internals/modules/agentnetwork/types/provider_test.go index 1195499e7..f9756bb8b 100644 --- a/management/internals/modules/agentnetwork/types/provider_test.go +++ b/management/internals/modules/agentnetwork/types/provider_test.go @@ -42,3 +42,38 @@ func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) { assert.False(t, p.SkipTLSVerification, "explicit false must clear skip_tls_verification") assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value") } + +// TestProvider_MetadataDisabled_RoundTrip covers the request→provider→response +// mapping of metadata_disabled, with the same update semantics: nil preserves, +// explicit false clears. +func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) { + enable := true + disable := false + + base := func() *api.AgentNetworkProviderRequest { + return &api.AgentNetworkProviderRequest{ + ProviderId: "bedrock_api", + Name: "bedrock", + UpstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + } + } + + p := NewProvider("acc-1") + + req := base() + req.MetadataDisabled = &enable + p.FromAPIRequest(req) + assert.True(t, p.MetadataDisabled, "create with metadata_disabled=true must set the field") + assert.True(t, p.ToAPIResponse().MetadataDisabled, "response must surface metadata_disabled") + + // Omitting the field on update leaves the stored value untouched. + p.FromAPIRequest(base()) + assert.True(t, p.MetadataDisabled, "omitting metadata_disabled on update must preserve it") + + // Explicit false clears it (re-enables metadata). + req = base() + req.MetadataDisabled = &disable + p.FromAPIRequest(req) + assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled") + assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value") +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go index 8594c392d..8a5314337 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go @@ -64,6 +64,11 @@ type JSONMetadataRule struct { UserKey string `json:"user_key,omitempty"` GroupsKey string `json:"groups_key,omitempty"` MaxValueLength int `json:"max_value_length,omitempty"` + // Sanitize replaces characters outside the destination provider's accepted + // set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to [A-Za-z0-9 +-=._:/@]; + // group display names with other characters would otherwise 400. + Sanitize bool `json:"sanitize,omitempty"` } // Config is the on-wire configuration accepted by the factory. An diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go index ee3f1c20d..722588a15 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go @@ -292,15 +292,21 @@ func applyJSONMetadata(rule *JSONMetadataRule, in *middleware.Input) *middleware mutations := &middleware.Mutations{} mutations.HeadersRemove = append(mutations.HeadersRemove, rule.Header) + emit := func(v string) string { + if rule.Sanitize { + v = sanitizeMetadataValue(v) + } + return truncate(v, rule.MaxValueLength) + } payload := map[string]string{} if rule.UserKey != "" { if identity := identityFor(in); identity != "" { - payload[rule.UserKey] = truncate(identity, rule.MaxValueLength) + payload[rule.UserKey] = emit(identity) } } if rule.GroupsKey != "" { if csv := authorisingTagsCSV(in); csv != "" { - payload[rule.GroupsKey] = truncate(csv, rule.MaxValueLength) + payload[rule.GroupsKey] = emit(csv) } } if len(payload) == 0 { @@ -359,6 +365,36 @@ func truncate(s string, maxBytes int) string { return s[:maxBytes] } +// sanitizeMetadataValue replaces any character outside AWS Bedrock's accepted +// request-metadata class — letters, digits, space, and + - = . _ : / @ — with +// '_'. This keeps values (notably the groups CSV, whose commas are rejected, and +// group display names with arbitrary characters) from making Bedrock reject the +// request with 400. The result stays opaque to the gateway. +func sanitizeMetadataValue(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if metadataCharAllowed(r) { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +func metadataCharAllowed(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return true + } + switch r { + case ' ', '+', '-', '=', '.', '_', ':', '/', '@': + return true + } + return false +} + // tagsIDsFromAuthorising reads llm_router's authorising-groups metadata // (a CSV of group ids) and returns the parsed slice. Returns nil when // the key is absent or empty so the caller can fall back to the full diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go index aab1271d8..8ec0930b5 100644 --- a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go @@ -304,6 +304,46 @@ func TestInject_JSONMetadata_TruncatesValues(t *testing.T) { "per-value byte length must be capped at MaxValueLength") } +// TestInject_JSONMetadata_Sanitize pins the AWS-Bedrock sanitization path: when +// Sanitize is set, characters outside Bedrock's accepted metadata class +// (notably the groups CSV comma and arbitrary characters in group display +// names) are replaced with '_' so Bedrock doesn't reject the request. Allowed +// characters (letters, digits, spaces, and @ . _ : / + - =) pass through. +func TestInject_JSONMetadata_Sanitize(t *testing.T) { + rule := ProviderInjection{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + } + mw := New(Config{Providers: []ProviderInjection{rule}}) + in := newInput(portkeyProvider, "alice", []string{"g1", "g2"}) + in.UserEmail = "alice@example.com" + // Group display names carry characters Bedrock rejects (comma, '#'); the CSV + // join adds another comma between the two groups. + in.UserGroupNames = []string{"Eng,Team", "Ops#1"} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.Len(t, out.Mutations.HeadersAdd, 1) + added := out.Mutations.HeadersAdd[0] + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", added.Key, + "the Bedrock cost-allocation header carries the metadata JSON") + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(added.Value), &payload)) + assert.Equal(t, "alice@example.com", payload["user"], + "'@' and '.' are in Bedrock's accepted set and must be preserved") + assert.NotContains(t, payload["group"], ",", "commas must be sanitized — Bedrock rejects them") + assert.NotContains(t, payload["group"], "#", "disallowed characters must be sanitized") + assert.Contains(t, payload["group"], "Eng", "allowed characters must be preserved") +} + // TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd verifies the // anti-spoof Remove still fires when there's nothing to stamp. func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 529cd2225..47ca80a7c 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5164,6 +5164,10 @@ components: type: boolean description: Whether upstream TLS certificate verification is skipped when the proxy dials this provider's URL. Intended for self-hosted / internal gateways behind a private or self-signed certificate. example: false + metadata_disabled: + type: boolean + description: Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it. + example: false created_at: type: string format: date-time @@ -5184,6 +5188,7 @@ components: - models - enabled - skip_tls_verification + - metadata_disabled - created_at - updated_at AgentNetworkProviderRequest: @@ -5240,6 +5245,10 @@ components: type: boolean description: Skip upstream TLS certificate verification when the proxy dials this provider's URL. For self-hosted / internal gateways behind a private or self-signed certificate. Defaults to false. When omitted on update, the stored value is left unchanged. example: false + metadata_disabled: + type: boolean + description: Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged. + example: false required: - provider_id - name diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 4956f9a9b..a9e98cf84 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2227,6 +2227,9 @@ type AgentNetworkProvider struct { // IdentityHeaderUserId Wire header name the proxy stamps with the caller's display identity (user email or peer name) when the catalog entry's HeaderPair is `customizable`. Empty disables stamping for this dimension. Ignored when the catalog entry has a fixed HeaderPair (e.g. LiteLLM, Portkey). Used today by Bifrost: typical values are `x-bf-lh-netbird_user_id` (always-on log metadata) or `x-bf-dim-netbird_user_id` (Prometheus / OTEL — requires the label to be pre-declared in the gateway's `client.prometheus_labels` config). IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` + // MetadataDisabled Whether identity metadata injection is disabled for this provider. When enabled (the default), the proxy stamps the caller's user and authorizing group onto upstream requests as provider-specific metadata (e.g. AWS Bedrock's X-Amzn-Bedrock-Request-Metadata header). Set true to suppress it. + MetadataDisabled bool `json:"metadata_disabled"` + // Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. Models []AgentNetworkProviderModel `json:"models"` @@ -2278,6 +2281,9 @@ type AgentNetworkProviderRequest struct { // IdentityHeaderUserId Wire header name for the caller's display identity. See AgentNetworkProvider.identity_header_user_id. When omitted on a request, the stored value is left unchanged; pass an empty string explicitly to clear it (which disables stamping for this dimension). IdentityHeaderUserId *string `json:"identity_header_user_id,omitempty"` + // MetadataDisabled Disable identity metadata injection (the caller's user + authorizing group) for this provider. Defaults to false (metadata is injected). When omitted on update, the stored value is left unchanged. + MetadataDisabled *bool `json:"metadata_disabled,omitempty"` + // Models Models exposed through this endpoint, with the operator's per-1k input/output prices. Empty means all catalog models are allowed at catalog prices. Models *[]AgentNetworkProviderModel `json:"models,omitempty"`