diff --git a/.gitignore b/.gitignore index 537d029..9d21165 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ result* # mdBook /book/* + +/extensions.yaml diff --git a/docs/hp-switch.md b/docs/hp-switch.md index be66b38..17cdb50 100644 --- a/docs/hp-switch.md +++ b/docs/hp-switch.md @@ -51,12 +51,12 @@ vlan 2 tagged 23,24 vlan 6 name public-event vlan 6 qos priority 0 -vlan 6 tagged 21-24 +vlan 6 tagged 13,15,21-24 vlan 7 name weinturm vlan 7 qos priority 1 vlan 7 tagged 21-23 -vlan 7 untagged 1-12,24 +vlan 7 untagged 1-12,13,15,24 vlan 8 name voice vlan 8 qos priority 5 @@ -66,6 +66,9 @@ vlan 8 voice interface ethernet 1-12 enable +interface ethernet 13,15 enable +interface ethernet 13,15 name WLAN + interface ethernet 17,19 enable interface ethernet 17,19 name dect diff --git a/hosts/default.nix b/hosts/default.nix index 40bae16..4960303 100644 --- a/hosts/default.nix +++ b/hosts/default.nix @@ -1,6 +1,6 @@ _inputs: { pbx = { system = "x86_64-linux"; - targetHost = "tel.weinturm.de"; + #targetHost = "tel.weinturm.de"; }; } diff --git a/hosts/pbx/configuration.nix b/hosts/pbx/configuration.nix index 15d1460..1942dff 100644 --- a/hosts/pbx/configuration.nix +++ b/hosts/pbx/configuration.nix @@ -6,6 +6,8 @@ ./services ]; + users.users.jalr.initialHashedPassword = "$y$j9T$jNVubFR0TeZOo2jm.aBke/$GPYbyIUGq2lFccC4fHZX61s3EwQ.dB8uUsTINeRLt/1"; + weinturm = { impermanence = { enable = true; diff --git a/hosts/pbx/networking.nix b/hosts/pbx/networking.nix index 363d878..8fef211 100644 --- a/hosts/pbx/networking.nix +++ b/hosts/pbx/networking.nix @@ -1,14 +1,28 @@ -{pkgs, ...}: { +{ + lib, + pkgs, + config, + ... +}: { networking = { hostName = "pbx"; useDHCP = false; # Fix Intel NIC e1000e hardware unit hang - localCommands = "${pkgs.ethtool}/bin/ethtool -K enp0s25 tso off gso off"; + localCommands = lib.mkBefore "${pkgs.ethtool}/bin/ethtool -K enp0s25 tso off gso off"; - firewall.interfaces = { - weinturm.allowedUDPPorts = [53 67]; - public-event.allowedUDPPorts = [53 67]; + firewall = { + interfaces = { + weinturm.allowedUDPPorts = [53 67]; + public-event.allowedUDPPorts = [53 67]; + }; + filterForward = true; + extraForwardRules = '' + oifname { "jugendtreff", "public-ip4" } meta l4proto tcp tcp dport 25 drop comment "Block outgoing SMTP (TCP/25)" + oifname { "jugendtreff", "public-ip4" } meta l4proto tcp tcp dport { 135, 137, 138, 139, 445 } drop comment "Block MS RPC/NetBIOS/SMB (TCP)" + oifname { "jugendtreff", "public-ip4" } meta l4proto udp udp dport { 135, 137, 138, 139, 445 } drop comment "Block MS RPC/NetBIOS/SMB (UDP)" + oifname { "jugendtreff", "public-ip4" } meta l4proto udp udp dport 1900 drop comment "Block SSDP (UPnP, UDP/1900)" + ''; }; vlans = { @@ -76,7 +90,22 @@ "voice" ]; }; - defaultGateway.address = "192.168.100.1"; + nftables.tables.pppoe = { + family = "ip"; + content = let + headerSize = { + ipv4 = 20; + tcp = 20; + pppoe = 8; + }; + maxsegSize = with headerSize; 1500 - ipv4 - tcp - pppoe; + in '' + chain clamp { + type filter hook forward priority mangle; + oifname "${config.networking.nat.externalInterface}" tcp flags syn tcp option maxseg size set ${toString maxsegSize} + } + ''; + }; nameservers = [ "9.9.9.9" "149.112.112.112" @@ -153,6 +182,37 @@ ]; } ]; + + option-def = lib.lists.optional config.services.unifi.enable { + name = "unifi-address"; + code = 1; + space = "ubnt"; + type = "ipv4-address"; + encapsulate = ""; + }; + + client-classes = lib.lists.optional config.services.unifi.enable { + name = "ubnt"; + test = "(option[vendor-class-identifier].text == 'ubnt')"; + option-def = [ + { + name = "vendor-encapsulated-options"; + code = 43; + type = "empty"; + encapsulate = "ubnt"; + } + ]; + option-data = [ + { + name = "unifi-address"; + space = "ubnt"; + data = "192.168.96.1"; + } + { + name = "vendor-encapsulated-options"; + } + ]; + }; }; }; diff --git a/hosts/pbx/secrets.yaml b/hosts/pbx/secrets.yaml index fc47f86..65e6b3c 100644 --- a/hosts/pbx/secrets.yaml +++ b/hosts/pbx/secrets.yaml @@ -1,8 +1,19 @@ +unpoller: ENC[AES256_GCM,data:w1PvLyJlUP+hsJFcgW9hKD/CvTQzSin+,iv:LuSbsN6Hg9XOc1SCYTBjQNXtqlg5tfHutzTNt4dm20I=,tag:BLBmfB0OwhR3VZzvVyd4IQ==,type:str] fieldpoc: omm: ENC[AES256_GCM,data:vOoow2CTJKfCiml5t0k=,iv:BTnf2ASndaNgjYtikTl/B3a5wSRh37epSDT0eGZpLkI=,tag:XOFlh+Ut3JKPd5AUPtrBMw==,type:str] sip: ENC[AES256_GCM,data:B82q2sD5I6NUa+RphJL+f1IT5qpZYlpMunZUaN5JJ5I=,iv:YzDg/g1C1z7kV2R5LLNMhe2UvaRaurQKaq4SbGfFKmQ=,tag:NuWn3D8u6jiJFZFTaFvv3g==,type:str] wireguard: public-ip4: ENC[AES256_GCM,data:NifuhsgDA+/4c+Op9CAg4jhizFdup7FL9jQt4VLGqGzOaY9lMpAFvrWiW2o=,iv:zKN7QTIEo8+KjwtNPxhUVwD+6Xmz48gp9nDAg3bOazo=,tag:GQCBEFAD2en33gKXraXArw==,type:str] +yate: + accounts: + easybell-2: ENC[AES256_GCM,data:jPyZY87r++dNLZCv,iv:BMVICnZujyIbE4IYi+Z9tqn5rbWwnEcoHm9/jWAAhsc=,tag:tk/Vs0tOt6p+a3vD0bJMfw==,type:str] + easybell-3: ENC[AES256_GCM,data:JNQKClwQtYm4GMRp,iv:WsYzrY4vDPQ5voGkQsnOTFTeo09XbE1SfOT6cPv6NJw=,tag:M0hvDGUnFM8lRxRiXNMOUA==,type:str] + easybell-4: ENC[AES256_GCM,data:+bvA76qDKPfSwF/j,iv:Dtnn8JOnIHEXfwjqIWnNlAWdCVIzDbuz1VT6YVPo62w=,tag:NQrKHSV93u5ZAnwYu8EDHQ==,type:str] + easybell-5: ENC[AES256_GCM,data:yj8BuiShAb7gRapp,iv:R0Rj6+Bd54nb4vGfv2yD+H5miWaxLIiMwozgsq/cGN8=,tag:/iQ0r1rTOvDsb+Ik1Rg6oA==,type:str] + easybell-6: ENC[AES256_GCM,data:aAgbSrXbReqUkFq0,iv:VcAsb+246Qys0BJGpTwxTaj5LpQ5fuyJNys3EOMzt5k=,tag:7zXTQNtdsadRiZ/7DrtnHg==,type:str] + easybell-7: ENC[AES256_GCM,data:XU+9wmOTck+xXedv,iv:2ehV8RDzGY68BmlJf5u1oCG/G80uDtFBiA4MGotrFgU=,tag:+mGKoOBjmrB70iOoBTTsKA==,type:str] + easybell-8: ENC[AES256_GCM,data:mVjh6ybvPnT8YhXy,iv:l6RXSdK7Jq/ObOc0gx2fw/9SoZNyGaIAjsl9wBiI7UI=,tag:eIOi54s6RsmYHvrG15pPYQ==,type:str] + easybell-9: ENC[AES256_GCM,data:v0fo8FFrfQQn1H29,iv:jmDFvuRb4W12D9Gh6CLArymyf7efMvsQiELGksTa6Lk=,tag:wylK++mO98LZCsWd1DIT1Q==,type:str] sops: age: - recipient: age16s0cyttcsp40jup9vnreck6mw500ae8j4ayrmf0tg79ukhgua3vsf4m5j4 @@ -14,8 +25,8 @@ sops: TFN1ZFJ2cEZmcHoxSmU1c3o0Q0w1cnMKkT8uBrgL9zyL5PAcqJqQerUdJN8yieVO JwJvcU3I6reHuVkeNKGCZXdYrNMGeFPWwL88yHJW9MYjhO6xfDo8WQ== -----END AGE ENCRYPTED FILE----- - lastmodified: "2025-07-22T09:02:55Z" - mac: ENC[AES256_GCM,data:EYfRNPGHQYmxYPswTozFpd7Vp9j7PhV/Vop8dvvdr3JeAUGoHF2FHZt2Xxrni/wu3CSFW2jGLpMPXigiCxZndbGZhREjCaFrvtNIL/5fhmFV9hoAuW7jp8ydRbHoSB2wJ0d+O/YO4Y5uoKO+pnbmvWgMpHllrBvMMJ/+1tBgh5g=,iv:48VMeGQvhVTAgrtKNbyE9YTQLsp7vYlRPrm9cUMBC24=,tag:j9PPD8B7CYiojNKf6BhG+w==,type:str] + lastmodified: "2025-07-23T20:25:37Z" + mac: ENC[AES256_GCM,data:fuTK5OV8mL8xe23/IkwDHiseSvfZ7BteR88k40rVCQaHOtVU66BteffEzxB6oHTQdmr4Ni8S7lrT2s3Y5oUpKe8oy6a7fbDL8fSipiKXzrUDvmnIr02Cp3UkUeEZrZXgClp31YRLtL00u1qvgSOxSBGCHXJwY1Xyoy9T5u0PNtQ=,iv:wQNa9COOvgoEmbPbCr1p/51158B9/97iqKGmvfYRti4=,tag:TEheul0eeir06sRGHm1NvQ==,type:str] pgp: - created_at: "2025-07-18T23:14:45Z" enc: |- diff --git a/hosts/pbx/services/default.nix b/hosts/pbx/services/default.nix index d22355b..4a97612 100644 --- a/hosts/pbx/services/default.nix +++ b/hosts/pbx/services/default.nix @@ -2,6 +2,7 @@ imports = [ ./fieldpoc ./public-ip4-tunnel.nix + ./unifi-controller ./webserver.nix ]; } diff --git a/hosts/pbx/services/fieldpoc/accounts.nix b/hosts/pbx/services/fieldpoc/accounts.nix new file mode 100644 index 0000000..0bf2ea2 --- /dev/null +++ b/hosts/pbx/services/fieldpoc/accounts.nix @@ -0,0 +1,63 @@ +{ + config, + lib, + pkgs, + ... +}: { + sops.secrets = lib.listToAttrs ( + map + (number: + lib.nameValuePair "yate/accounts/easybell-${toString number}" { + sopsFile = ../../secrets.yaml; + owner = "yate"; + }) + (lib.lists.range 2 9) + ); + + environment.etc."yate/accfile.conf" = { + mode = "symlink"; + source = "/var/run/yate/accfile.conf"; + }; + + systemd.services.yate.serviceConfig = let + easybellAccount = name: username: let + title = "easybell-${toString name}"; + secretPath = config.sops.secrets."yate/accounts/${title}".path; + in '' + [${title}] + enabled=yes + protocol=sip + username=${username} + password=$(cat "${secretPath}") + registrar=pbx.easybell.de + ''; + accounts = [ + (easybellAccount 2 "CPBX-61tkfwsx-000004") + (easybellAccount 3 "CPBX-61tkfwsx-000005") + (easybellAccount 4 "CPBX-61tkfwsx-000006") + (easybellAccount 5 "CPBX-61tkfwsx-000007") + (easybellAccount 6 "CPBX-61tkfwsx-000008") + (easybellAccount 7 "CPBX-61tkfwsx-000009") + (easybellAccount 8 "CPBX-61tkfwsx-000010") + (easybellAccount 9 "CPBX-61tkfwsx-000011") + ]; + in { + RuntimeDirectory = "yate"; + RuntimeDirectoryMode = lib.mkForce "2750"; + ExecStartPre = pkgs.writeShellScript "yate-pre-start" '' + cat > "$RUNTIME_DIRECTORY/accfile.conf" << EOF + ${lib.concatStringsSep "\n" accounts} + EOF + ''; + }; + + services.yate.config = { + yate.modules."regexroute.yate" = "enable"; + regexroute.default = let + matchCalled = account: ''''${called}^${account}$''; + in { + "${matchCalled "CPBX-61tkfwsx-000004"}" = "sip/sip:1337@192.168.98.11"; + #"^.*$" = ''echo REGEXROUTE DEBUG called=''${called} address=''${address} callsource=''${callsource} formats=''${formats} id=''${id} peerid=''${peerid} ip_host=''${ip_host} ip_port=''${ip_port} overlapped=''${overlapped} rtp_forward=''${rtp_forward} type=''${type} username=''${username} line=''${line} account=''${account} caller=''${caller} called=''${called} module=''${module}''; + }; + }; +} diff --git a/hosts/pbx/services/fieldpoc/default.nix b/hosts/pbx/services/fieldpoc/default.nix index 949f1bd..9aa1bec 100644 --- a/hosts/pbx/services/fieldpoc/default.nix +++ b/hosts/pbx/services/fieldpoc/default.nix @@ -5,6 +5,11 @@ to = 11250; }; in { + imports = [ + ./accounts.nix + ./extensions.nix + ]; + sops.secrets."fieldpoc/omm" = { sopsFile = ../../secrets.yaml; owner = "fieldpoc"; @@ -41,9 +46,18 @@ in { ]; services = { - yate.config.yrtpchan.general = { - minport = rtpPorts.from; - maxport = rtpPorts.to; + yate.config = { + yrtpchan.general = { + minport = rtpPorts.from; + maxport = rtpPorts.to; + }; + ysipchan = { + "listener voice" = { + addr = (builtins.elemAt config.networking.interfaces.voice.ipv4.addresses 0).address; + type = "udp"; + port = 5060; + }; + }; }; fieldpoc = { diff --git a/hosts/pbx/services/fieldpoc/extensions.nix b/hosts/pbx/services/fieldpoc/extensions.nix new file mode 100644 index 0000000..160b7f5 --- /dev/null +++ b/hosts/pbx/services/fieldpoc/extensions.nix @@ -0,0 +1,100 @@ +{ + lib, + pkgs, + ... +}: let + domain = "tel.weinturm.de"; + mkphonebook = pkgs.python3.pkgs.buildPythonPackage { + pname = "fieldpoc-mkphonebook"; + version = "1.0"; + + src = ./mkphonebook.py; + + dontUnpack = true; + + propagatedBuildInputs = [pkgs.makeWrapper]; + + format = "other"; + + installPhase = '' + mkdir -p $out/lib/mkphonebook + mkdir -p $out/bin + cp $src $out/lib/mkphonebook/script.py + + makeWrapper ${pkgs.python3.interpreter} $out/bin/mkphonebook \ + --add-flags "$out/lib/mkphonebook/script.py" \ + --set PYTHONPATH "${pkgs.python3.pkgs.pyyaml}/${pkgs.python3.sitePackages}" + ''; + }; + webmanifest = lib.generators.toJSON {} { + name = "Weinturm"; + short_name = "Telefonbuch"; + icons = [ + { + src = "/web-app-manifest-192x192.png"; + sizes = "192x192"; + type = "image/png"; + purpose = "maskable"; + } + { + src = "/web-app-manifest-512x512.png"; + sizes = "512x512"; + type = "image/png"; + purpose = "maskable"; + } + ]; + theme_color = "#ffffff"; + background_color = "#300a8d"; + display = "standalone"; + }; + webmanifestFile = pkgs.writeText "site.webmanifest" webmanifest; + webroot = pkgs.stdenvNoCC.mkDerivation { + name = "webroot-${domain}"; + src = ./html; + dontBuild = true; + installPhase = '' + export PATH="$PATH:${pkgs.lib.makeBinPath [pkgs.imagemagick]}" + mkdir $out + cp "$src/favicon.svg" "$out/favicon.svg" + convert -background transparent "$src/favicon.svg" -define icon:auto-resize=16,24,32,48,64,72,96,128,256 "$out/favicon.ico" + convert -background transparent "$src/favicon.svg" -resize 180x180 "$out/apple-touch-icon.png" + convert -background transparent "$src/favicon.svg" -resize 96x96 "$out/favicon-96x96.png" + convert -background transparent "$src/favicon.svg" -resize 192x192 "$out/web-app-manifest-192x192.png" + convert -background transparent "$src/favicon.svg" -resize 512x512 "$out/web-app-manifest-512x512.png" + cp "${webmanifestFile}" "$out/site.webmanifest" + ln -s /persist/html/index.html "$out/index.html" + ''; + }; +in { + environment.systemPackages = [ + ( + pkgs.writeShellScriptBin "fieldpoc-load-extensions" '' + set -e + + tmpfile="$(mktemp -p /tmp tmp.extensions.XXXXXXXXXX.json)" + trap "rm -f $tmpfile" 0 2 3 15 + + ${pkgs.yq}/bin/yq \ + '.extensions[] |= with_entries(select(.key | IN("name", "type", "dialout_allowed", "trunk", "static_target", "callgroup_members", "sip_password", "dect_ipei")))' \ + "$1" > $tmpfile + + cat "$tmpfile" | /run/wrappers/bin/sudo -u fieldpoc tee /var/lib/fieldpoc/extensions.json >/dev/null + + curl -s --fail --json '{}' http://127.0.0.1:9437/reload + + ${mkphonebook}/bin/mkphonebook "$1" "/persist/html/index.html" + + rm -f $tmpfile + '' + ) + ]; + + services.nginx.virtualHosts = { + "${domain}" = { + serverAliases = ["tel.weinturm-open-air.de"]; + enableACME = true; + forceSSL = true; + root = webroot; + }; + }; +} diff --git a/hosts/pbx/services/fieldpoc/html/favicon.svg b/hosts/pbx/services/fieldpoc/html/favicon.svg new file mode 100644 index 0000000..4ed0da0 --- /dev/null +++ b/hosts/pbx/services/fieldpoc/html/favicon.svg @@ -0,0 +1,162 @@ + + diff --git a/hosts/pbx/services/fieldpoc/mkphonebook.py b/hosts/pbx/services/fieldpoc/mkphonebook.py new file mode 100644 index 0000000..5deff0b --- /dev/null +++ b/hosts/pbx/services/fieldpoc/mkphonebook.py @@ -0,0 +1,285 @@ +import os +import sys +import yaml +from collections import defaultdict + + +def generate_html(extensions): + # Icons per Typ + type_icons = { + "sip": "π±", + "dect": "π", + "static": "π", + "callgroup": "π₯", + "temp": "β³", + "default": "βοΈ", + } + + def make_color(idx): + hue = (idx * 30 + idx % 2 * 180) % 360 + return f"hsl({hue}, 50%, 95%)" + + emergency_color = "hsl(0, 75%, 75%)" + + # Generiere Farbzuordnung je Location + locations = sorted(set(info.get("location", "") for info in extensions.values())) + location_colors = {} + for idx, loc in enumerate(locations): + location_colors[loc] = make_color(idx + 1) + + # Generiere HTML + html_header = """ + + +
+ + +| + | + | π§ Name | +π€ | +π Ort | +π§ Typ | +
|---|---|---|---|---|---|
| {icon} | +{ext} | +{name_cell} | +{vanity} | +{location} | +{typ} | +