From b667a856d4a091f9b5c817f21a1c8886d3d4e63a Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Thu, 21 May 2026 16:40:19 +0200 Subject: [PATCH] KI-AGENT: Asterisk-Trunk aus FEDEO anwenden --- .env.example | 5 + backend/src/routes/telephony.ts | 267 +++++++++++++++++++ docker-compose.yml | 9 +- docs/telekom-telefonie.md | 7 + frontend/pages/communication/phone-setup.vue | 78 +++++- frontend/pages/settings/tenant.vue | 34 +++ telephony/asterisk/manager.conf | 9 +- 7 files changed, 406 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 2d54e04..ca7969c 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,11 @@ TELEPHONY_ENABLED=false ASTERISK_IMAGE=andrius/asterisk:20 TELEPHONY_ASTERISK_HTTP_URL=http://asterisk-dev:8088/ws TELEPHONY_ASTERISK_WS_URL=ws://localhost:8088/ws +TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated +TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev +TELEPHONY_ASTERISK_AMI_PORT=5038 +TELEPHONY_ASTERISK_AMI_USER=fedeo +TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev TELEPHONY_SIP_DOMAIN=localhost TELEPHONY_TEST_EXTENSION=1001 TELEPHONY_TEST_PASSWORD=fedeo-test-1001 diff --git a/backend/src/routes/telephony.ts b/backend/src/routes/telephony.ts index e607eaf..0e39648 100644 --- a/backend/src/routes/telephony.ts +++ b/backend/src/routes/telephony.ts @@ -1,4 +1,7 @@ import { FastifyInstance } from "fastify" +import { promises as fs } from "node:fs" +import net from "node:net" +import path from "node:path" import { and, desc, eq } from "drizzle-orm" import { telephonyCalls, telephonyTrunks } from "../../db/schema" @@ -27,6 +30,16 @@ const asteriskHttpStatusUrls = () => { const publicAsteriskWsUrl = () => process.env.TELEPHONY_ASTERISK_WS_URL || `ws://localhost:${process.env.TELEPHONY_DEV_WS_PORT || "8088"}/ws` +const asteriskGeneratedDir = () => + process.env.TELEPHONY_ASTERISK_GENERATED_DIR || "/var/lib/fedeo/asterisk/generated" + +const asteriskAmiConfig = () => ({ + host: process.env.TELEPHONY_ASTERISK_AMI_HOST || "asterisk-dev", + port: Number(process.env.TELEPHONY_ASTERISK_AMI_PORT || 5038), + username: process.env.TELEPHONY_ASTERISK_AMI_USER || "fedeo", + password: process.env.TELEPHONY_ASTERISK_AMI_PASSWORD || "fedeo-ami-dev", +}) + const sipDomain = () => process.env.TELEPHONY_SIP_DOMAIN || "localhost" @@ -119,6 +132,208 @@ const durationSeconds = (startedAt?: Date | null, endedAt?: Date | null) => { return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000)) } +const asteriskValue = (value: string | null | undefined) => + String(value || "").replace(/[\r\n]/g, "").trim() + +const renderTelekomPjsipConfig = (trunk: any) => { + if (!trunk?.enabled) { + return [ + "; Von FEDEO generiert.", + "; Telekom-Trunk ist deaktiviert.", + "", + ].join("\n") + } + + const registrar = asteriskValue(trunk.registrar) || "tel.t-online.de" + const sipUser = asteriskValue(trunk.sipUser) + const authUser = asteriskValue(trunk.authUser) || sipUser + const password = asteriskValue(trunk.password) + const callerId = asteriskValue(trunk.callerId) || sipUser + + return [ + "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", + "[telekom-auth]", + "type=auth", + "auth_type=userpass", + `username=${authUser}`, + `password=${password}`, + "", + "[telekom-aor]", + "type=aor", + `contact=sip:${registrar}`, + "", + "[telekom]", + "type=endpoint", + "transport=transport-udp", + "context=from-telekom", + "disallow=all", + "allow=alaw,ulaw", + "aors=telekom-aor", + "outbound_auth=telekom-auth", + `from_user=${sipUser}`, + `from_domain=${registrar}`, + `callerid=Telekom <${callerId}>`, + "direct_media=no", + "force_rport=yes", + "rewrite_contact=yes", + "rtp_symmetric=yes", + "timers=no", + "", + "[telekom-identify]", + "type=identify", + "endpoint=telekom", + `match=${registrar}`, + "", + "[telekom-registration]", + "type=registration", + "transport=transport-udp", + "outbound_auth=telekom-auth", + `server_uri=sip:${registrar}`, + `client_uri=sip:${sipUser}@${registrar}`, + `contact_user=${sipUser}`, + "retry_interval=60", + "forbidden_retry_interval=300", + "expiration=480", + "line=yes", + "endpoint=telekom", + "", + ].join("\n") +} + +const renderTelekomExtensionsConfig = (trunk: any) => { + if (!trunk?.enabled) { + return [ + "; Von FEDEO generiert.", + "; Telekom-Routing ist deaktiviert.", + "", + ].join("\n") + } + + const inboundExtension = asteriskValue(trunk.inboundExtension) || "1001" + const outboundPrefix = asteriskValue(trunk.outboundPrefix) || "0" + const escapedPrefix = outboundPrefix.replace(/[^0-9*#+]/g, "") + const callerId = asteriskValue(trunk.callerId) || asteriskValue(trunk.sipUser) + + return [ + "; Von FEDEO generiert. Änderungen im Container können überschrieben werden.", + "[fedeo-local]", + escapedPrefix + ? `exten => _${escapedPrefix}X.,1,NoOp(FEDEO ausgehend über Telekom: $` + "{EXTEN})" + : "exten => _X.,1,NoOp(FEDEO ausgehend über Telekom: ${EXTEN})", + ` same => n,Set(CALLERID(num)=${callerId})`, + " same => n,Dial(PJSIP/${EXTEN}@telekom,60)", + " same => n,Hangup()", + "", + "exten => _+X.,1,NoOp(FEDEO ausgehend über Telekom: ${EXTEN})", + ` same => n,Set(CALLERID(num)=${callerId})`, + " same => n,Dial(PJSIP/${EXTEN}@telekom,60)", + " same => n,Hangup()", + "", + "[from-telekom]", + "exten => s,1,NoOp(FEDEO eingehend über Telekom)", + ` same => n,Dial(PJSIP/${inboundExtension},30)`, + " same => n,Hangup()", + "", + "exten => _X!,1,NoOp(FEDEO eingehend über Telekom: ${EXTEN})", + ` same => n,Dial(PJSIP/${inboundExtension},30)`, + " same => n,Hangup()", + "", + ].join("\n") +} + +const writeAsteriskTrunkConfig = async (trunk: any) => { + const targetDir = asteriskGeneratedDir() + await fs.mkdir(targetDir, { recursive: true }) + + const files = [ + { + name: "pjsip.telekom.conf", + content: renderTelekomPjsipConfig(trunk), + }, + { + name: "extensions.telekom.conf", + content: renderTelekomExtensionsConfig(trunk), + }, + ] + + await Promise.all(files.map(async (file) => { + const target = path.join(targetDir, file.name) + await fs.writeFile(target, `${file.content}\n`, { mode: 0o600 }) + })) + + return files.map((file) => path.join(targetDir, file.name)) +} + +const runAsteriskAmiCommand = async (command: string, timeoutMs = 5000) => { + const config = asteriskAmiConfig() + + return await new Promise<{ command: string, ok: boolean, raw: string }>((resolve, reject) => { + const socket = net.createConnection(config.port, config.host) + let raw = "" + let settled = false + + const finish = (ok: boolean) => { + if (settled) return + settled = true + socket.destroy() + resolve({ command, ok, raw }) + } + + socket.setTimeout(timeoutMs) + socket.on("connect", () => { + socket.write([ + "Action: Login", + `Username: ${config.username}`, + `Secret: ${config.password}`, + "Events: off", + "", + "Action: Command", + `Command: ${command}`, + "", + "Action: Logoff", + "", + ].join("\r\n")) + }) + socket.on("data", (chunk) => { + raw += chunk.toString("utf8") + if (raw.includes("Goodbye")) finish(!raw.includes("Authentication failed")) + }) + socket.on("timeout", () => finish(raw.length > 0)) + socket.on("end", () => finish(!raw.includes("Authentication failed"))) + socket.on("error", reject) + }) +} + +const runAsteriskReload = async () => { + const commands = [ + "pjsip reload", + "dialplan reload", + "pjsip send register telekom-registration", + ] + const results = [] + + for (const command of commands) { + results.push(await runAsteriskAmiCommand(command)) + } + + return { + ok: results.every((result) => result.ok), + commands: results, + } +} + +const readAsteriskTrunkStatus = async () => { + const registrations = await runAsteriskAmiCommand("pjsip show registrations") + const raw = registrations.raw || "" + + return { + reachable: registrations.ok, + registered: /telekom-registration[\s\S]*Registered/i.test(raw), + hasRegistration: raw.includes("telekom-registration"), + registrations: raw, + } +} + export default async function telephonyRoutes(server: FastifyInstance) { const loadTenantTrunk = async (tenantId: number | null, provider = "telekom") => { if (!tenantId) return null @@ -276,6 +491,58 @@ export default async function telephonyRoutes(server: FastifyInstance) { return sanitizeTrunk(created) }) + server.post("/telephony/trunk-config/apply", async (req, reply) => { + const tenantId = requireTenant(req.user.tenant_id) + const trunk = await loadTenantTrunk(tenantId) + + if (!trunk) { + return reply.code(404).send({ error: "Telefonie-Trunk ist noch nicht konfiguriert." }) + } + + if (trunk.enabled && (!trunk.sipUser || !trunk.password)) { + return reply.code(400).send({ + error: "SIP-ID und Kennwort sind erforderlich, bevor der Trunk angewendet werden kann.", + }) + } + + const files = await writeAsteriskTrunkConfig(trunk) + let reload: any = null + let status: any = null + let warning: string | null = null + + try { + reload = await runAsteriskReload() + status = await readAsteriskTrunkStatus() + } catch (error: any) { + warning = error?.message || "Asterisk konnte nicht neu geladen werden." + } + + return { + generated: true, + files, + trunk: sanitizeTrunk(trunk), + reload, + status, + warning, + } + }) + + server.get("/telephony/trunk-status", async (req, reply) => { + requireTenant(req.user.tenant_id) + + try { + return await readAsteriskTrunkStatus() + } catch (error: any) { + return reply.code(200).send({ + reachable: false, + registered: false, + hasRegistration: false, + registrations: "", + message: error?.message || "Asterisk-AMI ist nicht erreichbar.", + }) + } + }) + server.get("/telephony/calls", async (req) => { const tenantId = requireTenant(req.user.tenant_id) const limit = Math.min( diff --git a/docker-compose.yml b/docker-compose.yml index a6187b4..b015072 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,13 @@ services: - TELEPHONY_EXTERNAL_PROVIDER=${TELEPHONY_EXTERNAL_PROVIDER:-} - TELEPHONY_EXTERNAL_ENABLED=${TELEPHONY_EXTERNAL_ENABLED:-false} - TELEPHONY_EXTERNAL_INBOUND_EXTENSION=${TELEPHONY_EXTERNAL_INBOUND_EXTENSION:-1001} + - TELEPHONY_ASTERISK_GENERATED_DIR=${TELEPHONY_ASTERISK_GENERATED_DIR:-/var/lib/fedeo/asterisk/generated} + - TELEPHONY_ASTERISK_AMI_HOST=${TELEPHONY_ASTERISK_AMI_HOST:-asterisk-dev} + - TELEPHONY_ASTERISK_AMI_PORT=${TELEPHONY_ASTERISK_AMI_PORT:-5038} + - TELEPHONY_ASTERISK_AMI_USER=${TELEPHONY_ASTERISK_AMI_USER:-fedeo} + - TELEPHONY_ASTERISK_AMI_PASSWORD=${TELEPHONY_ASTERISK_AMI_PASSWORD:-fedeo-ami-dev} + volumes: + - asterisk-generated:/var/lib/fedeo/asterisk/generated networks: - traefik labels: @@ -124,7 +131,7 @@ services: - -c - /usr/local/bin/render-asterisk-config.sh && asterisk -f volumes: - - ./telephony/asterisk:/etc/asterisk:ro + - ./telephony/asterisk:/etc/asterisk - asterisk-generated:/etc/asterisk/generated - ./telephony/render-asterisk-config.sh:/usr/local/bin/render-asterisk-config.sh:ro ports: diff --git a/docs/telekom-telefonie.md b/docs/telekom-telefonie.md index 14f7b5c..3a4ae47 100644 --- a/docs/telekom-telefonie.md +++ b/docs/telekom-telefonie.md @@ -25,6 +25,8 @@ TELEPHONY_TELEKOM_AUTH_USER=# Telefonie Setup** im Bereich **Externe Telefonie**. + ## `.env` Fallback Die `.env`-Werte bleiben nur als lokaler Fallback für den Asterisk-Teststack erhalten, falls keine mandantenbezogene Konfiguration gepflegt ist. @@ -42,6 +44,11 @@ TELEPHONY_TELEKOM_PASSWORD= TELEPHONY_TELEKOM_CALLER_ID= TELEPHONY_TELEKOM_INBOUND_EXTENSION=1001 TELEPHONY_TELEKOM_OUTBOUND_PREFIX=0 +TELEPHONY_ASTERISK_GENERATED_DIR=/var/lib/fedeo/asterisk/generated +TELEPHONY_ASTERISK_AMI_HOST=asterisk-dev +TELEPHONY_ASTERISK_AMI_PORT=5038 +TELEPHONY_ASTERISK_AMI_USER=fedeo +TELEPHONY_ASTERISK_AMI_PASSWORD=fedeo-ami-dev ``` Wenn `TELEPHONY_TELEKOM_AUTH_USER` leer bleibt, verwendet Asterisk automatisch `TELEPHONY_TELEKOM_SIP_USER` als Authentifizierungsnutzer. diff --git a/frontend/pages/communication/phone-setup.vue b/frontend/pages/communication/phone-setup.vue index b6933fd..758e08d 100644 --- a/frontend/pages/communication/phone-setup.vue +++ b/frontend/pages/communication/phone-setup.vue @@ -1,4 +1,5 @@