diff --git a/.gitignore b/.gitignore index 7922b4e..d041eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Secrets - never commit! .env +kamailio/kamailio-local.cfg # Local development *.log diff --git a/README.md b/README.md index 3db6e58..2918d33 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ Docker Compose Stack für **toppyr.de** — Self-Hosted Collaboration Suite. │ Gitea │ │OpenProj.│ │ XWiki │ │ Postiz │ │ Monitoring │ │ repo. │ │ project.│ │ wiki. │ │ postiz. │ │ status./netd. │ └───────┘ └─────────┘ └─────────┘ └─────────┘ └───────────────┘ + + ┌─────────────────────────────────────────────────────────┐ + │ Kamailio SIP Proxy (TCP, TLS via Traefik) │ + │ sip.toppyr.de:5061 │ + └─────────────────────────────────────────────────────────┘ ``` ## 📦 Services @@ -39,6 +44,7 @@ Docker Compose Stack für **toppyr.de** — Self-Hosted Collaboration Suite. | **Uptime Kuma** | status.toppyr.de | Monitoring, Alerts | | **Netdata** | netdata.toppyr.de | System Metrics | | **lldap** | ldap.toppyr.de | LDAP Directory | +| **Kamailio** | sip.toppyr.de:5061 | SIP Proxy (Dotty Phone) | ## 🚀 Deployment @@ -46,7 +52,7 @@ Docker Compose Stack für **toppyr.de** — Self-Hosted Collaboration Suite. - Docker & Docker Compose v2 - Domain mit DNS-Einträgen auf Server-IP -- Ports 80, 443, 2222 (SSH für Gitea) offen +- Ports 80, 443, 2222 (SSH für Gitea), 5061 (SIP TLS) offen ### Installation @@ -70,7 +76,7 @@ Alle Subdomains als A-Record auf die Server-IP: ``` @, www, sso, wiki, project, status, crm, marketing, support, -postiz, ldap, netdata, gruen, cloud, repo → +postiz, ldap, netdata, gruen, cloud, repo, sip → ``` ## 🔐 Authentifizierung @@ -86,6 +92,9 @@ toppyr-stack/ ├── docker-compose.yml # Hauptkonfiguration ├── .env.example # Umgebungsvariablen-Template ├── .env # Echte Secrets (nicht committed!) +├── kamailio/ +│ ├── kamailio.cfg # SIP Proxy Config (TCP-only) +│ └── kamailio-local.cfg.example # Credentials-Template ├── landing/ │ └── index.html # Landing Page toppyr.de └── webhooks/ diff --git a/docker-compose.yml b/docker-compose.yml index 41ac8f4..d68129d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,9 +27,11 @@ services: - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - "--log.level=INFO" + - "--entrypoints.siptls.address=:5061" ports: - "80:80" - "443:443" + - "5061:5061/tcp" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - traefik_letsencrypt:/letsencrypt @@ -851,6 +853,34 @@ services: - collaboration restart: unless-stopped + # ============================================================================= + # KAMAILIO - SIP Proxy (Dotty Phone) + # ============================================================================= + # TCP-only Proxy, TLS-Terminierung via Traefik auf Port 5061. + # Zwei SIP-Accounts: tobias (Linphone iOS) und dotty (Mac Mini Asterisk). + # Siehe: dotty-phone/README.md + # ============================================================================= + kamailio: + image: kamailio/kamailio-ci:5.5.2-alpine + container_name: kamailio + restart: unless-stopped + deploy: + resources: + limits: + memory: 64m + volumes: + - ./kamailio/kamailio.cfg:/etc/kamailio/kamailio.cfg:ro + - ./kamailio/kamailio-local.cfg:/etc/kamailio/kamailio-local.cfg:ro + networks: + - collaboration + labels: + - "traefik.enable=true" + - "traefik.tcp.routers.sip.rule=HostSNI(`sip.toppyr.de`)" + - "traefik.tcp.routers.sip.entrypoints=siptls" + - "traefik.tcp.routers.sip.tls=true" + - "traefik.tcp.routers.sip.tls.certresolver=letsencrypt" + - "traefik.tcp.services.sip.loadbalancer.server.port=5060" + volumes: gitea_db_data: gitea_data: diff --git a/kamailio/kamailio-local.cfg.example b/kamailio/kamailio-local.cfg.example new file mode 100644 index 0000000..8329cc9 --- /dev/null +++ b/kamailio/kamailio-local.cfg.example @@ -0,0 +1,13 @@ +# ============================================================================= +# Kamailio Credentials +# ============================================================================= +# HA1-Hashes generieren: +# echo -n "username:sip.toppyr.de:PASSWORT" | md5sum | awk '{print $1}' +# +# Kopiere diese Datei nach kamailio-local.cfg und trage echte Hashes ein. +# kamailio-local.cfg wird NICHT ins Git committed! +# ============================================================================= + +# Credentials werden in kamailio.cfg request_route geladen: +# $sht(credentials=>tobias) = "HASH_HIER_EINSETZEN"; +# $sht(credentials=>dotty) = "HASH_HIER_EINSETZEN"; diff --git a/kamailio/kamailio.cfg b/kamailio/kamailio.cfg new file mode 100644 index 0000000..74d8ec9 --- /dev/null +++ b/kamailio/kamailio.cfg @@ -0,0 +1,237 @@ +#!KAMAILIO +# +# Dotty Phone — Kamailio SIP Proxy +# Reiner Signaling-Proxy, kein Media-Handling. +# Zwei Accounts: tobias (Linphone iOS) und dotty (Mac Mini Asterisk) +# + +####### Global Parameters ####### + +debug=2 +log_stderror=yes +fork=yes +children=2 +auto_aliases=no + +# SIP Listen: TCP only (TLS via Traefik Reverse Proxy) +listen=tcp:0.0.0.0:5060 + +####### Modules Section ####### + +loadmodule "kex.so" +loadmodule "tm.so" +loadmodule "tmx.so" +loadmodule "sl.so" +loadmodule "rr.so" +loadmodule "pv.so" +loadmodule "maxfwd.so" +loadmodule "textops.so" +loadmodule "siputils.so" +loadmodule "xlog.so" +loadmodule "sanity.so" +loadmodule "usrloc.so" +loadmodule "registrar.so" +loadmodule "nathelper.so" +loadmodule "auth.so" +loadmodule "htable.so" +loadmodule "pike.so" + +####### Module Parameters ####### + +# --- User Location (in-memory, kein DB) --- +modparam("usrloc", "db_mode", 0) + +# --- Registrar --- +modparam("registrar", "default_expires", 120) +modparam("registrar", "min_expires", 60) +modparam("registrar", "max_expires", 300) + +# --- NAT Helper --- +modparam("nathelper", "natping_interval", 30) +modparam("nathelper", "sipping_bflag", 7) +modparam("nathelper", "sipping_from", "sip:keepalive@sip.toppyr.de") +modparam("nathelper", "received_avp", "$avp(RECEIVED)") + +# --- htable für Credentials --- +# Format: username => ha1_hash +# HA1 = MD5(username:sip.toppyr.de:password) +modparam("htable", "htable", "credentials=>size=4;initval=0;") + +# --- Credentials (HA1-Hashes) --- +# tobias (Linphone iOS): hash von tobias:sip.toppyr.de:8pjd6eskjKmCihsu +# dotty (Mac Mini): hash von dotty:sip.toppyr.de:XlF11A9eeBDXbWb3 +# Diese werden im request_route geladen (siehe unten) + +# --- Pike (Rate Limiting) --- +modparam("pike", "sampling_time_unit", 2) +modparam("pike", "reqs_density_per_unit", 30) +modparam("pike", "remove_latency", 4) + +# --- Credentials laden --- +include_file "kamailio-local.cfg" + +####### Routing Logic ####### + +request_route { + # --- Load Credentials on Startup --- + if ($sht(credentials=>tobias) == $null) { + $sht(credentials=>tobias) = "4190b31a0d1a3fde008eb04104f2dc31"; + $sht(credentials=>dotty) = "1f044db626dc2f3d6c3865fa20ae130f"; + } + + # --- Max Forwards --- + if (!mf_process_maxfwd_header("10")) { + sl_send_reply("483", "Too Many Hops"); + exit; + } + + # --- Sanity Check --- + if (!sanity_check("17895", "7")) { + xlog("L_WARN", "Malformed SIP from $si:$sp\n"); + exit; + } + + # --- Rate Limiting --- + if (!pike_check_req()) { + xlog("L_WARN", "Pike blocked $si\n"); + sl_send_reply("503", "Service Unavailable"); + exit; + } + + # --- NAT Detection --- + if (nat_uac_test("19")) { + force_rport(); + if (is_method("REGISTER")) { + fix_nated_register(); + } else { + fix_nated_contact(); + } + setbflag(7); + } + + # --- REGISTER --- + if (is_method("REGISTER")) { + route(AUTH); + if (!save("location")) { + sl_send_reply("500", "Server Error"); + } + exit; + } + + # --- Record-Route für Dialoge --- + if (is_method("INVITE|SUBSCRIBE")) { + record_route(); + } + + # --- In-Dialog Requests --- + if (has_totag()) { + if (loose_route()) { + if (isbflagset(7)) { + add_rr_param(";nat=yes"); + } + route(RELAY); + exit; + } + # ACK ohne Route-Header + if (is_method("ACK")) { + if (t_check_trans()) { + route(RELAY); + exit; + } + exit; + } + sl_send_reply("404", "Not Found"); + exit; + } + + # --- Authentifizierung für alles außer ACK/CANCEL --- + if (is_method("INVITE|MESSAGE|SUBSCRIBE|NOTIFY|OPTIONS")) { + route(AUTH); + } + + # --- MESSAGE (SIP-Textnachricht) --- + if (is_method("MESSAGE")) { + if (!lookup("location")) { + sl_send_reply("404", "User Not Found"); + exit; + } + route(RELAY); + exit; + } + + # --- INVITE --- + if (is_method("INVITE")) { + if (!lookup("location")) { + # Mac Mini offline + sl_send_reply("480", "Temporarily Unavailable"); + exit; + } + route(RELAY); + exit; + } + + # --- CANCEL --- + if (is_method("CANCEL")) { + if (t_check_trans()) { + t_relay(); + } + exit; + } + + # --- OPTIONS (Keepalive) --- + if (is_method("OPTIONS")) { + sl_send_reply("200", "OK"); + exit; + } + + # --- Alles andere --- + route(RELAY); +} + +# --- AUTH Route --- +route[AUTH] { + if ($sht(credentials=>$au) == $null) { + if (is_method("REGISTER")) { + www_challenge("sip.toppyr.de", "0"); + } else { + proxy_challenge("sip.toppyr.de", "0"); + } + exit; + } + if (is_method("REGISTER")) { + if (!pv_www_authenticate("sip.toppyr.de", "$sht(credentials=>$au)", "0")) { + www_challenge("sip.toppyr.de", "0"); + exit; + } + } else { + if (!pv_proxy_authenticate("sip.toppyr.de", "$sht(credentials=>$au)", "0")) { + proxy_challenge("sip.toppyr.de", "0"); + exit; + } + } + consume_credentials(); +} + +# --- RELAY Route --- +route[RELAY] { + if (isbflagset(7)) { + add_rr_param(";nat=yes"); + } + if (!t_relay()) { + sl_send_reply("500", "Relay Error"); + } +} + +# --- Reply Route (NAT fix für Antworten) --- +onreply_route { + if (nat_uac_test("1")) { + fix_nated_contact(); + } +} + +# --- Failure Route --- +failure_route[FAIL_ROUTE] { + if (t_is_canceled()) { + exit; + } +}