From fd1f90d9e122c2205e6684764b6e3b658c21a0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20B=C3=BCcker?= Date: Thu, 26 Mar 2026 13:24:11 +0100 Subject: [PATCH 1/7] rough aspire launch working. tunnel not working yet --- .gitignore | 3 + deploy/local-dev-tunnel/Chart.yaml | 6 + .../local-dev-tunnel/templates/_helpers.tpl | 9 + .../local-dev-tunnel/templates/configmap.yaml | 19 ++ .../templates/deployment.yaml | 53 +++++ .../local-dev-tunnel/templates/service.yaml | 43 ++++ deploy/local-dev-tunnel/values.yaml | 30 +++ docs/local-development-with-aks-tunnel.md | 209 ++++++++++++++++++ scripts/local-tunnel/extract-cert.sh | 53 +++++ scripts/local-tunnel/setup-tunnel.sh | 111 ++++++++++ .../Helpers/CertificateHelper.cs | 64 ++++++ .../Helpers/HelmHelper.cs | 57 +++++ .../Helpers/KubernetesHelper.cs | 48 ++++ .../Helpers/SshKeyHelper.cs | 60 +++++ .../Helpers/WslHelper.cs | 68 ++++++ .../Program.cs | 123 +++++++++++ .../Properties/launchSettings.json | 29 +++ .../RecordingBot.LocalTunnel.AppHost.csproj | 18 ++ .../TunnelConfiguration.cs | 62 ++++++ .../appsettings.json | 17 ++ .../ServiceSetup/AzureSettings.cs | 44 ++-- src/TeamsRecordingBot.sln | 10 + 22 files changed, 1122 insertions(+), 14 deletions(-) create mode 100644 deploy/local-dev-tunnel/Chart.yaml create mode 100644 deploy/local-dev-tunnel/templates/_helpers.tpl create mode 100644 deploy/local-dev-tunnel/templates/configmap.yaml create mode 100644 deploy/local-dev-tunnel/templates/deployment.yaml create mode 100644 deploy/local-dev-tunnel/templates/service.yaml create mode 100644 deploy/local-dev-tunnel/values.yaml create mode 100644 docs/local-development-with-aks-tunnel.md create mode 100644 scripts/local-tunnel/extract-cert.sh create mode 100644 scripts/local-tunnel/setup-tunnel.sh create mode 100644 src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs create mode 100644 src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs create mode 100644 src/RecordingBot.LocalTunnel.AppHost/Helpers/KubernetesHelper.cs create mode 100644 src/RecordingBot.LocalTunnel.AppHost/Helpers/SshKeyHelper.cs create mode 100644 src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs create mode 100644 src/RecordingBot.LocalTunnel.AppHost/Program.cs create mode 100644 src/RecordingBot.LocalTunnel.AppHost/Properties/launchSettings.json create mode 100644 src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj create mode 100644 src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs create mode 100644 src/RecordingBot.LocalTunnel.AppHost/appsettings.json diff --git a/.gitignore b/.gitignore index 58a6a4ce2..07a57f918 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +# Local tunnel key material and certificates +.aks-tunnel/ + # User-specific files *.rsuser *.suo diff --git a/deploy/local-dev-tunnel/Chart.yaml b/deploy/local-dev-tunnel/Chart.yaml new file mode 100644 index 000000000..bcfa1e21c --- /dev/null +++ b/deploy/local-dev-tunnel/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: local-dev-tunnel +description: Helm chart for deploying a reverse SSH tunnel endpoint for local bot development +type: application +version: 1.0.0 +appVersion: 1.0.0 diff --git a/deploy/local-dev-tunnel/templates/_helpers.tpl b/deploy/local-dev-tunnel/templates/_helpers.tpl new file mode 100644 index 000000000..aa077d092 --- /dev/null +++ b/deploy/local-dev-tunnel/templates/_helpers.tpl @@ -0,0 +1,9 @@ +{{/* Default deployment name */}} +{{- define "fullName" -}} + {{- default $.Release.Name $.Values.global.override.name -}} +{{- end -}} + +{{/* Default namespace */}} +{{- define "namespace" -}} + {{- default $.Release.Namespace $.Values.global.override.namespace -}} +{{- end -}} diff --git a/deploy/local-dev-tunnel/templates/configmap.yaml b/deploy/local-dev-tunnel/templates/configmap.yaml new file mode 100644 index 000000000..18bdfc4e7 --- /dev/null +++ b/deploy/local-dev-tunnel/templates/configmap.yaml @@ -0,0 +1,19 @@ +{{- $fullName := include "fullName" . -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $fullName }}-ssh-config + namespace: {{ include "namespace" . }} + labels: + app: {{ $fullName }} +data: + sshd_config: | + Port {{ .Values.ssh.port }} + PermitRootLogin no + PasswordAuthentication no + PubkeyAuthentication yes + AuthorizedKeysFile /etc/ssh/authorized_keys/%u + GatewayPorts yes + AllowTcpForwarding yes + ClientAliveInterval 30 + ClientAliveCountMax 3 diff --git a/deploy/local-dev-tunnel/templates/deployment.yaml b/deploy/local-dev-tunnel/templates/deployment.yaml new file mode 100644 index 000000000..98b1944f6 --- /dev/null +++ b/deploy/local-dev-tunnel/templates/deployment.yaml @@ -0,0 +1,53 @@ +{{- $fullName := include "fullName" . -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + namespace: {{ include "namespace" . }} + labels: + app: {{ $fullName }} +spec: + replicas: 1 + selector: + matchLabels: + app: {{ $fullName }} + template: + metadata: + labels: + app: {{ $fullName }} + spec: + nodeSelector: + kubernetes.io/os: linux + containers: + - name: sshd + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + command: ["/bin/sh", "-c"] + args: + - | + set -e + apk add --no-cache openssh-server + ssh-keygen -A + adduser -D -s /bin/sh tunnel + mkdir -p /etc/ssh/authorized_keys + echo "{{ .Values.ssh.authorizedKey }}" > /etc/ssh/authorized_keys/tunnel + chmod 644 /etc/ssh/authorized_keys/tunnel + cp /etc/ssh-config/sshd_config /etc/ssh/sshd_config + echo "SSH tunnel endpoint ready" + exec /usr/sbin/sshd -D -e + ports: + - containerPort: {{ .Values.ssh.port }} + name: ssh + - containerPort: {{ .Values.ports.signaling }} + name: signaling + - containerPort: {{ .Values.ports.media }} + name: media + volumeMounts: + - name: ssh-config + mountPath: /etc/ssh-config + readOnly: true + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: ssh-config + configMap: + name: {{ $fullName }}-ssh-config diff --git a/deploy/local-dev-tunnel/templates/service.yaml b/deploy/local-dev-tunnel/templates/service.yaml new file mode 100644 index 000000000..199b000d1 --- /dev/null +++ b/deploy/local-dev-tunnel/templates/service.yaml @@ -0,0 +1,43 @@ +{{- $fullName := include "fullName" . -}} +# Public-facing service: only signaling and media ports +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ include "namespace" . }} + labels: + app: {{ $fullName }} +spec: + type: {{ .Values.service.type }} + {{- if .Values.service.publicIp }} + loadBalancerIP: {{ .Values.service.publicIp }} + {{- end }} + ports: + - name: https + port: {{ .Values.service.httpsPort }} + targetPort: signaling + protocol: TCP + - name: media + port: {{ .Values.service.mediaPort }} + targetPort: media + protocol: TCP + selector: + app: {{ $fullName }} +--- +# Internal SSH service: reachable only via kubectl port-forward +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-ssh + namespace: {{ include "namespace" . }} + labels: + app: {{ $fullName }} +spec: + type: ClusterIP + ports: + - name: ssh + port: {{ .Values.ssh.port }} + targetPort: ssh + protocol: TCP + selector: + app: {{ $fullName }} diff --git a/deploy/local-dev-tunnel/values.yaml b/deploy/local-dev-tunnel/values.yaml new file mode 100644 index 000000000..95e7582a3 --- /dev/null +++ b/deploy/local-dev-tunnel/values.yaml @@ -0,0 +1,30 @@ +global: + override: + name: "" + namespace: "" + +image: + repository: alpine + tag: "3.20" + +ssh: + authorizedKey: "" + port: 22 + +ports: + signaling: 9441 + media: 8445 + +service: + type: LoadBalancer + publicIp: null + httpsPort: 8443 + mediaPort: 28551 + +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi diff --git a/docs/local-development-with-aks-tunnel.md b/docs/local-development-with-aks-tunnel.md new file mode 100644 index 000000000..3a410dd6a --- /dev/null +++ b/docs/local-development-with-aks-tunnel.md @@ -0,0 +1,209 @@ +# Local Development with AKS Reverse Tunnel + +Debug your Teams recording bot locally while it's reachable from the internet, using your existing AKS cluster as a publicly-accessible tunnel endpoint. No third-party tunneling services (ngrok, Dev Tunnels) required. + +## Architecture + +``` +Internet → AKS LoadBalancer :8443/:28551 (TCP passthrough) + ↓ + Tunnel Pod (SSH server, NO TLS) + ↓ + kubectl port-forward (WSL → AKS pod :22) + ↓ + SSH reverse tunnel (-R 0.0.0.0:9441:localhost:9441) + ↓ + WSL (mirrored mode) → Windows localhost + ↓ + Local Bot Application (handles TLS with extracted cert) +``` + +**Key points:** + +- The tunnel pod is a lightweight Alpine container with an SSH server — it does **not** handle TLS. +- TLS termination happens on your local Windows machine, exactly as it does in the production container. +- The certificate is extracted from the existing cert-manager Kubernetes secret and imported into your local Windows certificate store. +- WSL mirrored networking mode means the bot on Windows can bind to the same `localhost` ports that the SSH reverse tunnel forwards to. + +## Prerequisites + +- **WSL 2** with mirrored networking mode enabled (`.wslconfig`): + ```ini + [wsl2] + networkingMode=mirrored + ``` +- **kubectl** installed in WSL, configured with your AKS context +- **helm v3** installed in WSL +- **ssh** and **ssh-keygen** available in WSL (usually pre-installed) +- **VC++ Redistributable** installed on Windows (required by Microsoft.Skype.Bots.Media) +- **.NET 8 SDK** on Windows + +## Quick Start (Aspire AppHost) + +The Aspire AppHost automates the entire setup: SSH keys, Helm deployment, certificate extraction, tunnel establishment, and bot startup. + +### 1. Configure + +Edit `src/RecordingBot.LocalTunnel.AppHost/appsettings.json`: + +```json +{ + "Tunnel": { + "KubeContext": "my-aks-cluster", + "Namespace": "dev-tunnel", + "ReleaseName": "local-dev-tunnel", + "BotReleaseName": "teams-recording-bot", + "BotNamespace": "default", + "Host": "bot.example.com", + "PublicHttpsPort": 8443, + "PublicMediaPort": 28551, + "SignalingPort": 9441, + "MediaPort": 8445, + "LocalSshPort": 2222, + "PublicIp": null, + "ChartPath": "../../deploy/local-dev-tunnel" + } +} +``` + +Key values to fill in: + +| Setting | Description | +|---------|-------------| +| `KubeContext` | Your AKS kubectl context name (run `kubectl config get-contexts` in WSL) | +| `Host` | The FQDN of your bot (must match the cert-manager certificate) | +| `BotReleaseName` | The Helm release name of your deployed bot (used to find the TLS secret) | +| `BotNamespace` | Namespace where the bot's TLS secret lives | +| `PublicIp` | (Optional) Pin the LoadBalancer to a specific IP | + +You also need to configure the bot secrets (AAD app, etc.) via a `.env` file in `src/RecordingBot.Console/`. The AppHost will set the tunnel-specific env vars automatically. + +### 2. Run + +```powershell +cd src +dotnet run --project RecordingBot.LocalTunnel.AppHost +``` + +The AppHost will: + +1. **Generate SSH keys** in `~/.aks-tunnel/` (first run only) +2. **Deploy the tunnel Helm chart** to your AKS cluster +3. **Extract the TLS certificate** from the cert-manager secret +4. **Import it** into your Windows certificate store +5. **Start kubectl port-forward** and the **SSH reverse tunnel** +6. **Launch the bot** with correct configuration + +The Aspire dashboard (http://localhost:15888) shows all running resources. + +### 3. Debug + +Set `RecordingBot.LocalTunnel.AppHost` as the startup project in Visual Studio and press **F5**. The bot process will be launched with the debugger attached. + +## Manual Setup (Shell Scripts) + +If you prefer to manage the tunnel manually without Aspire. + +### Deploy Tunnel & Start + +From WSL: + +```bash +# Deploy tunnel and start port forwarding + SSH tunnel +./scripts/local-tunnel/setup-tunnel.sh + +# Example: +./scripts/local-tunnel/setup-tunnel.sh my-aks default +``` + +### Extract Certificate + +From WSL: + +```bash +./scripts/local-tunnel/extract-cert.sh ./certs + +# Example: +./scripts/local-tunnel/extract-cert.sh my-aks default ingress-tls-teams-recording-bot ./certs +``` + +Set the certificate path in your `.env` file: + +``` +AzureSettings__CertificatePath=./certs/tunnel-cert.pfx +``` + +### Run the Bot + +```powershell +cd src\RecordingBot.Console +dotnet run +``` + +## Cleanup + +### Uninstall the tunnel from AKS + +```bash +helm uninstall local-dev-tunnel --kube-context --namespace dev-tunnel +``` + +### Remove local SSH keys and certificates + +```powershell +Remove-Item -Recurse -Force "$env:USERPROFILE\.aks-tunnel" +``` + +### Remove certificate files + +The extracted PFX and PEM files are stored in `~/.aks-tunnel/certs/` and are removed by the command above. + +## Helm Chart Details + +The `deploy/local-dev-tunnel/` chart deploys: + +| Resource | Purpose | +|----------|---------| +| **Deployment** | Alpine pod with OpenSSH server, configured with `GatewayPorts yes` for reverse tunneling | +| **Service (LoadBalancer)** | Exposes HTTPS (:8443→:9441) and media (:28551→:8445) publicly | +| **Service (ClusterIP)** | Internal SSH service (:22), reachable only via `kubectl port-forward` | +| **ConfigMap** | SSH server configuration | + +The chart uses SSH public key authentication. The public key is passed as a Helm value and written to the pod's authorized_keys file. + +### Customizable Values + +| Value | Default | Description | +|-------|---------|-------------| +| `ssh.authorizedKey` | `""` | SSH public key (set automatically by AppHost) | +| `ports.signaling` | `9441` | Pod port for call signaling | +| `ports.media` | `8445` | Pod port for media | +| `service.httpsPort` | `8443` | Public HTTPS port on LoadBalancer | +| `service.mediaPort` | `28551` | Public media port on LoadBalancer | +| `service.publicIp` | `null` | Pin LoadBalancer to specific IP | + +## Troubleshooting + +### SSH tunnel fails to connect + +- Ensure kubectl port-forward is running: `kubectl get pods -n dev-tunnel` +- Check the pod logs: `kubectl logs -n dev-tunnel -l app=local-dev-tunnel` +- Verify the SSH key was deployed: `kubectl exec -n dev-tunnel deploy/local-dev-tunnel -- cat /etc/ssh/authorized_keys/tunnel` + +### Certificate loading fails + +- Verify the PFX file exists at the path shown during setup (default: `~/.aks-tunnel/certs/tunnel-cert.pfx`). +- The bot loads the certificate directly from the PFX file when `AzureSettings:CertificatePath` is set. No Windows certificate store import is needed. +- For production / container deployments the bot falls back to store lookup via `AzureSettings:CertificateThumbprint`. + +### Bot starts but Teams can't reach it + +- Verify the LoadBalancer has an external IP: `kubectl get svc -n dev-tunnel` +- Check DNS: the `Host` must resolve to the LoadBalancer IP (or use the IP directly in the Bot Framework registration). +- Verify the SSH reverse tunnel is active (check the Aspire dashboard or process list). +- Test connectivity: `curl -k https://:8443/` should reach your local bot. + +### WSL networking issues + +- Confirm mirrored mode: `wsl --version` should show networkingMode=mirrored. +- Test that a port opened in WSL is reachable from Windows: `nc -l 9441` in WSL, `Test-NetConnection localhost -Port 9441` in PowerShell. diff --git a/scripts/local-tunnel/extract-cert.sh b/scripts/local-tunnel/extract-cert.sh new file mode 100644 index 000000000..887333dbe --- /dev/null +++ b/scripts/local-tunnel/extract-cert.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────── +# extract-cert.sh +# +# Extracts the TLS certificate from the AKS cert-manager secret and +# saves PEM files locally. For use outside of the Aspire AppHost. +# +# Usage: +# ./extract-cert.sh [output-dir] +# +# Example: +# ./extract-cert.sh my-aks-ctx default ingress-tls-teams-recording-bot ./certs +# ────────────────────────────────────────────────────────────────── +set -euo pipefail + +CONTEXT="${1:?Usage: $0 [output-dir]}" +NAMESPACE="${2:?Usage: $0 [output-dir]}" +SECRET_NAME="${3:?Usage: $0 [output-dir]}" +OUTPUT_DIR="${4:-.}" + +mkdir -p "$OUTPUT_DIR" + +echo "Extracting TLS certificate from secret '$SECRET_NAME' in '$NAMESPACE' (context: $CONTEXT)..." + +kubectl get secret "$SECRET_NAME" \ + --context "$CONTEXT" \ + -n "$NAMESPACE" \ + -o jsonpath='{.data.tls\.crt}' | base64 -d > "$OUTPUT_DIR/tls.crt" + +kubectl get secret "$SECRET_NAME" \ + --context "$CONTEXT" \ + -n "$NAMESPACE" \ + -o jsonpath='{.data.tls\.key}' | base64 -d > "$OUTPUT_DIR/tls.key" + +echo "Saved:" +echo " $OUTPUT_DIR/tls.crt" +echo " $OUTPUT_DIR/tls.key" + +# Optionally convert to PFX (requires openssl) +if command -v openssl &> /dev/null; then + openssl pkcs12 -export -out "$OUTPUT_DIR/tunnel-cert.pfx" \ + -passout pass: \ + -inkey "$OUTPUT_DIR/tls.key" \ + -in "$OUTPUT_DIR/tls.crt" + echo " $OUTPUT_DIR/tunnel-cert.pfx (password: empty)" +fi + +echo "" +echo "To import on Windows (run as admin in PowerShell):" +echo " certutil -f -p \"\" -importpfx $OUTPUT_DIR\\tunnel-cert.pfx" +echo "" +echo "Then retrieve the thumbprint:" +echo " (Get-PfxCertificate -FilePath $OUTPUT_DIR\\tunnel-cert.pfx).Thumbprint" diff --git a/scripts/local-tunnel/setup-tunnel.sh b/scripts/local-tunnel/setup-tunnel.sh new file mode 100644 index 000000000..bf71139f7 --- /dev/null +++ b/scripts/local-tunnel/setup-tunnel.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────── +# setup-tunnel.sh +# +# Deploys the local-dev-tunnel Helm chart and establishes a reverse +# SSH tunnel from the AKS pod to localhost. Run this from WSL. +# +# Prerequisites: +# - kubectl configured with your AKS context +# - helm v3 installed +# - ssh-keygen, ssh, sshpass (or SSH key pair) +# +# Usage: +# ./setup-tunnel.sh [release-name] [public-https-port] [public-media-port] +# +# The script will: +# 1. Generate an SSH key pair (if not present) +# 2. Deploy the Helm chart with the public key +# 3. Wait for the LoadBalancer IP +# 4. Start kubectl port-forward +# 5. Start the SSH reverse tunnel +# ────────────────────────────────────────────────────────────────── +set -euo pipefail + +CONTEXT="${1:?Usage: $0 [release-name]}" +NAMESPACE="${2:?Usage: $0 [release-name]}" +RELEASE="${3:-local-dev-tunnel}" +CHART_DIR="$(cd "$(dirname "$0")/../../deploy/local-dev-tunnel" && pwd)" +KEY_DIR="$HOME/.aks-tunnel" +KEY_PATH="$KEY_DIR/id_ed25519" + +SIGNALING_PORT=9441 +MEDIA_PORT=8445 +LOCAL_SSH_PORT=2222 +PUBLIC_HTTPS_PORT=${4:-8443} +PUBLIC_MEDIA_PORT=${5:-28551} + +cleanup() { + echo "" + echo "Shutting down tunnel..." + [ -n "${SSH_PID:-}" ] && kill "$SSH_PID" 2>/dev/null || true + [ -n "${KUBECTL_PID:-}" ] && kill "$KUBECTL_PID" 2>/dev/null || true + echo "Done." +} +trap cleanup EXIT INT TERM + +# ── 1. SSH Key ──────────────────────────────────────────────────── +mkdir -p "$KEY_DIR" +if [ ! -f "$KEY_PATH" ]; then + echo "[1/5] Generating SSH key pair..." + ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -q +else + echo "[1/5] SSH key pair exists." +fi +PUBLIC_KEY=$(cat "${KEY_PATH}.pub") + +# ── 2. Deploy Helm chart ───────────────────────────────────────── +echo "[2/5] Deploying Helm chart..." +helm upgrade "$RELEASE" "$CHART_DIR" \ + --kube-context "$CONTEXT" \ + --namespace "$NAMESPACE" --create-namespace \ + --install --wait --timeout 120s \ + --set ssh.authorizedKey="$PUBLIC_KEY" \ + --set ports.signaling="$SIGNALING_PORT" \ + --set ports.media="$MEDIA_PORT" + +# ── 3. Wait for LoadBalancer IP ─────────────────────────────────── +echo "[3/5] Waiting for LoadBalancer IP..." +for i in $(seq 1 60); do + IP=$(kubectl get svc "$RELEASE" \ + --context "$CONTEXT" -n "$NAMESPACE" \ + -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true) + if [ -n "$IP" ] && [ "$IP" != "" ]; then + echo " External IP: $IP" + break + fi + sleep 3 +done +if [ -z "${IP:-}" ]; then + echo "ERROR: Timed out waiting for LoadBalancer IP." + exit 1 +fi + +# ── 4. kubectl port-forward ─────────────────────────────────────── +echo "[4/5] Starting kubectl port-forward..." +kubectl --context "$CONTEXT" port-forward "svc/${RELEASE}-ssh" -n "$NAMESPACE" "${LOCAL_SSH_PORT}:22" & +KUBECTL_PID=$! +sleep 3 + +# ── 5. SSH reverse tunnel ──────────────────────────────────────── +echo "[5/5] Establishing SSH reverse tunnel..." +echo " -R 0.0.0.0:${SIGNALING_PORT}:localhost:${SIGNALING_PORT}" +echo " -R 0.0.0.0:${MEDIA_PORT}:localhost:${MEDIA_PORT}" +echo "" +echo "Tunnel is active. Traffic on ${IP}:${PUBLIC_HTTPS_PORT} → localhost:${SIGNALING_PORT}" +echo " ${IP}:${PUBLIC_MEDIA_PORT} → localhost:${MEDIA_PORT}" +echo "Press Ctrl+C to stop." +echo "" + +ssh -i "$KEY_PATH" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=15 \ + -o ServerAliveCountMax=3 \ + -R "0.0.0.0:${SIGNALING_PORT}:localhost:${SIGNALING_PORT}" \ + -R "0.0.0.0:${MEDIA_PORT}:localhost:${MEDIA_PORT}" \ + -p "$LOCAL_SSH_PORT" tunnel@localhost -N & +SSH_PID=$! + +wait "$SSH_PID" diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs new file mode 100644 index 000000000..a1d5a9410 --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace RecordingBot.LocalTunnel.AppHost.Helpers; + +/// +/// Extracts TLS certificates from an AKS Kubernetes secret and +/// saves them as local PFX / PEM files for the bot to load directly. +/// +public static class CertificateHelper +{ + private static readonly string CertDir = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aks-tunnel", "certs"); + + /// + /// Extracts the TLS certificate from the Kubernetes secret, converts to PFX, + /// and returns the absolute path to the PFX file. + /// + public static async Task ExtractAsync(TunnelConfiguration config, CancellationToken ct = default) + { + Console.WriteLine("Extracting TLS certificate from AKS..."); + + Directory.CreateDirectory(CertDir); + + // 1. Fetch secret data from Kubernetes + var secretJson = await WslHelper.RunAsync( + $"kubectl get secret {config.TlsSecretName} " + + $"--context {config.KubeContext} " + + $"-n {config.BotNamespace} " + + $"-o json", + ct); + + using var doc = JsonDocument.Parse(secretJson); + var data = doc.RootElement.GetProperty("data"); + var certB64 = data.GetProperty("tls.crt").GetString() + ?? throw new InvalidOperationException("tls.crt not found in secret."); + var keyB64 = data.GetProperty("tls.key").GetString() + ?? throw new InvalidOperationException("tls.key not found in secret."); + + // 2. Decode PEM content + var certPem = Encoding.UTF8.GetString(Convert.FromBase64String(certB64)); + var keyPem = Encoding.UTF8.GetString(Convert.FromBase64String(keyB64)); + + // Save PEM files for reference + await File.WriteAllTextAsync(Path.Combine(CertDir, "tls.crt"), certPem, ct); + await File.WriteAllTextAsync(Path.Combine(CertDir, "tls.key"), keyPem, ct); + + Console.WriteLine(" Certificate PEM files saved to " + CertDir); + + // 3. Create PFX from PEM + using var cert = X509Certificate2.CreateFromPem(certPem, keyPem); + var pfxBytes = cert.Export(X509ContentType.Pfx, ""); + var pfxPath = Path.Combine(CertDir, "tunnel-cert.pfx"); + await File.WriteAllBytesAsync(pfxPath, pfxBytes, ct); + + Console.WriteLine($" PFX file saved to {pfxPath}"); + return pfxPath; + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs new file mode 100644 index 000000000..5ef5dd246 --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace RecordingBot.LocalTunnel.AppHost.Helpers; + +/// +/// Deploys and manages the local-dev-tunnel Helm chart via WSL kubectl/helm. +/// +public static class HelmHelper +{ + /// + /// Deploys (or upgrades) the tunnel Helm chart to the AKS cluster. + /// + public static async Task DeployAsync(TunnelConfiguration config, string sshPublicKey, CancellationToken ct = default) + { + Console.WriteLine("Deploying local-dev-tunnel Helm chart..."); + + var chartPath = WslHelper.ToWslPath(Path.GetFullPath(config.ChartPath)); + + var setArgs = new List + { + $"--set ssh.authorizedKey=\"{sshPublicKey}\"", + $"--set ports.signaling={config.SignalingPort}", + $"--set ports.media={config.MediaPort}", + $"--set service.httpsPort={config.PublicHttpsPort}", + $"--set service.mediaPort={config.PublicMediaPort}", + }; + + if (!string.IsNullOrWhiteSpace(config.PublicIp)) + setArgs.Add($"--set service.publicIp={config.PublicIp}"); + + var cmd = + $"helm upgrade {config.ReleaseName} {chartPath} " + + $"--kube-context {config.KubeContext} " + + $"--namespace {config.Namespace} --create-namespace " + + $"--install --wait --timeout 120s " + + string.Join(" ", setArgs); + + await WslHelper.RunAsync(cmd, ct); + + Console.WriteLine(" Helm chart deployed."); + } + + /// + /// Uninstalls the tunnel Helm chart. + /// + public static async Task UninstallAsync(TunnelConfiguration config, CancellationToken ct = default) + { + Console.WriteLine("Uninstalling local-dev-tunnel Helm chart..."); + await WslHelper.RunAsync( + $"helm uninstall {config.ReleaseName} --kube-context {config.KubeContext} --namespace {config.Namespace}", + ct); + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/KubernetesHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/KubernetesHelper.cs new file mode 100644 index 000000000..77ec2358b --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/KubernetesHelper.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RecordingBot.LocalTunnel.AppHost.Helpers; + +/// +/// Queries Kubernetes resources via WSL kubectl. +/// +public static class KubernetesHelper +{ + /// + /// Waits for the tunnel LoadBalancer service to be assigned an external IP. + /// Returns the external IP address. + /// + public static async Task WaitForLoadBalancerIpAsync(TunnelConfiguration config, CancellationToken ct = default) + { + Console.WriteLine("Waiting for LoadBalancer external IP..."); + + for (int i = 0; i < 60; i++) + { + ct.ThrowIfCancellationRequested(); + + var ip = await WslHelper.RunAsync( + $"kubectl get svc {config.ReleaseName} " + + $"--context {config.KubeContext} " + + $"-n {config.Namespace} " + + $"-o jsonpath='{{.status.loadBalancer.ingress[0].ip}}'", + ct); + + if (!string.IsNullOrWhiteSpace(ip) && ip != "" && ip != "''") + { + // Strip surrounding quotes that jsonpath may leave + ip = ip.Trim('\'', '"'); + if (!string.IsNullOrWhiteSpace(ip)) + { + Console.WriteLine($" LoadBalancer IP: {ip}"); + return ip; + } + } + + await Task.Delay(3000, ct); + } + + throw new TimeoutException( + "Timed out waiting for LoadBalancer IP. Check your AKS cluster and tunnel service."); + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/SshKeyHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/SshKeyHelper.cs new file mode 100644 index 000000000..755f50042 --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/SshKeyHelper.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace RecordingBot.LocalTunnel.AppHost.Helpers; + +/// +/// Generates and manages an SSH key pair used for the reverse tunnel. +/// Keys are stored at ~/.aks-tunnel/ on the Windows side and accessed +/// from WSL via the /mnt/c/ mount. +/// +public static class SshKeyHelper +{ + private static readonly string KeyDir = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aks-tunnel"); + + public static string PrivateKeyPath => Path.Combine(KeyDir, "id_ed25519"); + public static string PublicKeyPath => PrivateKeyPath + ".pub"; + + /// + /// Ensures an SSH key pair exists, generating one if needed. + /// Returns the Windows path to the private key. + /// + public static async Task EnsureKeyPairAsync(CancellationToken ct = default) + { + Directory.CreateDirectory(KeyDir); + + if (File.Exists(PrivateKeyPath) && File.Exists(PublicKeyPath)) + { + Console.WriteLine(" SSH key pair already exists."); + return PrivateKeyPath; + } + + Console.WriteLine(" Generating SSH key pair for tunnel..."); + + var wslKeyPath = WslHelper.ToWslPath(PrivateKeyPath); + + // Remove any stale key first + await WslHelper.RunAsync($"rm -f {wslKeyPath} {wslKeyPath}.pub", ct); + await WslHelper.RunAsync($"ssh-keygen -t ed25519 -f {wslKeyPath} -N '' -q", ct); + + // Fix permissions from WSL side (Windows-mounted files default to 0777) + await WslHelper.RunAsync($"chmod 600 {wslKeyPath}", ct); + + if (!File.Exists(PrivateKeyPath)) + throw new FileNotFoundException("SSH key generation succeeded in WSL but file not found on Windows side.", PrivateKeyPath); + + Console.WriteLine($" SSH key pair written to {KeyDir}"); + return PrivateKeyPath; + } + + /// + /// Reads the public key content for embedding in Helm values. + /// + public static async Task GetPublicKeyAsync(CancellationToken ct = default) + { + return (await File.ReadAllTextAsync(PublicKeyPath, ct)).Trim(); + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs new file mode 100644 index 000000000..e562be34b --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs @@ -0,0 +1,68 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace RecordingBot.LocalTunnel.AppHost.Helpers; + +/// +/// Executes commands inside WSL from Windows. +/// +public static class WslHelper +{ + /// + /// Converts a Windows path to a WSL-compatible path. + /// E.g. C:\Users\me\.aks-tunnel\id_ed25519 → /mnt/c/Users/me/.aks-tunnel/id_ed25519 + /// + public static string ToWslPath(string windowsPath) + { + var full = Path.GetFullPath(windowsPath); + + // Drive letter + var drive = char.ToLowerInvariant(full[0]); + var rest = full[2..].Replace('\\', '/'); // skip "C:" + + return $"/mnt/{drive}{rest}"; + } + + /// + /// Runs a command inside WSL and returns stdout. Throws on non-zero exit. + /// + public static async Task RunAsync(string command, CancellationToken ct = default) + { + Console.WriteLine($" [wsl] {command}"); + + var psi = new ProcessStartInfo + { + FileName = "wsl", + Arguments = $"bash -l -c \"{command.Replace("\"", "\\\"")}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start WSL process."); + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + proc.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + await proc.WaitForExitAsync(ct); + + if (proc.ExitCode != 0) + { + throw new InvalidOperationException( + $"WSL command failed (exit {proc.ExitCode}):\n cmd: {command}\n stderr: {stderr}"); + } + + return stdout.ToString().Trim(); + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/Program.cs b/src/RecordingBot.LocalTunnel.AppHost/Program.cs new file mode 100644 index 000000000..772dd654a --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/Program.cs @@ -0,0 +1,123 @@ +using System; +using Aspire.Hosting; +using Microsoft.Extensions.Configuration; +using RecordingBot.LocalTunnel.AppHost; +using RecordingBot.LocalTunnel.AppHost.Helpers; + +// ────────────────────────────────────────────────────────────────── +// Local Development Tunnel – Aspire AppHost +// +// Orchestrates: +// 1. SSH key generation +// 2. Helm chart deployment (reverse-tunnel pod in AKS) +// 3. TLS certificate extraction from the AKS cert-manager secret +// 4. kubectl port-forward → SSH reverse tunnel → local bot +// ────────────────────────────────────────────────────────────────── + +var builder = DistributedApplication.CreateBuilder(args); + +// ── Configuration ──────────────────────────────────────────────── +var config = builder.Configuration.GetSection("Tunnel").Get() + ?? throw new InvalidOperationException( + "Missing 'Tunnel' section in appsettings.json. " + + "Copy appsettings.json and fill in your AKS details."); + +config.Validate(); + + +Console.WriteLine("╔══════════════════════════════════════════════╗"); +Console.WriteLine("║ Local Development Tunnel – Setup Phase ║"); +Console.WriteLine("╚══════════════════════════════════════════════╝"); +Console.WriteLine(); + +string certPath; +string externalIp; + +try +{ + // ── Phase 1: SSH key pair ──────────────────────────────────────── + Console.WriteLine("[1/4] SSH Key Pair"); + await SshKeyHelper.EnsureKeyPairAsync(); + var publicKey = await SshKeyHelper.GetPublicKeyAsync(); + Console.WriteLine(); + + // ── Phase 2: Deploy tunnel chart ───────────────────────────────── + Console.WriteLine("[2/4] Helm Chart Deployment"); + await HelmHelper.DeployAsync(config, publicKey); + Console.WriteLine(); + + // ── Phase 3: Extract certificate to local PFX ─────────────────── + Console.WriteLine("[3/4] Certificate Extraction"); + certPath = await CertificateHelper.ExtractAsync(config); + Console.WriteLine(); + + // ── Phase 4: Wait for LoadBalancer IP ──────────────────────────── + Console.WriteLine("[4/4] LoadBalancer"); + externalIp = await KubernetesHelper.WaitForLoadBalancerIpAsync(config); + Console.WriteLine(); + + Console.WriteLine("╔══════════════════════════════════════════════╗"); + Console.WriteLine("║ Setup complete – starting services ║"); + Console.WriteLine("╚══════════════════════════════════════════════╝"); + Console.WriteLine($" External IP : {externalIp}"); + Console.WriteLine($" Host : {config.Host}"); + Console.WriteLine($" Certificate : {certPath}"); + Console.WriteLine(); +} +catch (Exception ex) +{ + Console.WriteLine(); + Console.WriteLine("╔══════════════════════════════════════════════╗"); + Console.WriteLine("║ Setup FAILED ║"); + Console.WriteLine("╚══════════════════════════════════════════════╝"); + Console.WriteLine($"Error: {ex.GetType().Name}"); + Console.WriteLine($"Message: {ex.Message}"); + if (ex.InnerException != null) + { + Console.WriteLine($"Inner: {ex.InnerException.Message}"); + } + Console.WriteLine(); + Console.WriteLine("Stack trace:"); + Console.WriteLine(ex.StackTrace); + Console.WriteLine(); + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(); + return; +} + +// ── Aspire Resources ───────────────────────────────────────────── + +var wslKeyPath = WslHelper.ToWslPath(SshKeyHelper.PrivateKeyPath); + +// kubectl port-forward: maps local port to the tunnel pod's SSH ClusterIP service +builder.AddExecutable("kubectl-forward", "wsl", ".", + "kubectl", "--context", config.KubeContext, + "port-forward", $"svc/{config.ReleaseName}-ssh", + "-n", config.Namespace, + $"{config.LocalSshPort}:22"); + +// SSH reverse tunnel: retries until kubectl port-forward is ready, +// then keeps the tunnel alive. +var sshCommand = + $"while ! ssh -i {wslKeyPath} " + + $"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ExitOnForwardFailure=yes " + + $"-o ServerAliveInterval=15 -o ServerAliveCountMax=3 " + + $"-R 0.0.0.0:{config.SignalingPort}:localhost:{config.SignalingPort} " + + $"-R 0.0.0.0:{config.MediaPort}:localhost:{config.MediaPort} " + + $"-p {config.LocalSshPort} tunnel@localhost -N 2>/dev/null; do " + + $"echo 'SSH tunnel not ready, retrying in 3s...'; sleep 3; done"; + +builder.AddExecutable("ssh-tunnel", "wsl", ".", + "bash", "-c", sshCommand); + +// The Teams recording bot, configured for local development +builder.AddProject("recording-bot") + .WithEnvironment("AzureSettings__CertificatePath", certPath) + .WithEnvironment("AzureSettings__ServiceDnsName", config.Host) + .WithEnvironment("AzureSettings__CallSignalingPort", config.SignalingPort.ToString()) + .WithEnvironment("AzureSettings__CallSignalingPublicPort", config.PublicHttpsPort.ToString()) + .WithEnvironment("AzureSettings__InstanceInternalPort", config.MediaPort.ToString()) + .WithEnvironment("AzureSettings__InstancePublicPort", config.PublicMediaPort.ToString()) + .WithEnvironment("AzureSettings__PodName", "bot-0"); + +await builder.Build().RunAsync(); diff --git a/src/RecordingBot.LocalTunnel.AppHost/Properties/launchSettings.json b/src/RecordingBot.LocalTunnel.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..115f94d95 --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17123", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21123", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22123" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15123", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19123", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20123" + } + } + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj b/src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj new file mode 100644 index 000000000..86e1b23ca --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + latest + enable + x64 + x64 + true + + + + + + + + diff --git a/src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs b/src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs new file mode 100644 index 000000000..8e16ba476 --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace RecordingBot.LocalTunnel.AppHost; + +public sealed class TunnelConfiguration +{ + [Required] + public string KubeContext { get; set; } = ""; + + public string Namespace { get; set; } = "dev-tunnel"; + + public string ReleaseName { get; set; } = "local-dev-tunnel"; + + [Required] + public string BotReleaseName { get; set; } = "teams-recording-bot"; + + public string BotNamespace { get; set; } = "default"; + + [Required] + public string Host { get; set; } = ""; + + public int PublicHttpsPort { get; set; } = 8443; + + public int PublicMediaPort { get; set; } = 28551; + + public int SignalingPort { get; set; } = 9441; + + public int MediaPort { get; set; } = 8445; + + public int LocalSshPort { get; set; } = 2222; + + public string? PublicIp { get; set; } + + [Required] + public string ChartPath { get; set; } = "../../deploy/local-dev-tunnel"; + + /// + /// Resolves the TLS secret name using the same naming convention as the main chart. + /// + public string TlsSecretName => $"ingress-tls-{BotReleaseName}"; + + public void Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(KubeContext) || KubeContext == "YOUR_AKS_CONTEXT") + errors.Add("Tunnel:KubeContext must be set to your AKS kubectl context name."); + + if (string.IsNullOrWhiteSpace(Host) || Host == "YOUR_BOT_FQDN") + errors.Add("Tunnel:Host must be set to your bot's FQDN (e.g. bot.example.com)."); + + if (string.IsNullOrWhiteSpace(ChartPath)) + errors.Add("Tunnel:ChartPath must point to the local-dev-tunnel Helm chart directory."); + + if (errors.Count > 0) + throw new InvalidOperationException( + "Tunnel configuration is invalid:\n" + string.Join("\n", errors.Select(e => $" - {e}"))); + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/appsettings.json b/src/RecordingBot.LocalTunnel.AppHost/appsettings.json new file mode 100644 index 000000000..e30bfbc19 --- /dev/null +++ b/src/RecordingBot.LocalTunnel.AppHost/appsettings.json @@ -0,0 +1,17 @@ +{ + "Tunnel": { + "KubeContext": "aks-samples", + "Namespace": "dev-tunnel", + "ReleaseName": "local-dev-tunnel", + "BotReleaseName": "aks-sample", + "BotNamespace": "teams-recording-bot", + "Host": "aks-samples.westeurope.cloudapp.azure.com", + "PublicHttpsPort": 8443, + "PublicMediaPort": 28551, + "SignalingPort": 9441, + "MediaPort": 8445, + "LocalSshPort": 2222, + "PublicIp": null, + "ChartPath": "../../deploy/local-dev-tunnel" + } +} diff --git a/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs b/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs index 1d5b7a774..f597494a2 100644 --- a/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs +++ b/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs @@ -15,6 +15,8 @@ public partial class AzureSettings : IAzureSettings public string ServicePath {get;set;} = "/"; public string ServiceCname { get; set; } public string CertificateThumbprint { get; set; } + public string CertificatePath { get; set; } + public string CertificatePassword { get; set; } = ""; public Uri CallControlBaseUrl { get; set; } public Uri PlaceCallEndpointUrl { get; set; } public MediaPlatformSettings MediaPlatformSettings { get; private set; } @@ -45,7 +47,7 @@ public void Initialize() ServiceCname = ServiceDnsName; } - Certificate = GetCertificateFromStore(); + Certificate = LoadCertificate(); int podNumber = 0; @@ -79,27 +81,41 @@ public void Initialize() } /// - /// Helper to search the certificate store by its thumbprint. + /// Loads the certificate from a PFX file path if configured, + /// otherwise falls back to searching the Windows certificate store by thumbprint. /// - /// Certificate if found. - /// No certificate with thumbprint {CertificateThumbprint} was found in the machine store. - private X509Certificate2 GetCertificateFromStore() + private X509Certificate2 LoadCertificate() { - using (X509Store store = new(StoreName.My, StoreLocation.LocalMachine)) + // Prefer file-based certificate (local development) + if (!string.IsNullOrWhiteSpace(CertificatePath)) { - store.Open(OpenFlags.ReadOnly); - var certs = store.Certificates.Find(X509FindType.FindByThumbprint, CertificateThumbprint, validOnly: false); + if (!System.IO.File.Exists(CertificatePath)) + throw new System.IO.FileNotFoundException($"Certificate file not found at '{CertificatePath}'.", CertificatePath); - if (certs.Count != 1) + return new X509Certificate2(CertificatePath, CertificatePassword ?? ""); + } + + // Fall back to certificate store lookup by thumbprint (production / container) + if (!string.IsNullOrWhiteSpace(CertificateThumbprint)) + { + foreach (var location in new[] { StoreLocation.LocalMachine, StoreLocation.CurrentUser }) { - throw new CertNotFoundException($"No certificate with thumbprint {CertificateThumbprint} was found in the machine store.") - { - Thumbprint = CertificateThumbprint - }; + using var store = new X509Store(StoreName.My, location); + store.Open(OpenFlags.ReadOnly); + var certs = store.Certificates.Find(X509FindType.FindByThumbprint, CertificateThumbprint, validOnly: false); + + if (certs.Count == 1) + return certs[0]; } - return certs[0]; + throw new CertNotFoundException($"No certificate with thumbprint {CertificateThumbprint} was found in the machine or user certificate store.") + { + Thumbprint = CertificateThumbprint + }; } + + throw new InvalidOperationException( + "No certificate configured. Set either AzureSettings:CertificatePath (file) or AzureSettings:CertificateThumbprint (store)."); } [GeneratedRegex(@"\d+$")] diff --git a/src/TeamsRecordingBot.sln b/src/TeamsRecordingBot.sln index f830982bc..7d50c0eb9 100644 --- a/src/TeamsRecordingBot.sln +++ b/src/TeamsRecordingBot.sln @@ -15,6 +15,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.Console", "Rec EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RecordingBot.UiTests", "RecordingBot.UiTests\RecordingBot.UiTests.csproj", "{4B4859C9-86B5-4AF8-B305-DF4D9EF45358}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RecordingBot.LocalTunnel.AppHost", "RecordingBot.LocalTunnel.AppHost\RecordingBot.LocalTunnel.AppHost.csproj", "{B1A2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -63,6 +65,14 @@ Global {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Release|Any CPU.Build.0 = Release|Any CPU {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Release|x64.ActiveCfg = Release|Any CPU {4B4859C9-86B5-4AF8-B305-DF4D9EF45358}.Release|x64.Build.0 = Release|Any CPU + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|x64 + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|x64 + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|x64 + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|x64 + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|x64 + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|x64 + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|x64 + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From a1d13e054c8c038c63682fce355bcca4bcbc80a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20B=C3=BCcker?= Date: Thu, 26 Mar 2026 14:06:02 +0100 Subject: [PATCH 2/7] Working tunnel I believe --- .gitignore | 3 +- .../Helpers/WslHelper.cs | 5 ++- .../Program.cs | 37 ++++++++++++++++--- .../RecordingBot.LocalTunnel.AppHost.csproj | 1 + .../appsettings.json | 13 +++++++ 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 07a57f918..d23374326 100644 --- a/.gitignore +++ b/.gitignore @@ -407,4 +407,5 @@ FodyWeavers.xsd # Project specific files that should be ignored *.env deploy/teams-recording-bot/charts/* -src/RecordingBot.Console/cache/* \ No newline at end of file +src/RecordingBot.Console/cache/* +/src/RecordingBot.Console/ecs-cache diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs index e562be34b..a08714f8e 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/WslHelper.cs @@ -34,10 +34,13 @@ public static async Task RunAsync(string command, CancellationToken ct = { Console.WriteLine($" [wsl] {command}"); + // Ensure common paths like /snap/bin are in PATH for tools like kubectl + var wrappedCommand = $"export PATH=\"$PATH:/snap/bin:/usr/local/bin\" && {command}"; + var psi = new ProcessStartInfo { FileName = "wsl", - Arguments = $"bash -l -c \"{command.Replace("\"", "\\\"")}\"", + Arguments = $"bash -l -c \"{wrappedCommand.Replace("\"", "\\\"")}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/RecordingBot.LocalTunnel.AppHost/Program.cs b/src/RecordingBot.LocalTunnel.AppHost/Program.cs index 772dd654a..42d9e82de 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/Program.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/Program.cs @@ -90,11 +90,10 @@ var wslKeyPath = WslHelper.ToWslPath(SshKeyHelper.PrivateKeyPath); // kubectl port-forward: maps local port to the tunnel pod's SSH ClusterIP service +var kubectlCommand = $"export PATH=\"$PATH:/snap/bin:/usr/local/bin\" && kubectl --context {config.KubeContext} port-forward svc/{config.ReleaseName}-ssh -n {config.Namespace} {config.LocalSshPort}:22"; + builder.AddExecutable("kubectl-forward", "wsl", ".", - "kubectl", "--context", config.KubeContext, - "port-forward", $"svc/{config.ReleaseName}-ssh", - "-n", config.Namespace, - $"{config.LocalSshPort}:22"); + "bash", "-l", "-c", kubectlCommand); // SSH reverse tunnel: retries until kubectl port-forward is ready, // then keeps the tunnel alive. @@ -112,12 +111,40 @@ // The Teams recording bot, configured for local development builder.AddProject("recording-bot") + // Authentication settings - TODO: Set these in user secrets or environment variables + .WithEnvironment("AzureSettings__AadAppId", builder.Configuration["AzureSettings:AadAppId"] ?? "") + .WithEnvironment("AzureSettings__AadAppSecret", builder.Configuration["AzureSettings:AadAppSecret"] ?? "") + + // Certificate configuration .WithEnvironment("AzureSettings__CertificatePath", certPath) + .WithEnvironment("AzureSettings__CertificatePassword", "") + + // Network configuration .WithEnvironment("AzureSettings__ServiceDnsName", config.Host) + .WithEnvironment("AzureSettings__ServicePath", "/") + .WithEnvironment("AzureSettings__ServiceCname", config.Host) .WithEnvironment("AzureSettings__CallSignalingPort", config.SignalingPort.ToString()) .WithEnvironment("AzureSettings__CallSignalingPublicPort", config.PublicHttpsPort.ToString()) .WithEnvironment("AzureSettings__InstanceInternalPort", config.MediaPort.ToString()) .WithEnvironment("AzureSettings__InstancePublicPort", config.PublicMediaPort.ToString()) - .WithEnvironment("AzureSettings__PodName", "bot-0"); + + // Graph API endpoint + .WithEnvironment("AzureSettings__PlaceCallEndpointUrl", "https://graph.microsoft.com/v1.0") + + // Pod identification + .WithEnvironment("AzureSettings__PodName", "bot-0") + + // Media recording configuration + .WithEnvironment("AzureSettings__MediaFolder", builder.Configuration["AzureSettings:MediaFolder"] ?? "archive") + .WithEnvironment("AzureSettings__IsStereo", builder.Configuration["AzureSettings:IsStereo"] ?? "false") + .WithEnvironment("AzureSettings__WAVSampleRate", builder.Configuration["AzureSettings:WAVSampleRate"] ?? "0") + .WithEnvironment("AzureSettings__WAVQuality", builder.Configuration["AzureSettings:WAVQuality"] ?? "100") + + // Event capture configuration + .WithEnvironment("AzureSettings__CaptureEvents", builder.Configuration["AzureSettings:CaptureEvents"] ?? "false") + .WithEnvironment("AzureSettings__EventsFolder", builder.Configuration["AzureSettings:EventsFolder"] ?? "events") + .WithEnvironment("AzureSettings__TopicName", builder.Configuration["AzureSettings:TopicName"] ?? "recordingbotevents") + .WithEnvironment("AzureSettings__TopicKey", builder.Configuration["AzureSettings:TopicKey"] ?? "") + .WithEnvironment("AzureSettings__RegionName", builder.Configuration["AzureSettings:RegionName"] ?? "australiaeast"); await builder.Build().RunAsync(); diff --git a/src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj b/src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj index 86e1b23ca..00fd37f70 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj +++ b/src/RecordingBot.LocalTunnel.AppHost/RecordingBot.LocalTunnel.AppHost.csproj @@ -8,6 +8,7 @@ x64 x64 true + fa83b4d7-1c4b-4b8d-81e5-686bc0b3d402 diff --git a/src/RecordingBot.LocalTunnel.AppHost/appsettings.json b/src/RecordingBot.LocalTunnel.AppHost/appsettings.json index e30bfbc19..535c19ff7 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/appsettings.json +++ b/src/RecordingBot.LocalTunnel.AppHost/appsettings.json @@ -13,5 +13,18 @@ "LocalSshPort": 2222, "PublicIp": null, "ChartPath": "../../deploy/local-dev-tunnel" + }, + "AzureSettings": { + "AadAppId": "YOUR_APP_ID__USE_SECRETS_TO_OVERRIDE", + "AadAppSecret": "YOUR_APP_SECRET__USE_SECRETS_TO_OVERRIDE", + "MediaFolder": "archive", + "EventsFolder": "events", + "CaptureEvents": false, + "TopicName": "recordingbotevents", + "TopicKey": "", + "RegionName": "australiaeast", + "IsStereo": false, + "WAVSampleRate": 0, + "WAVQuality": 100 } } From 8133399ebc9e2ec07f5a3d0319ab8b64fe12b25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20B=C3=BCcker?= Date: Mon, 30 Mar 2026 22:36:34 +0200 Subject: [PATCH 3/7] wip: disable https redirection --- src/RecordingBot.Services/ServiceSetup/AppHost.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RecordingBot.Services/ServiceSetup/AppHost.cs b/src/RecordingBot.Services/ServiceSetup/AppHost.cs index 6ec464988..7e501bbb3 100644 --- a/src/RecordingBot.Services/ServiceSetup/AppHost.cs +++ b/src/RecordingBot.Services/ServiceSetup/AppHost.cs @@ -80,7 +80,7 @@ public void Boot(string[] args) app.UsePathBase(azureSettings.PodPathBase); app.UsePathBase(azureSettings.ServicePath); - app.UseHttpsRedirection(); + //app.UseHttpsRedirection(); app.UseAuthorization(); From d4a7dede2fb312f65abd6a12afa76d9bc9fa4ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20B=C3=BCcker?= Date: Tue, 12 May 2026 11:53:03 +0200 Subject: [PATCH 4/7] feat: local dev tunneling with aspire --- .../local-dev-tunnel/templates/_helpers.tpl | 14 ++ .../local-dev-tunnel/templates/configmap.yaml | 6 +- .../templates/deployment.yaml | 6 +- .../local-dev-tunnel/templates/ingress.yaml | 30 +++ .../local-dev-tunnel/templates/service.yaml | 24 ++- deploy/local-dev-tunnel/values.yaml | 9 +- .../Helpers/CertificateHelper.cs | 6 +- .../Helpers/HelmHelper.cs | 6 +- .../Program.cs | 180 +++++++++++++++--- .../TunnelConfiguration.cs | 18 +- .../appsettings.json | 6 +- 11 files changed, 257 insertions(+), 48 deletions(-) create mode 100644 deploy/local-dev-tunnel/templates/ingress.yaml diff --git a/deploy/local-dev-tunnel/templates/_helpers.tpl b/deploy/local-dev-tunnel/templates/_helpers.tpl index aa077d092..5f52ebf3e 100644 --- a/deploy/local-dev-tunnel/templates/_helpers.tpl +++ b/deploy/local-dev-tunnel/templates/_helpers.tpl @@ -7,3 +7,17 @@ {{- define "namespace" -}} {{- default $.Release.Namespace $.Values.global.override.namespace -}} {{- end -}} + +{{/* Define ingress-tls secret name */}} +{{- define "ingress.tls.secretName" -}} + {{- printf "ingress-tls-%s" .Values.ingress.botReleaseName -}} +{{- end -}} + +{{/* Check if host is set */}} +{{- define "hostName" -}} + {{- if .Values.ingress.host -}} + {{- printf "%s" $.Values.ingress.host -}} + {{- else -}} + {{- fail "You need to specify ingress.host" -}} + {{- end -}} +{{- end -}} diff --git a/deploy/local-dev-tunnel/templates/configmap.yaml b/deploy/local-dev-tunnel/templates/configmap.yaml index 18bdfc4e7..d227fd66c 100644 --- a/deploy/local-dev-tunnel/templates/configmap.yaml +++ b/deploy/local-dev-tunnel/templates/configmap.yaml @@ -10,9 +10,9 @@ data: sshd_config: | Port {{ .Values.ssh.port }} PermitRootLogin no - PasswordAuthentication no - PubkeyAuthentication yes - AuthorizedKeysFile /etc/ssh/authorized_keys/%u + PasswordAuthentication yes + PermitEmptyPasswords yes + PubkeyAuthentication no GatewayPorts yes AllowTcpForwarding yes ClientAliveInterval 30 diff --git a/deploy/local-dev-tunnel/templates/deployment.yaml b/deploy/local-dev-tunnel/templates/deployment.yaml index 98b1944f6..dbf440b95 100644 --- a/deploy/local-dev-tunnel/templates/deployment.yaml +++ b/deploy/local-dev-tunnel/templates/deployment.yaml @@ -28,11 +28,9 @@ spec: apk add --no-cache openssh-server ssh-keygen -A adduser -D -s /bin/sh tunnel - mkdir -p /etc/ssh/authorized_keys - echo "{{ .Values.ssh.authorizedKey }}" > /etc/ssh/authorized_keys/tunnel - chmod 644 /etc/ssh/authorized_keys/tunnel + echo "tunnel:" | chpasswd cp /etc/ssh-config/sshd_config /etc/ssh/sshd_config - echo "SSH tunnel endpoint ready" + echo "SSH tunnel endpoint ready (passwordless, secured by kubectl)" exec /usr/sbin/sshd -D -e ports: - containerPort: {{ .Values.ssh.port }} diff --git a/deploy/local-dev-tunnel/templates/ingress.yaml b/deploy/local-dev-tunnel/templates/ingress.yaml new file mode 100644 index 000000000..dd2b6cda8 --- /dev/null +++ b/deploy/local-dev-tunnel/templates/ingress.yaml @@ -0,0 +1,30 @@ +{{- $fullName := include "fullName" . -}} +{{- $namespace := include "namespace" . -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + namespace: {{ $namespace }} + labels: + app: {{ $fullName }} + annotations: + {{- if .Values.ingress.annotations }} + {{- toYaml .Values.ingress.annotations | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + tls: + - hosts: + - {{ include "hostName" . }} + secretName: {{ include "ingress.tls.secretName" . }} + rules: + - host: {{ include "hostName" . }} + http: + paths: + - path: {{ .Values.ingress.path }} + pathType: Prefix + backend: + service: + name: {{ $fullName }}-http + port: + number: 80 diff --git a/deploy/local-dev-tunnel/templates/service.yaml b/deploy/local-dev-tunnel/templates/service.yaml index 199b000d1..8d22de45c 100644 --- a/deploy/local-dev-tunnel/templates/service.yaml +++ b/deploy/local-dev-tunnel/templates/service.yaml @@ -1,5 +1,23 @@ {{- $fullName := include "fullName" . -}} -# Public-facing service: only signaling and media ports +# ClusterIP service for ingress HTTP traffic +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-http + namespace: {{ include "namespace" . }} + labels: + app: {{ $fullName }} +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: signaling + protocol: TCP + selector: + app: {{ $fullName }} +--- +# Public-facing LoadBalancer: only media port apiVersion: v1 kind: Service metadata: @@ -13,10 +31,6 @@ spec: loadBalancerIP: {{ .Values.service.publicIp }} {{- end }} ports: - - name: https - port: {{ .Values.service.httpsPort }} - targetPort: signaling - protocol: TCP - name: media port: {{ .Values.service.mediaPort }} targetPort: media diff --git a/deploy/local-dev-tunnel/values.yaml b/deploy/local-dev-tunnel/values.yaml index 95e7582a3..9e0a7e4ce 100644 --- a/deploy/local-dev-tunnel/values.yaml +++ b/deploy/local-dev-tunnel/values.yaml @@ -15,10 +15,17 @@ ports: signaling: 9441 media: 8445 +ingress: + enabled: true + className: traefik + host: "" + path: "" + botReleaseName: "" + annotations: {} + service: type: LoadBalancer publicIp: null - httpsPort: 8443 mediaPort: 28551 resources: diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs index a1d5a9410..a4398b525 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/CertificateHelper.cs @@ -10,7 +10,10 @@ namespace RecordingBot.LocalTunnel.AppHost.Helpers; /// /// Extracts TLS certificates from an AKS Kubernetes secret and -/// saves them as local PFX / PEM files for the bot to load directly. +/// saves them as local PFX / PEM files for the bot to use for the media port. +/// +/// Note: Signaling (HTTPS) TLS termination is handled by the ingress controller. +/// The bot only needs the certificate for the media platform (TCP with TLS). /// public static class CertificateHelper { @@ -20,6 +23,7 @@ public static class CertificateHelper /// /// Extracts the TLS certificate from the Kubernetes secret, converts to PFX, /// and returns the absolute path to the PFX file. + /// This certificate is used by the bot's media platform for TLS. /// public static async Task ExtractAsync(TunnelConfiguration config, CancellationToken ct = default) { diff --git a/src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs b/src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs index 5ef5dd246..1ffc475b4 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/Helpers/HelmHelper.cs @@ -22,11 +22,13 @@ public static async Task DeployAsync(TunnelConfiguration config, string sshPubli var setArgs = new List { - $"--set ssh.authorizedKey=\"{sshPublicKey}\"", $"--set ports.signaling={config.SignalingPort}", $"--set ports.media={config.MediaPort}", - $"--set service.httpsPort={config.PublicHttpsPort}", $"--set service.mediaPort={config.PublicMediaPort}", + $"--set ingress.host={config.Host}", + $"--set ingress.className={config.IngressClassName}", + $"--set ingress.path={config.DeveloperPathPrefix}", + $"--set ingress.botReleaseName={config.BotReleaseName}", }; if (!string.IsNullOrWhiteSpace(config.PublicIp)) diff --git a/src/RecordingBot.LocalTunnel.AppHost/Program.cs b/src/RecordingBot.LocalTunnel.AppHost/Program.cs index 42d9e82de..74231f7f3 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/Program.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Aspire.Hosting; using Microsoft.Extensions.Configuration; using RecordingBot.LocalTunnel.AppHost; @@ -7,11 +8,22 @@ // ────────────────────────────────────────────────────────────────── // Local Development Tunnel – Aspire AppHost // -// Orchestrates: +// Orchestrates local development against a remote AKS cluster, matching +// the deployed production architecture where: +// - Ingress controller terminates TLS for HTTPS signaling traffic +// - Bot handles HTTP for signaling, TLS for media +// +// Setup phases: // 1. SSH key generation -// 2. Helm chart deployment (reverse-tunnel pod in AKS) -// 3. TLS certificate extraction from the AKS cert-manager secret +// 2. Helm chart deployment (tunnel pod + ingress in AKS) +// 3. TLS certificate extraction from AKS cert-manager secret // 4. kubectl port-forward → SSH reverse tunnel → local bot +// +// Traffic flow: +// Teams → Ingress (HTTPS:8443) → Tunnel Pod (HTTP:9441) +// → SSH Tunnel → Local Bot (HTTP:9442) +// Teams → LoadBalancer (TCP:28551) → Tunnel Pod (TCP:8445) +// → SSH Tunnel → Local Bot (TLS:8445) // ────────────────────────────────────────────────────────────────── var builder = DistributedApplication.CreateBuilder(args); @@ -35,33 +47,37 @@ try { - // ── Phase 1: SSH key pair ──────────────────────────────────────── - Console.WriteLine("[1/4] SSH Key Pair"); - await SshKeyHelper.EnsureKeyPairAsync(); - var publicKey = await SshKeyHelper.GetPublicKeyAsync(); - Console.WriteLine(); - - // ── Phase 2: Deploy tunnel chart ───────────────────────────────── - Console.WriteLine("[2/4] Helm Chart Deployment"); - await HelmHelper.DeployAsync(config, publicKey); + // ── Phase 1: Deploy tunnel chart ───────────────────────────────── + Console.WriteLine("[1/4] Helm Chart Deployment"); + Console.WriteLine(" Deploying tunnel pod with ingress (passwordless SSH)..."); + await HelmHelper.DeployAsync(config, string.Empty); + Console.WriteLine(" ✓ Tunnel pod deployed"); Console.WriteLine(); - // ── Phase 3: Extract certificate to local PFX ─────────────────── - Console.WriteLine("[3/4] Certificate Extraction"); + // ── Phase 2: Extract certificate to local PFX ─────────────────── + Console.WriteLine("[2/4] Certificate Extraction"); + Console.WriteLine(" Extracting TLS certificate for media port..."); certPath = await CertificateHelper.ExtractAsync(config); Console.WriteLine(); - // ── Phase 4: Wait for LoadBalancer IP ──────────────────────────── - Console.WriteLine("[4/4] LoadBalancer"); + // ── Phase 3: Wait for LoadBalancer IP ──────────────────────────── + Console.WriteLine("[3/4] LoadBalancer"); externalIp = await KubernetesHelper.WaitForLoadBalancerIpAsync(config); Console.WriteLine(); Console.WriteLine("╔══════════════════════════════════════════════╗"); Console.WriteLine("║ Setup complete – starting services ║"); Console.WriteLine("╚══════════════════════════════════════════════╝"); - Console.WriteLine($" External IP : {externalIp}"); - Console.WriteLine($" Host : {config.Host}"); - Console.WriteLine($" Certificate : {certPath}"); + Console.WriteLine($" Architecture:"); + Console.WriteLine($" - Ingress handles TLS for HTTPS signaling"); + Console.WriteLine($" - Bot handles TLS for media port"); + Console.WriteLine($" External IP : {externalIp}"); + Console.WriteLine($" Host (HTTPS) : {config.Host}"); + Console.WriteLine($" HTTPS Port : {config.PublicHttpsPort}"); + Console.WriteLine($" Developer Path : {config.DeveloperPathPrefix}"); + Console.WriteLine($" Full URL : https://{config.Host}:{(config.PublicHttpsPort == 443 ? "" : config.PublicHttpsPort)}{config.DeveloperPathPrefix}"); + Console.WriteLine($" Media Port : {config.PublicMediaPort}"); + Console.WriteLine($" Certificate : {certPath}"); Console.WriteLine(); } catch (Exception ex) @@ -86,25 +102,61 @@ } // ── Aspire Resources ───────────────────────────────────────────── - -var wslKeyPath = WslHelper.ToWslPath(SshKeyHelper.PrivateKeyPath); +// +// Architecture (matching deployed environment): +// 1. Ingress Controller: Terminates TLS for HTTPS traffic +// - External: HTTPS on port 8443 (config.PublicHttpsPort) +// - Internal: Routes to tunnel pod HTTP on port 9441 (config.SignalingPort) +// +// 2. SSH Tunnel: Forwards HTTP signaling + TCP media from tunnel pod to localhost +// - Signaling: tunnel pod port 9441 → localhost:9442 (HTTP) +// - Media: tunnel pod port 8445 → localhost:8445 (TCP) +// +// 3. Local Bot: Listens on HTTP for signaling, uses TLS for media +// - HTTP signaling on port 9442 (CallSignalingPort + 1) +// - Media with TLS using extracted certificate +// +// ───────────────────────────────────────────────────────────────── // kubectl port-forward: maps local port to the tunnel pod's SSH ClusterIP service -var kubectlCommand = $"export PATH=\"$PATH:/snap/bin:/usr/local/bin\" && kubectl --context {config.KubeContext} port-forward svc/{config.ReleaseName}-ssh -n {config.Namespace} {config.LocalSshPort}:22"; +var kubectlCommand = $"export PATH=\"$PATH:/snap/bin:/usr/local/bin\" && " + + $"echo 'Checking if service {config.ReleaseName}-ssh exists in namespace {config.Namespace}...' && " + + $"if ! kubectl --context {config.KubeContext} get svc/{config.ReleaseName}-ssh -n {config.Namespace} &>/dev/null; then " + + $"echo 'ERROR: Service {config.ReleaseName}-ssh not found in namespace {config.Namespace}'; " + + $"echo 'Make sure the Helm chart deployed successfully.'; " + + $"kubectl --context {config.KubeContext} get svc -n {config.Namespace}; " + + $"exit 1; fi && " + + $"echo 'Service found! Starting kubectl port-forward to {config.ReleaseName}-ssh...' && " + + $"kubectl --context {config.KubeContext} port-forward svc/{config.ReleaseName}-ssh -n {config.Namespace} {config.LocalSshPort}:22"; builder.AddExecutable("kubectl-forward", "wsl", ".", "bash", "-l", "-c", kubectlCommand); -// SSH reverse tunnel: retries until kubectl port-forward is ready, -// then keeps the tunnel alive. +// SSH reverse tunnel: retries until kubectl port-forward is ready, then keeps the tunnel alive. +// Note: Bot HTTP listener is on SignalingPort + 1, so we forward SignalingPort to SignalingPort + 1 +// Authentication is handled by kubectl port-forward, so we use passwordless SSH var sshCommand = - $"while ! ssh -i {wslKeyPath} " + + $"echo 'Waiting for kubectl port-forward to establish...' && sleep 5 && " + + $"while ! nc -z localhost {config.LocalSshPort} 2>/dev/null; do " + + $"echo 'Port {config.LocalSshPort} not ready, waiting for kubectl port-forward...'; sleep 3; done && " + + $"echo 'Port {config.LocalSshPort} is ready! Establishing SSH tunnel...' && " + + $"echo 'Note: Using passwordless SSH (secured by kubectl port-forward)' && " + + $"while true; do " + + $"echo | ssh -tt " + $"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ExitOnForwardFailure=yes " + $"-o ServerAliveInterval=15 -o ServerAliveCountMax=3 " + - $"-R 0.0.0.0:{config.SignalingPort}:localhost:{config.SignalingPort} " + + $"-R 0.0.0.0:{config.SignalingPort}:localhost:{config.SignalingPort + 1} " + $"-R 0.0.0.0:{config.MediaPort}:localhost:{config.MediaPort} " + - $"-p {config.LocalSshPort} tunnel@localhost -N 2>/dev/null; do " + - $"echo 'SSH tunnel not ready, retrying in 3s...'; sleep 3; done"; + $"-p {config.LocalSshPort} tunnel@localhost -N 2>&1 | tee /tmp/ssh-debug.log; " + + $"SSH_EXIT=$?; " + + $"if [ $SSH_EXIT -eq 0 ] || grep -q 'Authenticated to' /tmp/ssh-debug.log; then " + + $"echo 'SSH tunnel established successfully!'; break; " + + $"elif grep -q 'Connection reset' /tmp/ssh-debug.log; then " + + $"echo 'Connection reset, retrying in 3s...'; sleep 3; " + + $"else " + + $"echo \"SSH tunnel failed (exit $SSH_EXIT), retrying in 3s...\"; " + + $"tail -10 /tmp/ssh-debug.log; sleep 3; " + + $"fi; done"; builder.AddExecutable("ssh-tunnel", "wsl", ".", "bash", "-c", sshCommand); @@ -121,7 +173,7 @@ // Network configuration .WithEnvironment("AzureSettings__ServiceDnsName", config.Host) - .WithEnvironment("AzureSettings__ServicePath", "/") + .WithEnvironment("AzureSettings__ServicePath", config.DeveloperPathPrefix) .WithEnvironment("AzureSettings__ServiceCname", config.Host) .WithEnvironment("AzureSettings__CallSignalingPort", config.SignalingPort.ToString()) .WithEnvironment("AzureSettings__CallSignalingPublicPort", config.PublicHttpsPort.ToString()) @@ -148,3 +200,73 @@ .WithEnvironment("AzureSettings__RegionName", builder.Configuration["AzureSettings:RegionName"] ?? "australiaeast"); await builder.Build().RunAsync(); + +static string GetKeyFingerprint(string publicKey) +{ + var parts = publicKey.Split(' '); + if (parts.Length >= 2) + { + return $"{parts[0]} ...{parts[1][^8..]}"; + } + return publicKey.Length > 50 ? $"{publicKey[..50]}..." : publicKey; +} + +static async Task VerifyPodDeploymentAsync(TunnelConfiguration config, string localPublicKey) +{ + try + { + // Check if pod is running + var podName = await WslHelper.RunAsync( + $"kubectl get pod -n {config.Namespace} -l app={config.ReleaseName} " + + $"--context {config.KubeContext} -o jsonpath='{{.items[0].metadata.name}}'"); + + if (string.IsNullOrWhiteSpace(podName)) + { + Console.WriteLine(" WARNING: Could not find tunnel pod"); + return; + } + + Console.WriteLine($" Pod: {podName}"); + + // Wait a moment for the pod to fully initialize + await Task.Delay(2000); + + // Get the authorized_keys content from the pod + var podKey = await WslHelper.RunAsync( + $"kubectl exec -n {config.Namespace} {podName} " + + $"--context {config.KubeContext} -- " + + $"cat /etc/ssh/authorized_keys/tunnel 2>/dev/null || echo 'NOTFOUND'"); + + if (podKey.Contains("NOTFOUND") || string.IsNullOrWhiteSpace(podKey)) + { + Console.WriteLine(" ERROR: authorized_keys file not found in pod!"); + Console.WriteLine(" The pod may still be starting. Try restarting the Aspire host."); + return; + } + + Console.WriteLine(" ✓ SSH authorized_keys file exists"); + + // Compare keys + var localKeyTrimmed = localPublicKey.Trim(); + var podKeyTrimmed = podKey.Trim(); + + if (localKeyTrimmed == podKeyTrimmed) + { + Console.WriteLine(" ✓ SSH key matches! Authentication should work."); + } + else + { + Console.WriteLine(" ✗ ERROR: SSH key MISMATCH!"); + Console.WriteLine($" Local key: {GetKeyFingerprint(localKeyTrimmed)}"); + Console.WriteLine($" Pod key: {GetKeyFingerprint(podKeyTrimmed)}"); + Console.WriteLine(); + Console.WriteLine(" SOLUTION: Delete the local SSH keys and redeploy:"); + Console.WriteLine($" Remove-Item -Recurse -Force $env:USERPROFILE\\.aks-tunnel"); + Console.WriteLine(" Then restart the Aspire host"); + } + } + catch (Exception ex) + { + Console.WriteLine($" WARNING: Could not verify pod: {ex.Message}"); + } +} diff --git a/src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs b/src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs index 8e16ba476..0d6e629de 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/TunnelConfiguration.cs @@ -22,7 +22,7 @@ public sealed class TunnelConfiguration [Required] public string Host { get; set; } = ""; - public int PublicHttpsPort { get; set; } = 8443; + public int PublicHttpsPort { get; set; } = 443; public int PublicMediaPort { get; set; } = 28551; @@ -37,6 +37,16 @@ public sealed class TunnelConfiguration [Required] public string ChartPath { get; set; } = "../../deploy/local-dev-tunnel"; + public string IngressClassName { get; set; } = "traefik"; + + /// + /// Path prefix for this developer's tunnel, e.g. "/dev-john" or "/tunnel-mary". + /// This allows multiple developers to tunnel simultaneously and differentiates + /// from the production bot deployment. + /// + [Required] + public string DeveloperPathPrefix { get; set; } = ""; + /// /// Resolves the TLS secret name using the same naming convention as the main chart. /// @@ -55,6 +65,12 @@ public void Validate() if (string.IsNullOrWhiteSpace(ChartPath)) errors.Add("Tunnel:ChartPath must point to the local-dev-tunnel Helm chart directory."); + if (string.IsNullOrWhiteSpace(DeveloperPathPrefix)) + errors.Add("Tunnel:DeveloperPathPrefix must be set (e.g. '/dev-yourname') to differentiate from production and other developers."); + + if (!string.IsNullOrWhiteSpace(DeveloperPathPrefix) && !DeveloperPathPrefix.StartsWith("/")) + errors.Add("Tunnel:DeveloperPathPrefix must start with '/' (e.g. '/dev-yourname')."); + if (errors.Count > 0) throw new InvalidOperationException( "Tunnel configuration is invalid:\n" + string.Join("\n", errors.Select(e => $" - {e}"))); diff --git a/src/RecordingBot.LocalTunnel.AppHost/appsettings.json b/src/RecordingBot.LocalTunnel.AppHost/appsettings.json index 535c19ff7..d7fbf66f3 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/appsettings.json +++ b/src/RecordingBot.LocalTunnel.AppHost/appsettings.json @@ -6,13 +6,15 @@ "BotReleaseName": "aks-sample", "BotNamespace": "teams-recording-bot", "Host": "aks-samples.westeurope.cloudapp.azure.com", - "PublicHttpsPort": 8443, + "PublicHttpsPort": 443, "PublicMediaPort": 28551, "SignalingPort": 9441, "MediaPort": 8445, "LocalSshPort": 2222, "PublicIp": null, - "ChartPath": "../../deploy/local-dev-tunnel" + "ChartPath": "../../deploy/local-dev-tunnel", + "IngressClassName": "traefik", + "DeveloperPathPrefix": "/dev-yourname" }, "AzureSettings": { "AadAppId": "YOUR_APP_ID__USE_SECRETS_TO_OVERRIDE", From 735e2a0fe59b19dd2becdbaf1abaa1a3f0145a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20B=C3=BCcker?= Date: Tue, 12 May 2026 12:23:27 +0200 Subject: [PATCH 5/7] fix: local port rebinding issue --- .../Properties/launchSettings.json | 5 ++--- .../ServiceSetup/AppHost.cs | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/RecordingBot.Console/Properties/launchSettings.json b/src/RecordingBot.Console/Properties/launchSettings.json index 34663a19f..bb188056c 100644 --- a/src/RecordingBot.Console/Properties/launchSettings.json +++ b/src/RecordingBot.Console/Properties/launchSettings.json @@ -2,11 +2,10 @@ "profiles": { "RecordingBot.Console": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:9441;http://localhost:9442" + } } } } \ No newline at end of file diff --git a/src/RecordingBot.Services/ServiceSetup/AppHost.cs b/src/RecordingBot.Services/ServiceSetup/AppHost.cs index 7e501bbb3..5b4ba3a8c 100644 --- a/src/RecordingBot.Services/ServiceSetup/AppHost.cs +++ b/src/RecordingBot.Services/ServiceSetup/AppHost.cs @@ -44,10 +44,16 @@ public void Boot(string[] args) // Setup Listening Urls builder.WebHost.UseKestrel(serverOptions => { - serverOptions.ListenAnyIP(azureSettings.CallSignalingPort + 1); - serverOptions.ListenAnyIP(azureSettings.CallSignalingPort, config => config.UseHttps(azureSettings.Certificate)); + // Disable port fallback - fail fast if port is in use + serverOptions.Listen(IPAddress.Any, azureSettings.CallSignalingPort + 1, listenOptions => + { + //listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2; + }); }); + // Override launch settings to prevent port conflicts + builder.WebHost.UseUrls(); + // Add services to the container. builder.Services.AddControllers().AddJsonOptions(options => { @@ -77,15 +83,14 @@ public void Boot(string[] args) } // Configure the HTTP request pipeline. - app.UsePathBase(azureSettings.PodPathBase); - app.UsePathBase(azureSettings.ServicePath); - - //app.UseHttpsRedirection(); + app.UsePathBase(azureSettings.PodPathBase); - app.UseAuthorization(); + //app.UseHttpsRedirection(); app.UseRouting(); + app.UseAuthorization(); + app.MapControllers(); app.Run(); From 152698d831dd1eb77e59ed89d25be751e649dd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20B=C3=BCcker?= Date: Tue, 12 May 2026 12:56:44 +0200 Subject: [PATCH 6/7] fix: broken path base for local debugging --- src/RecordingBot.LocalTunnel.AppHost/Program.cs | 2 +- src/RecordingBot.Services/ServiceSetup/AzureSettings.cs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/RecordingBot.LocalTunnel.AppHost/Program.cs b/src/RecordingBot.LocalTunnel.AppHost/Program.cs index 74231f7f3..46c7b0300 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/Program.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/Program.cs @@ -184,7 +184,7 @@ .WithEnvironment("AzureSettings__PlaceCallEndpointUrl", "https://graph.microsoft.com/v1.0") // Pod identification - .WithEnvironment("AzureSettings__PodName", "bot-0") + .WithEnvironment("AzureSettings__PodName", "local") // Media recording configuration .WithEnvironment("AzureSettings__MediaFolder", builder.Configuration["AzureSettings:MediaFolder"] ?? "archive") diff --git a/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs b/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs index f597494a2..ee11d9f46 100644 --- a/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs +++ b/src/RecordingBot.Services/ServiceSetup/AzureSettings.cs @@ -51,14 +51,15 @@ public void Initialize() int podNumber = 0; - if (!string.IsNullOrEmpty(PodName)) + PodPathBase = ServicePath; + if (!string.IsNullOrEmpty(PodName) && PodName != "local") { _ = int.TryParse(PodNumberRegex().Match(PodName).Value, out podNumber); + PodPathBase = $"{ServicePath}{podNumber}"; } // Create structured config objects for service. - CallControlBaseUrl = new Uri($"https://{ServiceCname}{(CallSignalingPublicPort != 443 ? ":" + CallSignalingPublicPort : "")}{ServicePath}{podNumber}/{HttpRouteConstants.CALL_SIGNALING_ROUTE_PREFIX}/{HttpRouteConstants.ON_NOTIFICATION_REQUEST_ROUTE}"); - PodPathBase = $"{ServicePath}{podNumber}"; + CallControlBaseUrl = new Uri($"https://{ServiceCname}{(CallSignalingPublicPort != 443 ? ":" + CallSignalingPublicPort : "")}{PodPathBase}/{HttpRouteConstants.CALL_SIGNALING_ROUTE_PREFIX}/{HttpRouteConstants.ON_NOTIFICATION_REQUEST_ROUTE}"); MediaPlatformSettings = new MediaPlatformSettings { From d89a1aa327dc6ce5fb4738eb00067c996c317878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20B=C3=BCcker?= Date: Tue, 12 May 2026 13:51:48 +0200 Subject: [PATCH 7/7] style: cleanup --- src/RecordingBot.LocalTunnel.AppHost/Program.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/RecordingBot.LocalTunnel.AppHost/Program.cs b/src/RecordingBot.LocalTunnel.AppHost/Program.cs index 46c7b0300..91f9278de 100644 --- a/src/RecordingBot.LocalTunnel.AppHost/Program.cs +++ b/src/RecordingBot.LocalTunnel.AppHost/Program.cs @@ -48,20 +48,20 @@ try { // ── Phase 1: Deploy tunnel chart ───────────────────────────────── - Console.WriteLine("[1/4] Helm Chart Deployment"); - Console.WriteLine(" Deploying tunnel pod with ingress (passwordless SSH)..."); + Console.WriteLine("[1/3] Helm Chart Deployment"); + Console.WriteLine(" Deploying tunnel pod with ingress..."); await HelmHelper.DeployAsync(config, string.Empty); Console.WriteLine(" ✓ Tunnel pod deployed"); Console.WriteLine(); // ── Phase 2: Extract certificate to local PFX ─────────────────── - Console.WriteLine("[2/4] Certificate Extraction"); + Console.WriteLine("[2/3] Certificate Extraction"); Console.WriteLine(" Extracting TLS certificate for media port..."); certPath = await CertificateHelper.ExtractAsync(config); Console.WriteLine(); // ── Phase 3: Wait for LoadBalancer IP ──────────────────────────── - Console.WriteLine("[3/4] LoadBalancer"); + Console.WriteLine("[3/3] LoadBalancer"); externalIp = await KubernetesHelper.WaitForLoadBalancerIpAsync(config); Console.WriteLine(); @@ -140,7 +140,6 @@ $"while ! nc -z localhost {config.LocalSshPort} 2>/dev/null; do " + $"echo 'Port {config.LocalSshPort} not ready, waiting for kubectl port-forward...'; sleep 3; done && " + $"echo 'Port {config.LocalSshPort} is ready! Establishing SSH tunnel...' && " + - $"echo 'Note: Using passwordless SSH (secured by kubectl port-forward)' && " + $"while true; do " + $"echo | ssh -tt " + $"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ExitOnForwardFailure=yes " +