- What is this project?
- Quick demo
- HOWTO
- Using Docker (for testing only)
- Using a venv
- Keyset pagination
- API authentication (optional)
A Python REST API on top of the FreeRADIUS database for automation purposes.
- It provides an object-oriented view of the database schema
- It implements some logic to ensure data consistency
It relies on pyfreeradius package which was originally embedded in this project.
I recommend to read the docs of
pyfreeradius
since this project is now essentially a wrapper.

It works with MySQL, MariaDB, PostgreSQL and SQLite using DB-API 2.0 (PEP 249) compliant drivers such as:
mysql-connector-python
psycopg
psycopg2
pymysql
pysqlite3
sqlite3
It may work with other compliant drivers, yet not tested.
Number of results is limited to 100
by default.
curl -X 'GET' http://localhost:8000/nas
#> 200 OK
[
{"nasname": "3.3.3.3", "shortname": "my-super-nas", "secret": "my-super-secret"},
{"nasname": "4.4.4.4", "shortname": "my-other-nas", "secret": "my-other-secret"},
{"nasname": "4.4.4.5", "shortname": "another-nas", "secret": "another-secret"},
]
curl -X 'GET' http://localhost:8000/users
#> 200 OK
[
{
"username": "alice@adsl",
"checks": [{"attribute": "Cleartext-Password", "op": ":=", "value": "alice-pass"}],
"replies": [
{"attribute": "Framed-IP-Address", "op": ":=", "value": "10.0.0.2"},
{"attribute": "Framed-Route", "op": "+=", "value": "192.168.1.0/24"},
{"attribute": "Framed-Route", "op": "+=", "value": "192.168.2.0/24"},
{"attribute": "Huawei-Vpn-Instance", "op": ":=", "value": "alice-vrf"},
],
"groups": [{"groupname": "100m", "priority": 1}],
},
{
"username": "bob",
"checks": [{"attribute": "Cleartext-Password", "op": ":=", "value": "bob-pass"}],
"replies": [
{"attribute": "Framed-IP-Address", "op": ":=", "value": "10.0.0.1"},
{"attribute": "Framed-Route", "op": "+=", "value": "192.168.1.0/24"},
{"attribute": "Framed-Route", "op": "+=", "value": "192.168.2.0/24"},
{"attribute": "Huawei-Vpn-Instance", "op": ":=", "value": "bob-vrf"},
],
"groups": [{"groupname": "100m", "priority": 1}],
},
# other users removed for brevity
# β¦
]
curl -X 'GET' http://localhost:8000/groups
#> 200 OK
[
{
"groupname": "100m",
"checks": [],
"replies": [{"attribute": "Filter-Id", "op": ":=", "value": "100m"}],
"users": [
{"username": "bob", "priority": 1},
{"username": "alice@adsl", "priority": 1},
{"username": "eve", "priority": 1},
],
},
{
"groupname": "200m",
"checks": [],
"replies": [{"attribute": "Filter-Id", "op": ":=", "value": "200m"}],
"users": [{"username": "eve", "priority": 2}],
},
{
"groupname": "250m",
"checks": [],
"replies": [{"attribute": "Filter-Id", "op": ":=", "value": "250m"}],
"users": [],
},
# other groups removed for brevity
# β¦
]
curl -X 'GET' http://localhost:8000/nas/3.3.3.3
#> 200 OK
{
"nasname": "3.3.3.3",
"shortname": "my-super-nas",
"secret": "my-super-secret"
}
curl -X 'GET' http://localhost:8000/users/eve
#> 200 OK
{
"username": "eve",
"checks": [{"attribute": "Cleartext-Password", "op": ":=", "value": "eve-pass"}],
"replies": [
{"attribute": "Framed-IP-Address", "op": ":=", "value": "10.0.0.3"},
{"attribute": "Framed-Route", "op": "+=", "value": "192.168.1.0/24"},
{"attribute": "Framed-Route", "op": "+=", "value": "192.168.2.0/24"},
{"attribute": "Huawei-Vpn-Instance", "op": ":=", "value": "eve-vrf"},
],
"groups": [{"groupname": "100m", "priority": 1}, {"groupname": "200m", "priority": 2}],
}
#> 200 OK
curl -X 'GET' http://localhost:8000/groups/100m
{
"groupname": "100m",
"checks": [],
"replies": [{"attribute": "Filter-Id", "op": ":=", "value": "100m"}],
"users": [
{"username": "bob", "priority": 1},
{"username": "alice@adsl", "priority": 1},
{"username": "eve", "priority": 1},
],
}
curl -X 'POST' \
'http://localhost:8000/nas' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"nasname": "5.5.5.5",
"shortname": "my-nas",
"secret": "my-secret"
}'
#> 201 Created
curl -X 'POST' \
'http://localhost:8000/users' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"username": "my-user@my-realm",
"checks": [{"attribute": "Cleartext-Password", "op": ":=", "value": "my-pass"}],
"replies": [
{"attribute": "Framed-IP-Address", "op": ":=", "value": "192.168.0.1"},
{"attribute": "Huawei-Vpn-Instance","op": ":=", "value": "my-vrf"}
]
}'
#> 201 Created
curl -X 'POST' \
'http://localhost:8000/groups' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"groupname": "300m",
"replies": [
{"attribute": "Filter-Id", "op": ":=", "value": "300m"}
]
}'
#> 201 Created
The update strategy follows RFC 7396 (JSON Merge Patch) guidelines:
- omitted fields during the update are not modified
None
value means removal (i.e., resets a field to its default value)- a list field can only be overwritten (replaced)
As a consequence of the last point, to add attributes to an existing user (or a group), you must fetch the existing attributes first, combine them with the new ones, and send the result as the update parameter.
curl -X 'PATCH' \
'http://localhost:8000/nas/5.5.5.5' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"secret": "new-secret",
"shortname": "new-shortname"
}'
#> 200 OK
curl -X 'PATCH' \
'http://localhost:8000/users/my-user%40my-realm' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"checks": [{"attribute": "Cleartext-Password", "op": ":=", "value": "new-pass"}],
"replies": [
{"attribute": "Framed-IP-Address", "op": ":=", "value": "192.168.0.1"},
{"attribute": "Framed-Route", "op": "+=", "value": "192.168.1.0/24"},
{"attribute": "Huawei-Vpn-Instance","op": ":=", "value": "my-vrf"}
]
}'
#> 200 OK
curl -X 'PATCH' \
'http://localhost:8000/groups/300m' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"replies": [
{"attribute": "Filter-Id", "op": ":=", "value": "300m-premium"}
]
}'
#> 200 OK
curl -X 'DELETE' http://localhost:8000/nas/5.5.5.5
#> 204 No Content
curl -X 'DELETE' http://localhost:8000/users/my-user@my-realm
#> 204 No Content
curl -X 'DELETE' http://localhost:8000/groups/300m
#> 204 No Content
Reminder:
- When a user is deleted, so are its attributes and its belonging to groups (read more)
- When a group is deleted, so are its attributes and its belonging to users (read more)
An instance of the FreeRADIUS server is NOT needed for testing. The focus is on the FreeRADIUS database. As long as you have one, the API can run on a Python environment.
I made a full Docker stack for testing purposes that should run "as is". It includes the API and a MySQL database with some initial data π
If you already have a FreeRADIUS database (either local or remote) or if you fear Docker, you can skip this section π
wget https://github.com/angely-dev/freeradius-api/archive/refs/heads/master.zip
unzip master.zip
cd freeradius-api-master/docker
#
docker compose up -d
Docker output should be like this:
Creating network "docker_default" with the default driver
Creating volume "docker_myvol" with default driver
Pulling mydb (mysql:)...
[β¦]
Building radapi
[β¦]
Pulling myadmin (phpmyadmin:)...
[β¦]
Creating docker_mydb_1 ... done
Creating docker_myadmin_1 ... done
Creating docker_radapi_1 ... done
Then go to: http://localhost:8000/docs
- Get the project and set the venv:
wget https://github.com/angely-dev/freeradius-api/archive/refs/heads/master.zip
unzip master.zip
cd freeradius-api-master
#
python3 -m venv venv
source venv/bin/activate
- Edit
requirements.txt
to set the DB driver depending on your database system (MySQL, PostgreSQL, etc.):
# Uncomment the appropriate line matching the DB-API 2.0 (PEP 249) compliant driver to use
mysql-connector-python
#psycopg
#psycopg2
#pymysql
#pysqlite3
#sqlite3
- Then install the requirements:
pip install -r requirements.txt
- Edit
src/settings.py
to set your DB settings (driver, connection and table names):
# Uncomment the appropriate line matching the DB-API 2.0 (PEP 249) compliant driver to use
DB_DRIVER = "mysql.connector"
#DB_DRIVER = "psycopg"
#DB_DRIVER = "psycopg2"
#DB_DRIVER = "pymysql"
#DB_DRIVER = "pysqlite3"
#DB_DRIVER = "sqlite3"
# Database connection settings
DB_NAME = "raddb"
DB_USER = "raduser"
DB_PASS = "radpass"
DB_HOST = "mydb"
# Database table names
RAD_TABLES = RadTables(
radcheck="radcheck",
radreply="radreply",
radgroupcheck="radgroupcheck",
radgroupreply="radgroupreply",
radusergroup="radusergroup",
nas="nas",
)
- You can also customize the number of results per page:
# Number of results per page for pagination
ITEMS_PER_PAGE = 100
- Finally, you may want to configure the API URL (especially in production):
# API_URL will be used to set the "Location" header field
# after a resource has been created (POST) as per RFC 7231
# and the "Link" header field (pagination) as per RFC 8288
API_URL = "http://localhost:8000"
- That's it! Now run the API and play with it live! All thanks to FastAPI generating the OpenAPI specs which is rendered by Swagger UI π
$ cd src
$ uvicorn api:app
INFO: Started server process [12884]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
- Then go to: http://localhost:8000/docs

As of v1.3.0, results are paginated (fetching all results at once is generally not needed nor recommended). There are two common options for pagination:
- Offset pagination (aka
LIMIT
+OFFSET
) β not implemented - Keyset pagination (aka
WHERE
+LIMIT
) β implemented
In the era of infinite scroll, the latter is generally preferred over the former. Not only is it better at performance but also simpler to implement. Read more about the implementation details.
Pagination is done through HTTP response headers (as per RFC 8288) rather than JSON metadata. This is debatable but I prefer the returned JSON to contain only business data. Actually, this is what GitHub API does.
Here is an example for fetching usernames (the same applies for groupnames and nasnames):
$ curl -X 'GET' -i http://localhost:8000/users
HTTP/1.1 200 OK
date: Mon, 05 Jun 2023 20:07:09 GMT
server: uvicorn
content-length: 121
content-type: application/json
link: <http://localhost:8000/users?from_username=acb>; rel="next" # notice the Link header
["aaa","aab","aac","aad","aae","aaf","aag","aah","aai","aba","abb","abc","abd","abe","abf","abg","abh","abi","aca","acb"]
The API consumer (e.g., a frontend app) can then scroll to the next page /users?from_username=acb
:
$ curl -X 'GET' -i http://localhost:8000/users?from_username=acb
HTTP/1.1 200 OK
date: Mon, 05 Jun 2023 20:07:43 GMT
server: uvicorn
content-length: 121
content-type: application/json
link: <http://localhost:8000/users?from_username=aed>; rel="next" # notice the Link header
["acc","acd","ace","acf","acg","ach","aci","ada","adb","adc","add","ade","adf","adg","adh","adi","aea","aeb","aec","aed"]
And so on until there are no more results to fetch:
$ curl -X 'GET' -i http://localhost:8000/users?from_username=zzz
HTTP/1.1 200 OK
date: Mon, 05 Jun 2023 20:08:06 GMT
server: uvicorn
content-length: 2
content-type: application/json
# no more Link header
Only
rel="next"
is implemented since there wasn't a need yet forrel="prev|last|first"
.
You may want to add authentication to the API.
A simple solution is through API key. Only two steps are required this way:
- Create
src/auth.py
:
from fastapi import Depends, HTTPException
from fastapi.security.api_key import APIKeyHeader
_x_api_key = 'my-valid-key'
_x_api_key_header = APIKeyHeader(name='X-API-Key')
async def verify_key(x_api_key: str = Depends(_x_api_key_header)):
if x_api_key != _x_api_key:
raise HTTPException(401, 'Invalid key')
- Apply following changes in
src/api.py
:
# top of the file
+from auth import verify_key
+from fastapi import Depends
# bottom of the file
-app = FastAPI(title='FreeRADIUS REST API')
+app = FastAPI(title='FreeRADIUS REST API', dependencies=[Depends(verify_key)])
That's it! All endpoints now require authentication.
In the above code, we make use of both global dependencies and security features of FastAPI. API key is not properly documented yet but the issue fastapi/fastapi#142 provides some working snippets.
The key would not normally go "in the clear" in the code like this. Depending on your setup, it would be passed using CI/CD variables for example.
$ curl -X 'GET' -i http://localhost:8000/users
HTTP/1.1 403 Forbidden
{"detail":"Not authenticated"}
$ curl -X 'GET' -H 'X-API-Key: an-invalid-key' -i http://localhost:8000/users
HTTP/1.1 401 Unauthorized
{"detail":"Invalid key"}
$ curl -X 'GET' -H 'X-API-Key: my-valid-key' -i http://localhost:8000/users
HTTP/1.1 200 OK
["bob","alice@adsl","eve","oscar@wil.de"]
Because the API key security scheme is part of the OpenAPI specification, the need for authentication gets specified on the Web UI π
- You will now notice an
Authorize
button at the top-right corner as well as a little lock on each endpoint:
- To play with the API, you first need to provide the key by clicking that
Authorize
button:
- Alternatively, on the Redoc Web UI:
FastAPI supports multiple security schemes, including OAuth2, API key and others. OAuth2 is a vast subject and will not be treated here. API key is a simple mechanism you probably already used in some projects. The Swagger doc explains it very well:
An API key is a token that a client provides when making API calls. The key can be sent in the query string or as a request header or as a cookie. API keys are supposed to be a secret that only the client and server know. Like Basic authentication, API key-based authentication is only considered secure if used together with other security mechanisms such as HTTPS/SSL.
A request header is quite common. Some examples:
X-API-Key: <TOKEN>
(as per the Swagger doc and the one I used in the solution)X-Auth-Token: <TOKEN>
(e.g., LibreNMS)Authorization: Token <TOKEN>
(e.g., NetBox and DjangoREST-based projects more generally)Authorization: Bearer <TOKEN>
(in accordance with RFC 6750 of the OAuth2 framework)
The
X-
prefix denotes a non-standard HTTP header. You can set whatever name you prefer after thatX-
. The associated semantic is up to you. Some interesting background about theX-
convention can be found in RFC 6648 which, by the way, officially deprecates it.
Although
Authorization: Token
seems more standard as it follows RFC 2617 syntax, note theToken
authentication scheme is not part of the IANA HTTP Authentication Scheme Registry so it is still custom in a sense. The only true standard header isAuthorization: Bearer
introduced by the OAuth2 framework.