Compare commits

..

41 Commits

Author SHA1 Message Date
f707f21237 Make sure required services are started in nix modules 2024-03-24 14:42:46 +01:00
3197e4f8d3 Make nerd example config use HTTP REST API for reloading 2023-12-25 18:48:54 +01:00
b820eaff52 Refactor controller to HTTP REST API 2023-12-25 18:45:02 +01:00
6d016d272f Allow specific components to be enabled an disabled 2023-12-23 22:13:27 +01:00
9571f38965 Add notice about YWSD in docs 2023-10-17 21:59:47 +02:00
c21f8f0e16 Add docs for multiple RFPs 2023-10-17 20:44:13 +02:00
d23c1fbedb Specify FieldPOC version once in flake.nix 2023-10-14 21:26:27 +02:00
a35780e489 Document integration of nerd 2023-10-14 21:09:26 +02:00
74d682272c Add docs for operating FieldPOC 2023-10-14 20:59:36 +02:00
aa06353a18 Add link to git repo in docs 2023-10-14 20:20:22 +02:00
928d23ce14 Document extensions file and FieldPOC controller 2023-10-14 20:19:26 +02:00
42686f1f41 Document command line options 2023-10-14 19:24:05 +02:00
b59c8bfd05 Add FieldPOC configuration reference 2023-10-14 18:41:20 +02:00
190d89eb2c Moving installation docs to seperate directory 2023-10-14 18:24:16 +02:00
498a429c8d Explain FieldPOC setup config 2023-10-14 18:19:49 +02:00
73c2aff0af Extend OMM setup 2023-09-16 23:13:45 +02:00
da0df6bab4 Warp docs in a mkdocs website 2023-09-15 18:00:18 +02:00
cc43776e6d Add some docs 2023-06-18 22:19:30 +02:00
3519bdcec4 Add NixOS modules 2023-06-18 21:51:21 +02:00
6825ba8156 Fix handling of temp extension data 2023-06-18 16:04:21 +02:00
ca9c82dd59 Prevent crashing on exeptions during reload of extensions 2023-06-18 15:28:53 +02:00
e51cf2fc48 Use extension attributes in DECT thread 2023-06-18 15:27:06 +02:00
6c391dc9bb Wait after exceptions to slow down retries 2023-06-17 22:38:23 +02:00
e598684837 Do not commit nix build products to git 2023-06-17 20:55:44 +02:00
f6eefbfc8e Updating ywsd dependency 2023-06-17 20:51:05 +02:00
0b30cb2bc7 Retry after failing DECT tasks 2023-06-17 20:18:54 +02:00
ff480cb8b5 Add flake 2022-10-16 10:43:13 +02:00
710d68268f Don't allow extensions to be a prefix of other extensions 2022-09-03 08:58:24 +02:00
6a227b4dce Move extensions.json file path to config file 2022-09-02 22:03:38 +02:00
d72ff01372 Refactor extensions parsing 2022-09-02 21:51:55 +02:00
ba0d687b63 Refactor thread handling 2022-09-02 21:07:39 +02:00
035576dff0 Fix json syntax 2022-09-02 20:43:58 +02:00
557ed051d6 Describe system architecture 2022-09-01 13:33:27 +02:00
9a84809984 Rename reload_config to reload_extensions to make clear what it really does 2022-08-27 16:16:24 +02:00
e840ec0afc Explain the use of sipsecret 2022-08-27 16:14:20 +02:00
Ember 'n0emis' Keske
3a113c9baf try to fix sqyalchemy-error wieh deleting a forkrankmember 2022-07-27 14:12:02 +02:00
Ember 'n0emis' Keske
2f1347f341 allow to claim extensions 2022-07-20 23:39:58 +02:00
Ember 'n0emis' Keske
4382942ad8 sync after creating temp-extensions 2022-07-20 15:44:11 +02:00
Ember 'n0emis' Keske
d6d664b469 do more dect sync stuff 2022-07-10 13:04:20 +02:00
Ember 'n0emis' Keske
a444e41787 routing: save ids when adding to database, use correct queue 2022-07-10 12:40:05 +02:00
c2ef36c5a7 Add missing dependencies 2022-07-06 14:45:37 +02:00
36 changed files with 1684 additions and 160 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
__pycache__
dist/
.venv
result

View File

@@ -7,6 +7,10 @@ A simple to use, good enough phone system for medium sized DECT networks.
FieldPOC requires a Mitel SIP DECT phone system, i.e. RFP L43 WLAN.
Make sure you have the equipment, else this software is quit useless.
This is more a sample setup.
Everything described here can be done on multiple different ways.
We just try to document a way that is proven to work.
Get a computer, i.e. an APU Board, and install Archlinux on it.
This can be a VM too, but you have to adapt stuff.
@@ -333,6 +337,9 @@ cdr_finalize=UPDATE users SET inuse=(CASE WHEN inuse>0 THEN inuse-1 ELSE 0 END)
Just choose one of your RFPs as your OMM.
Find out the MAC address and fill it in the according fields in the kea config before.
Please generate a 16-characters long hexadecimal string as `sipsecret`.
This allowes the SIP accounts used for DECT phones to get a deterministic, but somewhat secure internal password.
Create a privileged user account and add the credentials to `fieldpoc_config.json`:
```
@@ -341,6 +348,7 @@ Create a privileged user account and add the credentials to `fieldpoc_config.jso
"host": "10.222.222.11",
"username": "omm",
"password": "<password>"
"sipsecret": "<secret>"
}
}
```
@@ -413,3 +421,43 @@ List some options:
```
python -m fieldpoc.run --help
```
## Architecture
FieldPOC is daemon that takes a defined state and makes all connected services fit that state.
Each connected service is observed by a dedicated class executed in a seperate thread.
There exist some more classes and threads to just glue everything together.
### `extensions.json`
The `extensions.json` file is the core of the whole setup and defines the state.
It contains which extensions exist and for what they should be used.
### `fieldpoc.py`
This file containes the main class `FieldPOC`.
It spawns the mentioned threads, initializes other classes and provices communication queues between the threads/classes.
### `config.py`
This manages FieldPOCs configuration.
### `controller.py`
The controller provices an interactive interface to manage and debug FieldPOC.
### `run.py`
This is a thin wrapper to start FieldPOC.
### `dect.py`
This deals with the Mitel OMM and configures all phones based on the current state.
### `routing.py`
This part sets up Yate using Ywsd, registers all extensions and configures routing.
### `ywsd.py`
This starts Ywsd with FieldPOC so you don't have to start it seperately.

View File

@@ -0,0 +1,6 @@
# Multiple RFPs
The [network setup described in the install section](../install/network.md) already accounts for multiple RFPs.
Just add more to the same LAN.
The DHCP options tell the other RFPs where they find the OMM.
The additional RFPs show up in the OMM and have to be activated manually.

24
docs/extension/nerd.md Normal file
View File

@@ -0,0 +1,24 @@
# Nerd web interface
Nerd is a web interface for managing extensions.
Source code: <https://github.com/dect-e/nerd/>
## Sync extensions to FieldPOC
Nerd has an HTTP endpoint generation a FieldPOC compatible extensions file.
You can adopt the follwing NixOS configuration snipped.
It downloads the JSON file, copies it to the location FieldPOC expects the extensions and reload FieldPOC.
```
systemd.services.fieldpoc-nerd = {
wantedBy = ["multi-user.target"];
startAt = "*-*-* *:*:00";
script = ''
${pkgs.curl}/bin/curl https://nerd.example.com/export.json\?event=1 > /etc/fieldpoc/extensions.json
${pkgs.curl}/bin/curl --json '{}' http://127.0.0.1:9437/reload
'';
};
```

5
docs/index.md Normal file
View File

@@ -0,0 +1,5 @@
# FieldPOC
A simple to use, good enough phone system for medium sized DECT networks.
The source code can be found at: <https://git.clerie.de/clerie/fieldpoc/>

21
docs/install/configure.md Normal file
View File

@@ -0,0 +1,21 @@
# Configure FieldPOC
To enable FieldPOC on the system just add this config.
```
services.fieldpoc = {
enable = true;
ommIp = "10.42.132.2";
ommUser = "omm";
ommPasswordPath = "/var/src/secrets/fieldpoc/ommpassword";
sipsecretPath = "/var/src/secrets/fieldpoc/sipsecret";
};
```
- `ommIp` is the IP address of your OMM.
- `ommUser` is the user account that FieldPOC uses to access the OMM. You usually want to set this to `omm`.
- `ommPasswordPath` is the path to a file containing the password for the OMM user. This password is configured when [setting up the OMM](omm.md#login-credentials).
- `sipsecretPath` is a path to a file containing a 16 characters hex string. FieldPOC is generating internal SIP accounts for each DECT user. So nobody can hijack these accounts, this secret is used as a seed for generating passwords for these accounts.
When enabling the FieldPOC module a correctly configured Yate service is added to your system.

18
docs/install/hardware.md Normal file
View File

@@ -0,0 +1,18 @@
# Required hardware
These are the hardware requirements for a minimal working system.
## DECT Antenna
FieldPOC is automation around the management API of Mitel DECT systems.
So you need to have a [Mitel RFP 3rd gen](https://howto.dect.network/#hardware-rfp-generations).
Our development setup currently uses an RFP 43 WLAN.
## Powering the Antenna
The RFP gets powered using PoE, so make sure you have a **PoE Injector**
## Telephony Server
The telephony server should be a x84 64bit computer with two Gigabit Ethernet RJ45 ports.
In out case we use an APU Board, but it doesn't really matter.

16
docs/install/index.md Normal file
View File

@@ -0,0 +1,16 @@
# Installation overview
FieldPOC requires a bunch of components to work together.
We guide you through the setup of a minimal working system.
Follow it in order and you will end up with a working setup.
1. Required hardware
2. Install NixOS
3. Install FieldPOC
4. Configure networking
5. Configure FieldPOC
6. Prepare RFP
7. Setup OMM
8. Have DECT
There are plenty of options to extend the system and we try to document them in the following sections.

55
docs/install/network.md Normal file
View File

@@ -0,0 +1,55 @@
# Configure networking
The hardware should have two network interfaces, so we can use one interface to connect the telephony server to the internet and access for management.
The other network interface is used to connect to the DECT antenna.
## Management and internet interface
You can configure the interface for management and internet access how you like it.
Usually leaving it with doing DHCP is totally find.
## Interface for DECT antenna
The telephony server is acting as a router for the DECT antenna.
The network interface used in this example is called `enp3s0`.
Replace it with the name of your own interface.
Assign a static IP address to the interface:
```
networking.interfaces.enp3s0.ipv4.addresses = [ { address = "10.42.132.1"; prefixLength = 24; } ];
```
Phoning over the internet involves weird protocols.
Because configuring firewalls for that use-case is hard, we disable the NixOS firewall on that interface.
We can do that safely, as only the DECT antenna is connected to it and we have to trust it anyway.
```
networking.firewall.trustedInterfaces = [ "enp3s0" ];
```
### Configure DHCP server
FieldPOC ships with some configuration wrapper that helps setting up the DHCP server required for the DECT antennas.
```
services.fieldpoc = {
dhcp = {
enable = true;
interface = "enp3s0";
subnet = "10.42.132.0/24";
pool = "10.42.132.200 - 10.42.132.250";
router = "10.42.132.1";
dnsServers = "10.42.10.8";
omm = "10.42.132.2";
reservations = [
{
name = "omm";
macAddress = "00:30:42:1b:8c:7c";
ipAddress = "10.42.132.2";
}
];
};
};
```

25
docs/install/nixos.md Normal file
View File

@@ -0,0 +1,25 @@
# Install NixOS
To coordinate all the different components, FieldPOC is packaged with NixOS.
Follow [the official installation guide](https://nixos.org/manual/nixos/stable/#ch-installation) to install NixOS on you telephony server.
You can use the minimal installer, as we don't need graphical user interface.
## Add FieldPOC packages
FieldPOC is provided as a Flake.
Add the following inputs to your `flake.nix`:
```
inputs.fieldpoc.url = "git+https://git.clerie.de/clerie/fieldpoc.git";
```
Do not set the FielPOC flake to follow your nixpkgs input as this messes with the dependencies of FieldPOC.
Especially the Python modules are incredibly dependent on specific version combinations to properly run.
Add input modules to your system:
```
fieldpoc.nixosModules.default
```

67
docs/install/omm.md Normal file
View File

@@ -0,0 +1,67 @@
# Setup OMM
OMM stands for Open Mobility Manager.
It is there to manage multiple RFPs.
## Designate OMM
The OMM is a pice of software that can be hosted stand-alone or you can designate a RFP to be the OMM.
In setups operating FieldPOC it is usually totally fine and recommended to just designate a RFP to be the OMM.
RFPs automatically host the OMM when they receive their own IP address in the OMM field via DHCP.
## Management interface
The OMM exposes a web interface at their IP address.
It is reachable via its IP address, port 443.
## Setup
When using the OMM the first time, some initial tasks have to be done.
This only apply on a fresh factory reset RFP.
You get a login screen without a PARK shown.
You can login with the credentials `omm` and password `omm`.
Afterwards you get propted to accept the EULA.
### Login credentials
You get asked to set a new password for user `omm`.
Set a new password.
Make sure to leave password aging to `None`.
Next you get ask to set a password for the `root` user too.
It is used to access the OMM via SSH.
Set a password for it too.
### Activate license
You can configure many things, but the OMM does not really do anything without a license.
Go to tab `System` and then `System Settings` and find row `PARK`.
Load PARK file as explained in [howto.dect.network](https://howto.dect.network/#system-settings).
Wait for OMM restarting.
### SIP Settings
Connect the OMM with the SIP server of FieldPOC.
Go to tag `System`, then `SIP` and find rows `Proxy server` and `Registrar server`.
Fill in the IPv4 address of the DECT network interface of the FieldPOC server (`10.42.132.1` in the example).
Save using the `OK` button.
### DECT devices subscription
This enables DECT devices to connect to the phone system.
Go to tab `SIP Users/Devices`.
Set `DECT authentication code` to `0000`.
Set checkbox in row `Auto-create on subscription`.
Change the drop down in row `Subscription` to `Subscription`.
Save using the `OK` button.
> Sometimes the value in row `Subscription` changes to `Off` randomly.
> If you can't subscribe new devices, make sure to check this option.

11
docs/install/rfp.md Normal file
View File

@@ -0,0 +1,11 @@
# Prepare RFP
Before adding an RFP to your FieldPOC setup do a [software update](https://howto.dect.network/#software-update) to **SIP-DECT 8.1** and [factory reset them](https://howto.dect.network/#factory-reset).
## Connecting RFP
Connect the RFP with the PoE injector and then with the network interface of your telephony server that carries the network for the DECT antennas.
RFPs will take a while to boot.
First only one LED will glow.
After a while more will add.

View File

@@ -0,0 +1,11 @@
# Claim DECT extension
Get a DECT phone an connect it to a new DECT base station.
It will require an authentication code.
This code got specified while [setting up the OMM](../install/omm.md#dect-devices-subscription).
Wait for the base station registration succeed.
After the base station registration wait a bit more to get a temporary number assigned.
Then call `DECT claim token base` + `dect_claim_token`.
The call should succeed and the DECT phone got the claimed token assigned.

View File

@@ -0,0 +1,8 @@
# Troubleshooting
## Registering DECT phone to base station does not work anymore
Make sure DECT `Subscription` is still set to `Subscription`.
Sometimes this changes back to `Off` by itself.
See [OMM setup](../install/omm.md#dect-devices-subscription)

View File

@@ -0,0 +1,21 @@
# Command line options
## Config file path
`-c`, `--config <path>`
Specifies the path to [FieldPOC configuration file](configuration.md).
Of ommitted it looks for a file called `fieldpoc_config.json` in the current working directory.
## Init
`--init`
Set up database. Run only once.
## Debug
`--debug`
Sets log level to debug.
Logs to stdout.

View File

@@ -0,0 +1,60 @@
# FieldPOC configuration
`fieldpoc.json` is the main configuration file for FieldPOC. It is in JSON format.
## Example config
```
{
"extensions": {
"file": "fieldpoc_extensions.json"
},
"controller": {
"host": "127.0.0.1",
"port": 9437
},
"dect": {
"host": "10.222.222.11",
"username": "omm",
"password": "xxx",
"sipsecret": "51df84aace052b0e75b8c1da5a6da9e2"
},
"yate": {
"host": "127.0.0.1",
"port": 5039
},
"database": {
"hostname": "127.0.0.1",
"username": "fieldpoc",
"password": "fieldpoc",
"database": "fieldpoc"
}
}
```
## Controller
Configuration for the FieldPOC controller.
- `host`: IP address to listen on for controller socket.
- `port`: Port to listen on for controller socket.
## DECT
- `host`: IP address of the OMM.
- `username`: Username of OMM account.
- `password`: Password of OMM account.
- `sipsecret`: Secret used to seed DECT SIP account passwords.
## Yate
- `host`: IP address Yate control socket is reachable.
- `port`: Port yate control socket is reachable.
## Database
- `hostname`: IP address PostgreSQL database is reachable.
- `username`: Username of the database user.
- `password`: Password of the database user.
- `database`: Database name for FieldPOC.

View File

@@ -0,0 +1,46 @@
# FieldPOC controller
The FieldPOC controller is a HTTP REST API to help managing the current state of the FieldPOC system.
Connect to it via the IP address von port set in the [FieldPOC configuration](configuration.md).
## API Endpoints
### `/sync`
```bash
curl --json '{}' http://127.0.0.1:9437/sync
```
Notify all parts of FieldPOC to check configuration of connected components and update it.
### `/queues`
```bash
curl http://127.0.0.1:9437/queues
```
Show internal message queue stats.
### `/reload`
```bash
curl --json '{}' http://127.0.0.1:9437/reload
```
Read [FieldPOC extensions specification](extensions.md) file and apply it.
### `/claim`
```bash
curl --json '{"extension": "1111", "token": "1234"}' http://127.0.0.1:9437/claim
```
Bind extension to DECT device
- `extension` is the current extension number of the DECT device.
- `token` is the `dect_claim_token` of the extension that should get applied.
This works because newly connected DECT phones get a temporary number assigned.
This temporary number is usually the current number.
But it is possible to use any extension, so the extension for a device can be changed any time.

View File

@@ -0,0 +1,100 @@
# Extensions data
`extensions.json` contains all extension a FieldPOC instance provides.
## Example
```
{
"extensions": {
"2574": {
"name": "clerie",
"type": "dect",
"trunk": false,
"dialout_allowed": true,
"dect_claim_token": "2574"
},
"5375": {
"name": "n0emis",
"type": "callgroup",
"dialout_allowed": true,
"trunk": false,
"callgroup_members": [
"5376",
"5377",
"5379"
]
},
"5376": {
"name": "n0emis SIP",
"type": "sip",
"dialout_allowed": true,
"trunk": false,
"outgoing_extension": "5375",
"sip_password": "wieK5xal"
},
"5377": {
"name": "n0emis DECT",
"type": "dect",
"dialout_allowed": true,
"trunk": false,
"outgoing_extension": "5375",
"dect_ipei": "10345 0136625 3"
},
"9998": {
"name": "Temporary Numbers",
"trunk": false,
"dialout_allowed": true,
"type": "temp"
},
"9997": {
"name": "DECT Claim Extensions",
"type": "static",
"dialout_allowed": false,
"trunk": true,
"static_target": "external/nodata//run/current-system/sw/bin/dect_claim"
}
}
}
```
## Extensions definition
The key for an extension is always the number of the extension.
Following keys are required:
- `name`: Description of the extension.
- `type`: Type of the extension.
## Extension types
Based on the type of the extension more options are required.
### SIP extension
- `type`: `sip`
- `sip_password`: Plain text password for the SIP account.
### DECT extension
- `type`: `dect`
- `dect_claim_token`: Phone number part used to connect a DECT phone to this extension.
- `dect_ipei`: IPEI of the DECT phone this extension should be connected to.
`dect_claim_token` and `dect_ipei` are mutally exclusive.
### Static extension
- `type`: `static`
- `static_target`: Path to script that is executed on calling this extension.
### Temporary extension
- `type`: `temp`
### Callgroup extension
- `type`: `callgroup`
- `callgroup_members`: List of extension numbers that belong to this callgroup.

10
docs/reference/ywsd.md Normal file
View File

@@ -0,0 +1,10 @@
# YWSD
FieldPOC is using [Yate Wähl System Digital](https://github.com/eventphone/ywsd) for call routing.
The routes are stored in a PostgreSQL database that is populated by FieldPOC.
Actual routing is handled by the YWSD software that is started as part of FieldPOC.
When FieldPOC stopped, YWSD is stopped to.
Therefore running calls will continue as long as everything else is working.
But new calls can not be established.

View File

@@ -1,12 +1,18 @@
#!/usr/bin/env python3
class ConfigBase:
_DEFAULTS = {}
def __init__(self, c):
self._c = c
def __getattr__(self, name):
# check if key is in config
if name in self._c.keys():
return self._c.get(name)
# check if key is in default config
elif name in self._DEFAULTS.keys():
return self._DEFAULTS.get(name)
else:
raise AttributeError()
@@ -20,42 +26,32 @@ class DatabaseConfig(ConfigBase):
class DectConfig(ConfigBase):
_DEFAULTS = {
"enabled": True,
}
def __init__(self, c):
self._c = c
def check(self):
return True
class YateConfig(ConfigBase):
class ExtensionsConfig(ConfigBase):
pass
class YateConfig(ConfigBase):
_DEFAULTS = {
"enabled": True,
}
class Config:
def __init__(self, c):
self._c = c
self.controller = ControllerConfig(c.get("controller", {}))
self.database = DatabaseConfig(c.get("database", {}))
self.dect = DectConfig(c.get("dect", {}))
self.extensions = ExtensionsConfig(c.get("extensions", {}))
self.yate = YateConfig(c.get("yate", {}))
def check(self):
return self.dect.check()
class ExtensionConfig:
def __init__(self, c):
self.num = c[0]
self._c = c[1]
class Extensions:
def __init__(self, c):
self._c = c
self.extensions = []
for e in self._c.get("extensions", {}).items():
self.extensions.append(ExtensionConfig(e))
def extensions_by_type(self, t):
for e in self.extensions:
if e.type == t:
yield e

View File

@@ -1,86 +1,190 @@
#!/usr/bin/env python3
import logging
import selectors
import socket
import socketserver
import time
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler, HTTPStatus
import threading
import json
logger = logging.getLogger("fieldpoc.controller")
class Controller:
def __init__(self, fp):
self.fp = fp
self.handlers = [] # collect control sockets for handlers, so we can shut them down on demand
def get_handler(self):
class HandleRequest(socketserver.BaseRequestHandler):
fp = self.fp
controller = self
"""
Create a handler passed to the HTTP server
def setup(self):
self.handler_read, self.handler_write = socket.socketpair()
self.controller.handlers.append(self.handler_write)
Return a subclass of BaseHTTPRequestHandler
"""
def handle(self):
self.request.sendall("FieldPOC interactive controller\n".encode("utf-8"))
# Paths by HTTP request method
PATHS = {
"GET": {},
"POST": {},
}
sel = selectors.DefaultSelector()
sel.register(self.handler_read, selectors.EVENT_READ)
sel.register(self.request, selectors.EVENT_READ)
while True:
self.request.sendall("> ".encode("utf-8"))
def index():
"""
Index page
"""
for key, mask in sel.select():
if key.fileobj == self.handler_read:
self.request.sendall("\ndisconnecting\n".encode("utf-8"))
return
elif key.fileobj == self.request:
data = self.request.recv(1024).decode("utf-8").strip()
return "FieldPOC HTTP Controller"
if data == "help":
self.request.sendall("""Availiable commands:
help Show this info
handlers Show currently running handlers
sync Start syncing
queues Show queue stats
exit Disconnect
""".encode("utf-8"))
elif data == "quit" or data == "exit":
self.request.sendall("disconnecting\n".encode("utf-8"))
return
elif data == "handlers":
self.request.sendall(("\n".join([str(h) for h in self.controller.handlers]) + "\n").encode("utf-8"))
elif data == "sync":
self.fp.queue_all({"type": "sync"})
elif data == "queues":
self.request.sendall(("\n".join(["{} {}".format(name, queue.qsize()) for name, queue in self.fp.queues.items()]) + "\n").encode("utf-8"))
else:
self.request.sendall("Unknown command, type 'help'\n".encode("utf-8"))
PATHS["GET"]["/"] = index
def sync(payload):
"""
Notify all parts of FieldPOC to check configuration of connected components and update it
"""
self.fp.queue_all({"type": "sync"})
return "ok"
PATHS["POST"]["/sync"] = sync
def queues():
"""
Show status of message queues
"""
return {name: queue.qsize() for name, queue in self.fp.queues.items()}
PATHS["GET"]["/queues"] = queues
def reload(payload):
"""
Read extensions specification and apply it
"""
self.fp.reload_extensions()
PATHS["POST"]["/reload"] = reload
def claim(payload):
"""
Bind extension to DECT device
"""
if payload is None:
return {}
try:
self.fp.queue_all({"type": "claim", "extension": payload["extension"], "token": payload["token"]})
return "ok"
except KeyError:
return { "error": "You have to specify calling extension and token" }
PATHS["POST"]["/claim"] = claim
class HandleRequest(BaseHTTPRequestHandler):
def do_GET(self):
if self.path in PATHS["GET"]:
response = PATHS["GET"][self.path]()
return self.make_response(response)
else:
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
return
def do_HEAD(self):
if self.path in PATHS["GET"]:
response = PATHS["GET"][self.path]()
return self.make_response(response, head_only=True)
else:
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
return
def do_POST(self):
if self.path in PATHS["POST"]:
payload = self.prepare_payload()
response = PATHS["POST"][self.path](payload)
return self.make_response(response)
else:
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
return
def make_response(self, content, head_only=False):
"""
Take a dict and return as HTTP json response
"""
# Convert response to json
content = json.dumps(content) + "\n"
# Encode repose as binary
encoded = content.encode("utf-8")
# Send 200 status code
self.send_response(HTTPStatus.OK)
# Set json header
self.send_header("Content-Type", "application/json; charset=utf-8")
# Specify content length
self.send_header("Conten-Length", str(len(encoded)))
# Finish headers
self.end_headers()
# Return early on HEAD request
if head_only:
return
# Send response
self.wfile.write(encoded)
def prepare_payload(self):
"""
Read payload data from HTTP POST request and parse as json.
"""
# Get payload length
length = int(self.headers["Content-Length"])
# Read payload up to length
encoded = self.rfile.read(length)
# Decode payload to string
content = encoded.decode("utf-8")
# Return early if no payload
if not content:
return None
# Parse payload as json
return json.loads(content)
def finish(self):
self.controller.handlers.remove(self.handler_write)
return HandleRequest
def run(self):
logger.info("starting server")
class Server(socketserver.ThreadingTCPServer):
class Server(ThreadingHTTPServer):
"""
Subclass ThreadingHTTPServer to set custom options
"""
# Sometimes sockets are still bound to addresses and ports
# we just ignore that with this
allow_reuse_address = True
with Server((self.fp.config.controller.host, self.fp.config.controller.port), self.get_handler()) as server:
# Start the server
with Server(
# Passing address and port from config
(self.fp.config.controller.host, self.fp.config.controller.port),
# With the handler class we generated
self.get_handler()
) as server:
# Starting server loop in another thread
threading.Thread(target=server.serve_forever).start()
# Listen on the message queue
while True:
# Wait for a new message and get it
msg = self.fp.queues["controller"].get()
# Remove message from queue
self.fp.queues["controller"].task_done()
if msg.get("type") == "stop":
logger.info("stopping server")
# Stop http server loop
server.shutdown()
for h in self.handlers:
h.send(b'\0')
# Exist message queue listening loop
break

View File

@@ -3,12 +3,16 @@
import logging
import mitel_ommclient2
import time
import hashlib
from .extensions import ExtensionConfig
logger = logging.getLogger("fieldpoc.dect")
class Dect:
def __init__(self, fp):
self.fp = fp
self.c = None # OOMClient2 not initialized at the beginning
def _init_client(self):
self.c = mitel_ommclient2.OMMClient2(
@@ -18,38 +22,211 @@ class Dect:
ommsync=True,
)
def get_temp_number(self):
temp_num_prefix = next(self.fp.extensions.extensions_by_type("temp")).num
@property
def temp_num_prefix(self):
return next(self.fp.extensions.extensions_by_type("temp")).num
def load_temp_extensions(self):
current_temp_extension = 0
used_temp_extensions = [u.num[len(temp_num_prefix):] for u in self.c.find_users(lambda u: u.num.startswith(temp_num_prefix))]
used_temp_extensions = self.c.find_users(lambda u: u.num.startswith(self.temp_num_prefix))
for u in used_temp_extensions:
temp_num = u.num
self.fp.temp_extensions[temp_num] = ExtensionConfig(
num=temp_num,
name=f"Temp {temp_num[4:]}",
type="dect",
trunk=False,
dialout_allowed=False,
)
def get_temp_number(self):
current_temp_extension = 0
used_temp_extensions = [num[len(self.temp_num_prefix):] for num, ext in self.fp.temp_extensions.items()]
while "{:0>4}".format(current_temp_extension) in used_temp_extensions:
current_temp_extension += 1
return "{}{:0>4}".format(temp_num_prefix, current_temp_extension)
return "{}{:0>4}".format(self.temp_num_prefix, current_temp_extension)
def get_sip_password_for_number(self, num):
return num
return hashlib.sha256(bytes.fromhex((self.fp.config.dect.sipsecret + str(num))[-16:])).hexdigest()[:16]
def create_and_bind_user(self, d, num):
u = self.c.create_user(num)
self.c.attach_user_device(u.uid, d.ppn)
self.c.set_user_relation_fixed(u.uid)
self.c.set_user_sipauth(u.uid, num, self.get_sip_password_for_number(num))
return u
def sync(self):
logger.info("syncing")
# load DECT extensions from configuration
extensions = self.fp.extensions.extensions_by_type("dect")
# accessible by extension number
extensions_by_num = {e.num: e for e in extensions}
# accessible by device IPEI, for devices with static IPEI in configuration
extensions_by_ipei = {e.dect_ipei: e for _, e in extensions_by_num.items() if e.dect_ipei is not None}
# signal if new extensions got created
created_tmp_ext = False
# collect users
users_by_ext = {}
users_by_uid = {}
# check all users on OMM
for user in self.c.get_users():
# check user on OMM against fieldpoc configuration
e = extensions_by_num.get(user.num)
# user in OMM, but no DECT extension in configuration
if not e:
# the user might have a temporary extension assigned, collect it
if user.num.startswith(next(self.fp.extensions.extensions_by_type("temp")).num):
users_by_ext[user.num] = user
users_by_uid[user.uid] = user
# we have no configuration for the user
else:
# TODO: delete in omm
pass
continue
# update user in OMM if name of extension changed
elif e.name != user.name:
self.c.set_user_name(user.uid, e.name)
if e.dect_ipei is not None and user.relType != mitel_ommclient2.types.PPRelTypeType("Unbound"):
d = self.c.get_device(user.ppn)
if d.ipei != e.dect_ipei:
logger.debug(f"Detaching {user} {d}")
self.c.detach_user_device(user.uid, user.ppn)
self.c.set_user_sipauth(user.uid, e.num, self.get_sip_password_for_number(e.num))
users_by_ext[user.num] = user
users_by_uid[user.uid] = user
# check all devices on OMM
for d in self.c.get_devices():
# find extension in fieldpoc configuration with static IPEI of the device on OMM
e = extensions_by_ipei.get(d.ipei)
# in case the IPEI is statically assigned to an extension
if e:
# find user on OMM configured for the selectied extension
u = users_by_ext.get(e.num)
# bind the user to the device if user exist and device is not bound to any user
if u and d.relType == mitel_ommclient2.types.PPRelTypeType("Unbound"):
logger.debug(f'Binding user for {d}')
self.c.attach_user_device(u.uid, d.ppn)
self.c.set_user_relation_fixed(u.uid)
# fix user settings on OMM if device on OMM is already connected to a user
elif d.relType != mitel_ommclient2.types.PPRelTypeType("Unbound"):
ui = users_by_uid.get(d.uid)
if ui.num != e.num:
logger.debug(f'User for {d} has wrong number')
if self.fp.temp_extensions.get(ui.num):
self.fp.temp_extensions.pop(ui.num)
self.c.set_user_num(d.uid, e.num)
self.c.set_user_sipauth(d.uid, e.num, self.get_sip_password_for_number(e.num))
self.c.set_user_name(user.uid, e.name)
# create a new user on OMM if none exist and bind it to not bound device on OMM
else:
logger.debug(f'Creating and binding user for {d}')
user = self.create_and_bind_user(d, e.num)
self.c.set_user_name(user.uid, e.name)
# assign any unbound device without static assigned IPEI a temporary extension
elif d.relType == mitel_ommclient2.types.PPRelTypeType("Unbound"):
temp_num = self.get_temp_number()
logger.debug(f'Creating and binding tmp-user for {d}: {temp_num}')
user = self.create_and_bind_user(d, temp_num)
self.c.set_user_name(user.uid, f"Temp {temp_num[4:]}")
self.fp.temp_extensions[temp_num] = ExtensionConfig(
num=temp_num,
name=f"Temp {temp_num[4:]}",
type="dect",
trunk=False,
dialout_allowed=False,
)
created_tmp_ext = True
# update rounting when new extensions got created
if created_tmp_ext:
logger.info("notify for routing update")
self.fp.queues['routing'].put({"type": "sync"})
logger.info("finished sync")
def claim_extension(self, current_extension, token):
new_extension = None
# find an extension that can be claimed with the provided token
for ext in self.fp.extensions.extensions_by_type("dect"):
if ext.dect_claim_token == token:
new_extension = ext
break
if e:
# find user on OMM related to the current extension
user = next(self.c.find_users(lambda u: u.num == current_extension))
# if the current extension is a temporary extension, free it up again
if self.fp.temp_extensions.get(user.num):
self.fp.temp_extensions.pop(user.num)
# assign new extension to the user on OMM
self.c.set_user_num(user.uid, new_extension.num)
self.c.set_user_sipauth(user.uid, e.num, self.get_sip_password_for_number(new_extension.num))
self.c.set_user_name(user.uid, new_extension.name)
def run(self):
logger.info("initialising connection to OMM")
self._init_client()
logger.info("starting dect thread")
while True:
msg = self.fp.queues["dect"].get()
self.fp.queues["dect"].task_done()
if msg.get("type") == "stop":
self.restart_loop = False
logger.info("stopped")
break
elif msg.get("type") == "sync":
unbound_devices = self.c.find_devices(lambda d: d.relType == mitel_ommclient2.types.PPRelTypeType("Unbound"))
for d in unbound_devices:
print(d)
temp_num = self.get_temp_number()
u = self.c.create_user(temp_num)
print(u)
self.c.attach_user_device(u.uid, d.ppn)
self.c.set_user_relation_fixed(u.uid)
self.c.set_user_sipauth(u.uid, temp_num, self.get_sip_password_for_number(temp_num))
else:
try:
# setup connection to OMM
if self.c is None:
logger.info("initialising connection to OMM")
self._init_client()
self.load_temp_extensions()
# handle tasks
if msg.get("type") == "sync":
self.sync()
elif msg.get("type") == "claim":
self.claim_extenstion(msg.get("extension"), msg.get("token")[4:])
except:
logger.exception("task failed")
# reset connection to OMM
if self.c is not None:
try:
self.c.connection.close()
except:
pass
self.c = None
time.sleep(2)
logger.info("requeue failed task")
self.fp.queues["dect"].put(msg)
if self.c is not None:
self.c.connection.close()

71
fieldpoc/extensions.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
class ExtensionConfig:
num = None
callgroup_members = []
dect_claim_token = None
dect_ipei = None
dialout_allowed = False
name = None
outgoing_extension = None
sip_password = None
static_target = None
trunk = False
type = None
def __init__(self, **kwargs):
for name, value in kwargs.items():
if hasattr(type(self), name):
self.__setattr__(name, value)
else:
raise Exception("Invalid config option {}".format(name))
if self.type is None:
raise Exception("Type field for extension {} is required".format(self.num))
elif self.type == "dect":
if self.dect_ipei is not None and self.dect_claim_token is not None:
raise Exception("dect_ipei can't be used together with dect_claim_token in extension {}".format(self.num))
elif self.type == "sip":
if self.sip_password is None:
raise Exception("sip_password is required for sip extension {}".format(self.num))
elif self.type == "static":
if self.static_target is None:
raise Exception("static_target is required for static extension {}".format(self.num))
def check_global(self, extensions):
for e in extensions.extensions:
if e.num != self.num and e.num.startswith(self.num):
raise Exception("Extension {} uses {} as prefix, which is already defined as an extension".format(e.num, self.num))
if self.type == "callgroup":
for member in self.callgroup_members:
if member not in extensions.extensions_by_num.keys():
raise Exception("Callgroup member {} of callgroup {} does not exist as extension".format(member, self.num))
class Extensions:
extensions = []
extensions_by_num = {}
def __init__(self, c):
for num, e in c.get("extensions", {}).items():
extension_config = ExtensionConfig(**{
"num": num,
} | e)
self.extensions.append(extension_config)
if extension_config.num in self.extensions_by_num.keys():
raise Exception("Extension num already used {}".format(num))
self.extensions_by_num[num] = extension_config
for e in self.extensions:
e.check_global(self)
def extensions_by_type(self, t):
for e in self.extensions:
if e.type == t:
yield e

View File

@@ -8,6 +8,7 @@ import threading
from . import config
from . import controller
from . import extensions
from . import dect
from . import routing
from . import ywsd
@@ -17,12 +18,12 @@ logger = logging.getLogger("fieldpoc.fieldpoc")
class FieldPOC:
config = None
extensions = None
temp_extensions = {}
def __init__(self, config_file_path, extensions_file_path):
def __init__(self, config_file_path):
logger.info("loading configuration")
self.config_file_path = pathlib.Path(config_file_path)
self._load_config()
self.extensions_file_path = pathlib.Path(extensions_file_path)
self._load_extensions()
logger.info("initialising queues")
@@ -38,6 +39,20 @@ class FieldPOC:
self._routing = routing.Routing(self)
self._ywsd = ywsd.Ywsd(self)
logger.info("initialising threads")
self.threads = {
"controller": threading.Thread(target=self._controller.run),
"dect": threading.Thread(target=self._dect.run) if self.config.dect.enabled == True else None,
"routing": threading.Thread(target=self._routing.run),
"ywsd": threading.Thread(target=self._ywsd.run, daemon=True) if self.config.yate.enabled == True else None,
}
# Set thread names
for name, thread in self.threads.items():
if thread is None:
continue
thread.name = name
def queue_all(self, msg):
"""
Send message to every queue
@@ -59,24 +74,30 @@ class FieldPOC:
logger.info("initialization complete")
def run(self):
logger.info("starting components")
"""
Start FieldPOC
"""
self._controller_thread = threading.Thread(target=self._controller.run)
self._controller_thread.start()
logger.info("starting threads")
self._dect_thread = threading.Thread(target=self._dect.run)
self._dect_thread.start()
for name, thread in self.threads.items():
if thread is None:
continue
thread.start()
self._routing_thread = threading.Thread(target=self._routing.run)
self._routing_thread.start()
logger.info("started threads")
self._ywsd_thread = threading.Thread(target=self._ywsd.run, daemon=True)
self._ywsd_thread.start()
logger.info("started components")
def reload_extensions(self):
self._load_extensions()
self.queue_all({"type": "sync"})
def _load_config(self):
self.config = config.Config(json.loads(self.config_file_path.read_text()))
def _load_extensions(self):
self.extensions = config.Extensions(json.loads(self.extensions_file_path.read_text()))
try:
new_extensions = extensions.Extensions(json.loads(pathlib.Path(self.config.extensions.file).read_text()))
except:
logger.exception("loading extensions failed")
else:
self.extensions = new_extensions

View File

@@ -27,11 +27,12 @@ class YwsdYateModel(YateModel):
@classmethod
def create(cls, diffsync, ids, attrs):
with diffsync.engine.connect() as conn:
conn.execute(
result = conn.execute(
Yate.table.insert().values(
guru3_identifier=ids["guru3_identifier"], **attrs
)
)
attrs["yate_id"] = result.inserted_primary_key[0]
return super().create(diffsync, ids=ids, attrs=attrs)
def update(self, attrs):
@@ -84,7 +85,7 @@ class YwsdExtensionModel(ExtensionModel):
@classmethod
def create(cls, diffsync, ids, attrs):
with diffsync.engine.connect() as conn:
conn.execute(
result = conn.execute(
Extension.table.insert().values(
extension=ids["extension"],
type=attrs["extension_type"],
@@ -98,6 +99,7 @@ class YwsdExtensionModel(ExtensionModel):
),
)
)
attrs["extension_id"] = result.inserted_primary_key[0]
return super().create(diffsync, ids=ids, attrs=attrs)
def update(self, attrs):
@@ -210,7 +212,7 @@ class YwsdForkRankModel(ForkRankModel):
@classmethod
def create(cls, diffsync, ids, attrs):
with diffsync.engine.connect() as conn:
conn.execute(
result = conn.execute(
ForkRank.table.insert().values(
extension_id=diffsync.get(
"extension", ids["extension"]
@@ -219,6 +221,7 @@ class YwsdForkRankModel(ForkRankModel):
**attrs,
)
)
attrs["forkrank_id"] = result.inserted_primary_key[0]
return super().create(diffsync, ids=ids, attrs=attrs)
def update(self, attrs):
@@ -275,7 +278,7 @@ class YwsdForkRankMemberModel(ForkRankMemberModel):
conn.execute(
ForkRank.member_table.update()
.where(
_and(
sqlalchemy.and_(
ForkRank.member_table.c.forkrank_id
== self.diffsync.get("forkrank", self.forkrank).forkrank_id,
ForkRank.member_table.c.extension_id
@@ -290,7 +293,7 @@ class YwsdForkRankMemberModel(ForkRankMemberModel):
with self.diffsync.engine.connect() as conn:
conn.execute(
ForkRank.member_table.delete().where(
_and(
sqlalchemy.and_(
ForkRank.member_table.c.forkrank_id
== self.diffsync.get("forkrank", self.forkrank).forkrank_id,
ForkRank.member_table.c.extension_id
@@ -301,7 +304,7 @@ class YwsdForkRankMemberModel(ForkRankMemberModel):
return super().delete()
class BackendNerd(diffsync.DiffSync):
class BackendFieldPOC(diffsync.DiffSync):
yate = YateModel
extension = ExtensionModel
user = UserModel
@@ -310,28 +313,32 @@ class BackendNerd(diffsync.DiffSync):
top_level = ["yate", "extension", "user", "forkrank", "forkrankmember"]
def load(self, data):
yate_dect = self.yate(
guru3_identifier="dect", hostname="dect", voip_listener="local"
)
self.add(yate_dect)
def __init__(self, fp, *args, **kwargs):
self.fp = fp
super().__init__(*args, **kwargs)
def load(self, extensions):
#yate_dect = self.yate(
# guru3_identifier="dect", hostname="dect", voip_listener="local"
#)
#self.add(yate_dect)
yate_sip = self.yate(
guru3_identifier="sip", hostname="sip", voip_listener="local"
)
self.add(yate_sip)
for key, value in data["extensions"].items():
for num, extension in extensions.items():
yate = None
if value["type"] in ["dect", "static"]:
if extension.type in ["dect", "static"]:
# yate = yate_dect.guru3_identifier
yate = yate_sip.guru3_identifier
elif value["type"] in ["sip"]:
elif extension.type in ["sip"]:
yate = yate_sip.guru3_identifier
elif value["type"] in ["callgroup"]:
forkrank = self.forkrank(extension=key, index=0, mode="DEFAULT")
elif extension.type in ["callgroup"]:
forkrank = self.forkrank(extension=num, index=0, mode="DEFAULT")
self.add(forkrank)
for member in value["callgroup_members"]:
for member in extension.callgroup_members:
frm = self.forkrankmember(
forkrank=forkrank.get_unique_id(),
extension=member,
@@ -339,34 +346,30 @@ class BackendNerd(diffsync.DiffSync):
active=True,
)
self.add(frm)
elif extension.type in ["temp"]:
continue
extension = self.extension(
extension=key,
**dict(
(k, v)
for k, v in value.items()
if k in ["name", "outgoing_extension", "dialout_allowed"]
),
extension_type=("SIMPLE" if not value["trunk"] else "TRUNK")
if not value["type"] == "callgroup"
else "GROUP",
self.add(self.extension(
extension=num,
name=extension.name,
outgoing_extension=extension.outgoing_extension,
dialout_allowed=extension.dialout_allowed,
extension_type=("SIMPLE" if extension.trunk is not None else "TRUNK") if not extension.type == "callgroup" else "GROUP",
forwarding_mode="DISABLED",
lang="en-GB",
yate=yate,
)
self.add(extension)
))
if value["type"] in ["sip", "static", "dect"]:
if extension.type in ["sip", "static", "dect"]:
user_type = {"sip": "user", "dect": "user", "static": "static"}
user = self.user(
username=key,
displayname=value["name"],
password=value.get("sip_password", key),
user_type=user_type[value["type"]],
trunk=value["trunk"],
static_target=value.get("static_target", ""),
)
self.add(user)
self.add(self.user(
username=num,
displayname=extension.name,
password=extension.sip_password if extension.sip_password is not None else self.fp._dect.get_sip_password_for_number(num),
user_type=user_type[extension.type],
trunk=extension.trunk,
static_target=extension.static_target if extension.static_target is not None else "",
))
class BackendYwsd(diffsync.DiffSync):
@@ -473,8 +476,8 @@ class Routing:
def run(self):
while True:
msg = self.fp.queues["dect"].get()
self.fp.queues["dect"].task_done()
msg = self.fp.queues["routing"].get()
self.fp.queues["routing"].task_done()
if msg.get("type") == "stop":
break
@@ -482,8 +485,10 @@ class Routing:
logger.info("syncing")
state_fieldpoc = BackendNerd()
state_fieldpoc.load(self.fp.extensions._c)
state_fieldpoc = BackendFieldPOC(self.fp)
extensions = self.fp.extensions.extensions_by_num.copy()
extensions.update(self.fp.temp_extensions)
state_fieldpoc.load(extensions)
state_yate = BackendYwsd()
state_yate.load("postgresql+psycopg2://{}:{}@{}/{}".format(
self.fp.config.database.username,

View File

@@ -12,7 +12,6 @@ logger = logging.getLogger("fieldpoc.run")
ap = argparse.ArgumentParser(prog="fieldpoc")
ap.add_argument("-c", "--config", dest="config_file_path", default="fieldpoc_config.json", help="Path to the fieldpoc config file")
ap.add_argument("-e", "--extensions", dest="extensions_file_path", default="fieldpoc_extensions.json", help="Path to the fieldpoc extensions file")
ap.add_argument('--init', dest="init", action='store_true')
ap.add_argument('--debug', dest="debug", action='store_true')
@@ -33,7 +32,7 @@ def run():
logger.info("prepared signal handling")
fp = fieldpoc.FieldPOC(config_file_path=args.config_file_path, extensions_file_path=args.extensions_file_path)
fp = fieldpoc.FieldPOC(config_file_path=args.config_file_path)
if args.init:
fp.init()

View File

@@ -11,6 +11,21 @@ import yate.asyncio
logger = logging.getLogger("fieldpoc.ywsd")
### BEGIN Monkeypatches
# disable asyncio signal handlers
def add_signal_handler(*args, **kwargs):
raise NotImplementedError("Disable signal handling so we can run ywsd in a thread")
asyncio.unix_events._UnixSelectorEventLoop.add_signal_handler = add_signal_handler
# paython-yate stuff I have no idea why anymore
async def setup_for_tcp(self, host, port):
self.reader, self.writer = await asyncio.open_connection(host, port)
self.send_connect()
yate.asyncio.YateAsync.setup_for_tcp = setup_for_tcp
### END Monkeypatches
class Ywsd:
def __init__(self, fp):
self.fp = fp
@@ -55,17 +70,7 @@ class Ywsd:
def run(self):
# Mokey patch python-yate so I don't have to fork it yet
async def setup_for_tcp(self, host, port):
self.reader, self.writer = await asyncio.open_connection(host, port)
self.send_connect()
yate.asyncio.YateAsync.setup_for_tcp = setup_for_tcp
# Mokey patch ywsd so I don't have to fork it yet
asyncio.set_event_loop(self.event_loop)
def add_signal_handler(*args, **kwargs):
raise NotImplementedError("Disable signal handling so we can run ywsd in a thread")
asyncio.get_event_loop().add_signal_handler = add_signal_handler
logger.info("starting ywsd")
self.app = ywsd.engine.YateRoutingEngine(settings=self.settings(), web_only=False, **self.settings().YATE_CONNECTION)

View File

@@ -1,14 +1,20 @@
{
"extensions": {
"file": "fieldpoc_extensions.json"
},
"controller": {
"host": "127.0.0.1",
"port": 9437
},
"dect": {
"enabled": false,
"host": "10.222.222.11",
"username": "omm",
"password": "xxx"
"password": "xxx",
"sipsecret": "51df84aace052b0e75b8c1da5a6da9e2"
},
"yate": {
"enabled": false,
"host": "127.0.0.1",
"port": 5039
},

View File

@@ -95,14 +95,14 @@
"name": "Temporary Numbers",
"trunk": false,
"dialout_allowed": true,
"type": "static"
"type": "temp"
},
"9999": {
"9997": {
"name": "DECT Claim Extensions",
"type": "static",
"dialout_allowed": false,
"trunk": true,
"static_target": "external/nodata//opt/nerdsync/claim.py"
"static_target": "external/nodata//run/current-system/sw/bin/dect_claim"
}
}
}

48
flake.lock generated Normal file
View File

@@ -0,0 +1,48 @@
{
"nodes": {
"mitel-ommclient2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1687019250,
"narHash": "sha256-cN9ZuQ/1irnoYg013v1ZDn15MHcFXhxILGhRNDGd794=",
"ref": "refs/heads/main",
"rev": "a11629f543a8b43451cecc46600a78cbb6af015a",
"revCount": 70,
"type": "git",
"url": "https://git.clerie.de/clerie/mitel_ommclient2.git"
},
"original": {
"type": "git",
"url": "https://git.clerie.de/clerie/mitel_ommclient2.git"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1665732960,
"narHash": "sha256-WBZ+uSHKFyjvd0w4inbm0cNExYTn8lpYFcHEes8tmec=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4428e23312933a196724da2df7ab78eb5e67a88e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"mitel-ommclient2": "mitel-ommclient2",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

154
flake.nix Normal file
View File

@@ -0,0 +1,154 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
mitel-ommclient2 = {
url = "git+https://git.clerie.de/clerie/mitel_ommclient2.git";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, mitel-ommclient2, ... }: let
fieldpoc_version = "0.11.0";
in {
packages.x86_64-linux = let
pkgs = import nixpkgs {
system = "x86_64-linux";
};
in {
fieldpoc = pkgs.python3Packages.buildPythonPackage {
pname = "fieldpoc";
version = fieldpoc_version;
src = ./.;
format = "pyproject";
buildInputs = [ pkgs.python3Packages.hatchling ];
propagatedBuildInputs = [
mitel-ommclient2.packages.x86_64-linux.mitel-ommclient2
pkgs.python3Packages.sqlalchemy
self.packages.x86_64-linux.ywsd
self.packages.x86_64-linux.diffsync
];
};
ywsd = pkgs.python3Packages.buildPythonApplication rec {
pname = "ywsd";
version = "0.12.2";
src = pkgs.fetchFromGitHub {
owner = "eventphone";
repo = pname;
rev = "v${version}";
sha256 = "sha256-VkL7MhK6Z0Y1Hnnoq0Ukyclk9gfc4u8qav6yNj+LCJg=";
};
propagatedBuildInputs = [
pkgs.python3Packages.aiopg
pkgs.python3Packages.aiohttp
self.packages.x86_64-linux.python-yate
pkgs.python3Packages.pyyaml
pkgs.python3Packages.sqlalchemy
];
doCheck = false;
};
python-yate = pkgs.python3Packages.buildPythonPackage rec {
pname = "python-yate";
version = "0.4.1";
src = pkgs.python3Packages.fetchPypi {
inherit pname version;
sha256 = "sha256-rx0hcmP4SmvCkKXawyiMTKCVpTKTAgZVpbdQFdxV+hs=";
};
propagatedBuildInputs = [
pkgs.python3Packages.aiohttp
pkgs.python3Packages.async-timeout
];
pythonImportsCheck = [ "yate" ];
};
diffsync = pkgs.python3Packages.buildPythonPackage rec {
pname = "diffsync";
version = "1.5.1";
src = pkgs.python3Packages.fetchPypi {
inherit pname version;
sha256 = "84a736d03d385bd07cf7c86f57385d4130c3c3273bf7bc90febe2fa530ee1aa6";
};
propagatedBuildInputs = [
pkgs.python3Packages.pydantic
(pkgs.python3Packages.override {
overrides = (self: super: {
structlog = super.structlog.overrideAttrs (old: rec {
version = "21.5.0";
src = pkgs.fetchFromGitHub {
owner = "hynek";
repo = "structlog";
rev = "refs/tags/${version}";
sha256 = "0bc5lj0732j0hjq89llgrncyzs6k3aaffvg07kr3la44w0hlrb4l";
};
});
});
}).structlog
pkgs.python3Packages.colorama
pkgs.python3Packages.redis
];
pythonImportsCheck = [ "diffsync" ];
};
yate = pkgs.yate.overrideAttrs (old: {
configureFlags = [ "--with-libpq=${pkgs.postgresql.withPackages (ps: [ ])}" ];
});
docs = pkgs.stdenv.mkDerivation {
pname = "fieldpoc-docs";
version = fieldpoc_version;
src = ./.;
buildInputs = [
pkgs.python3.pkgs.mkdocs-material
];
buildPhase = ''
python3 -m mkdocs build
'';
installPhase = ''
mkdir -p $out
cp -r ./site/* $out/
'';
};
};
nixosModules = {
fieldpoc = { ... }: {
imports = [
./nix/modules/dhcp.nix
./nix/modules/fieldpoc.nix
./nix/modules/yate.nix
];
nixpkgs.overlays = [
(_: _: {
inherit (self.packages."x86_64-linux")
fieldpoc
yate;
})
];
};
default = self.nixosModules.fieldpoc;
};
hydraJobs = {
inherit (self)
packages;
};
};
}

27
mkdocs.yml Normal file
View File

@@ -0,0 +1,27 @@
site_name: FieldPOC
theme:
name: material
nav:
- index.md
- Installation:
- install/index.md
- install/hardware.md
- install/nixos.md
- install/network.md
- install/configure.md
- install/rfp.md
- install/omm.md
- Reference:
- reference/command-line-options.md
- reference/configuration.md
- reference/extensions.md
- reference/controller.md
- reference/ywsd.md
- Operation:
- operation/claim-dect-extension.md
- operation/troubleshooting.md
- Extend:
- extension/nerd.md
- extension/multiple-rfps.md

141
nix/modules/dhcp.nix Normal file
View File

@@ -0,0 +1,141 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.services.fieldpoc.dhcp;
in {
options.services.fieldpoc.dhcp = {
enable = mkEnableOption "fieldpoc-dhcp";
interface = mkOption {
type = types.str;
};
subnet = mkOption {
type = types.str;
};
pool = mkOption {
type = types.str;
};
router = mkOption {
type = types.str;
};
dnsServers = mkOption {
type = types.str;
};
omm = mkOption {
type = types.str;
};
reservations = mkOption {
type = with types; listOf (submodule {
options = {
name = mkOption {
type = types.str;
};
macAddress = mkOption {
type = types.str;
};
ipAddress = mkOption {
type = types.str;
};
};
});
default = [];
};
};
config = mkIf cfg.enable {
services.kea.dhcp4 = {
enable = true;
settings = {
interfaces-config = {
interfaces = [ cfg.interface ];
};
option-def = [
{
space = "dhcp4";
name = "vendor-encapsulated-options";
code = 43;
type = "empty";
encapsulate = "sipdect";
}
{
space = "sipdect";
name = "ommip1";
code = 10;
type = "ipv4-address";
}
{
space = "sipdect";
name = "ommip2";
code = 19;
type = "ipv4-address";
}
{
space = "sipdect";
name = "syslogip";
code = 14;
type = "ipv4-address";
}
{
space = "sipdect";
name = "syslogport";
code = 15;
type = "int16";
}
{
space = "dhcp4";
name = "magic_str";
code = 224;
type = "string";
}
];
subnet4 = [
{
subnet = cfg.subnet;
pools = [
{
pool = cfg.pool;
}
];
option-data = [
{
name = "routers";
data = cfg.router;
}
{
name = "domain-name-servers";
data = cfg.dnsServers;
}
{
name = "vendor-encapsulated-options";
}
{
space = "sipdect";
name = "ommip1";
data = cfg.omm;
}
{
name = "magic_str";
data = "OpenMobilitySIP-DECT";
}
];
reservations = map (r: {
hostname = r.name;
hw-address = r.macAddress;
ip-address = r.ipAddress;
option-data = [
{
name = "host-name";
data = r.name;
}
];
}) cfg.reservations;
}
];
};
};
};
}

157
nix/modules/fieldpoc.nix Normal file
View File

@@ -0,0 +1,157 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.fieldpoc;
in {
options = {
services.fieldpoc = {
enable = mkEnableOption "fieldpoc";
ommIp = mkOption {
type = types.str;
};
ommUser = mkOption {
type = types.str;
};
ommPasswordPath = mkOption {
type = types.path;
};
sipsecretPath = mkOption {
type = types.path;
};
};
};
config = mkIf cfg.enable {
environment.systemPackages = with pkgs; [
fieldpoc
];
systemd.services.fieldpoc = {
description = "Fieldpoc daemon";
wantedBy = [ "multi-user.target" ];
requires = [ "network-online.target" "yate.service" ];
after = [ "network-online.target" "yate.service" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.fieldpoc}/bin/fieldpoc -c /run/fieldpoc/config.json --debug";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
User = "fieldpoc";
Group = "fieldpoc";
RuntimeDirectory = "fieldpoc";
RuntimeDirectoryMode = "0755";
ConfigurationDirectory = "fieldpoc";
StateDirectory = "fieldpoc";
StateDirectoryMode = "0700";
};
preStart = let
cfgFile = pkgs.writeText "config.json" (lib.generators.toJSON { } {
extensions = {
file = "/var/lib/fieldpoc/extensions.json";
};
controller = {
host = "127.0.0.1";
port = 9437;
};
dect = {
host = cfg.ommIp;
username = cfg.ommUser;
password = "!!OMMPASSWORD!!";
sipsecret = "!!SIPSECRET!!";
};
yate = {
host = "127.0.0.1";
port = 5039;
};
database = {
hostname = "127.0.0.1";
username = "fieldpoc";
password = "fieldpoc";
database = "fieldpoc";
};
});
in ''
${pkgs.gnused}/bin/sed -e "s/!!OMMPASSWORD!!/$(cat ${cfg.ommPasswordPath})/g" -e "s/!!SIPSECRET!!/$(cat ${cfg.sipsecretPath})/g" ${cfgFile} > /run/fieldpoc/config.json
if [ ! -f "/var/lib/fieldpoc/extensions.json" ]; then
echo '{"extensions": {}}' > /var/lib/fieldpoc/extensions.json
fi
'';
};
users.users.fieldpoc = {
isSystemUser = true;
group = "fieldpoc";
};
users.groups.fieldpoc = { };
services.postgresql = {
enable = true;
initialScript = pkgs.writeText "backend-initScript" ''
CREATE ROLE fieldpoc WITH LOGIN PASSWORD 'fieldpoc' CREATEDB;
CREATE DATABASE fieldpoc;
GRANT ALL PRIVILEGES ON DATABASE fieldpoc TO fieldpoc;
'';
};
services.yate = {
enable = true;
config = {
extmodule = {
"listener ywsd" = {
type = "tcp";
addr = "127.0.0.1";
port = "5039";
};
};
cdrbuild = {
parameters = {
X-Eventphone-Id = "false";
};
};
pgsqldb = {
default = {
host = "localhost";
port = "5432";
database = "fieldpoc";
user = "fieldpoc";
password = "fieldpoc";
};
};
register = {
general = {
expires = 30;
"user.auth" = "yes";
"user.register" = "yes";
"user.unregister" = "yes";
"engine.timer" = "yes";
"call.cdr" = "yes";
"linetracker" = "yes";
};
default = {
priority = 30;
account = "default";
};
"user.auth" = {
query = "SELECT password FROM users WHERE username='\${username}' AND password IS NOT NULL AND password<>'' AND type='user' LIMIT 1;";
result = "password";
};
"user.register".query = "INSERT INTO registrations (username, location, oconnection_id, expires) VALUES ('\${username}', '\${data}', '\${oconnection_id}', NOW() + INTERVAL '\${expires} s') ON CONFLICT ON CONSTRAINT uniq_registrations DO UPDATE SET expires = NOW() + INTERVAL '\${expires} s'";
"user.unregister".query = "DELETE FROM registrations WHERE (username = '\${username}' AND location = '\${data}' AND oconnection_id = '\${connection_id}') OR ('\${username}' = '' AND '\${data}' = '' AND oconnection_id = '\${connection_id}')";
"engine.timer".query = "DELETE FROM registrations WHERE expires<=CURRENT_TIMESTAMP;";
"call.cdr".critial = "no";
"linetracker" = {
critical = "yes";
initquery = "UPDATE users SET inuse=0 WHERE inuse is not NULL;DELETE from active_calls;";
cdr_initialize = "UPDATE users SET inuse=inuse+1 WHERE username='\${external}';INSERT INTO active_calls SELECT username, x_eventphone_id FROM (SELECT '\${external}' as username, '\${X-Eventphone-Id}' as x_eventphone_id, '\${direction}' as direction) as active_call WHERE x_eventphone_id != '' AND x_eventphone_id IS NOT NULL and direction = 'outgoing';";
cdr_finalize = "UPDATE users SET inuse=(CASE WHEN inuse>0 THEN inuse-1 ELSE 0 END) WHERE username='\${external}';DELETE FROM active_calls WHERE username = '\${external}' AND x_eventphone_id = '\${X-Eventphone-Id}' AND '\${direction}' = 'outgoing';";
};
};
};
};
};
}

57
nix/modules/yate.nix Normal file
View File

@@ -0,0 +1,57 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.yate;
in {
options = {
services.yate = {
enable = mkEnableOption "yate";
config = mkOption {
type = with types; attrsOf anything;
default = { };
};
};
};
config = mkIf cfg.enable {
environment.etc = mapAttrs' (name: config: nameValuePair "yate/${name}.conf" { text = (if (isAttrs config) then generators.toINI {} config else config); }) cfg.config;
systemd.services.yate = {
description = "YATE Telephony Server";
wantedBy = [ "multi-user.target" ];
requires = [ "network-online.target" "postgresql.service" ];
after = [ "network-online.target" "postgresql.service" ];
environment = { PWLIB_ASSERT_ACTION = "C"; };
serviceConfig = {
Type = "forking";
ExecStart =
"${pkgs.yate}/bin/yate -d -p /run/yate/yate.pid -c /etc/yate -F -s -vvv -DF -r -l /var/lib/yate/yate.log";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
User = "yate";
Group = "yate";
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
RuntimeDirectory = "yate";
RuntimeDirectoryMode = "0755";
ConfigurationDirectory = "yate";
StateDirectory = "yate";
StateDirectoryMode = "0700";
PIDFile = "/run/yate/yate.pid";
TimeoutSec = 30;
};
reloadTriggers = map (name: config.environment.etc."yate/${name}.conf".source) (attrNames cfg.config);
};
users.users.yate = {
isSystemUser = true;
group = "yate";
};
users.groups.yate = { };
};
}

View File

@@ -20,6 +20,8 @@ classifiers = [
]
dependencies = [
"mitel-ommclient2",
"sqlalchemy",
"ywsd",
]
[project.scripts]