- Go 100%
| cmd/server | ||
| internal | ||
| sqlfiles | ||
| .gitignore | ||
| edlgo.service | ||
| go.mod | ||
| go.sum | ||
| openapi.yaml | ||
| README.md | ||
| sqlc.yaml | ||
edlgo
A small, self-hosted External Dynamic List (EDL) manager for Palo Alto Networks (PAN-OS) firewalls.
PAN-OS can subscribe to plain-text lists of IP addresses or URLs fetched over HTTP and use them in security policy. edlgo lets you curate those lists through a JSON API and serves each one at a stable URL that your firewall polls. It is a single Go binary with an embedded SQLite database — no external services to run.
Features
- EDL feeds served as plain text, one entry per line, ready for a PAN-OS EDL object.
- Groups — each group is one feed, typed as either
iporurl. - JWT auth with short-lived access tokens and rotating refresh tokens (argon2id password hashing).
- Per-group access control — admins manage everything; non-admin users get
readorwriteon individual groups (default deny). - Audit log — create/update/delete actions are recorded in an append-only table.
- Single binary — embedded schema migrations (goose) and pure-Go SQLite (
modernc.org/sqlite), so there's no CGO and no separate database to provision.
Requirements
- Go 1.26+
- No CGO toolchain required (pure-Go SQLite driver).
Quick start
# from the repo root
go run ./cmd/server
On first start the server will:
- Create
edlgo.db(SQLite, WAL mode) in the working directory. - Run schema migrations.
- Seed an
adminuser if one doesn't already exist.
It listens on :8080 by default.
Configuration
| Source | Name | Default | Purpose |
|---|---|---|---|
| Env | JWT_SECRET |
random ephemeral | HMAC secret for signing access tokens. Set this in production — if unset, a random secret is generated at startup and all sessions are invalidated on restart. |
| Env | ADMIN_PASSWORD |
admin |
Password for the seeded admin user (used only when the user is first created). |
| Flag | -port |
8080 |
Listen port. Must be in the unprivileged range 1024–65535. |
The database path is fixed at edlgo.db relative to the working directory.
JWT_SECRET="$(openssl rand -base64 32)" ADMIN_PASSWORD="change-me" go run ./cmd/server -port 9090
API
All /api/* routes require a Bearer access token. Admin-only routes additionally require the user to be an admin.
Public — consumed by the firewall
| Method | Path | Description |
|---|---|---|
GET |
/edl/:name/ips.txt |
Plain-text IP list for the group named :name. |
GET |
/edl/:name/urls.txt |
Plain-text URL list for the group named :name. |
Point a PAN-OS EDL object at, e.g., http://your-host:8080/edl/blocklist/ips.txt.
Auth
| Method | Path | Body | Description |
|---|---|---|---|
POST |
/auth/login |
{username, password} |
Returns {access_token, refresh_token, expires_in}. |
POST |
/auth/refresh |
{refresh_token} |
Rotates the refresh token and returns a new token pair. |
POST |
/auth/logout |
{refresh_token} |
Revokes the refresh token (idempotent). |
POST |
/auth/password/change |
{password} |
Changes the current user's password. |
Access tokens are valid for 15 minutes; refresh tokens for 7 days.
Groups, entries, users (authenticated)
| Method | Path | Access | Description |
|---|---|---|---|
GET |
/api/groups/ |
user | List groups the user can access. |
GET |
/api/groups/:id |
user | Get a single group. |
POST |
/api/groups/ |
admin | Create a group ({name, type}). |
PUT |
/api/groups/:id |
admin | Rename a group. |
DELETE |
/api/groups/:id |
admin | Delete a group (cascades to its entries). |
GET |
/api/groups/:id/ips |
user | List IP entries in a group. |
GET |
/api/groups/:id/urls |
user | List URL entries in a group. |
POST |
/api/ips/ |
user (write) | Add an IP entry ({group_id, address, comment}). |
POST |
/api/ips/bulk |
user (write) | Add many IP entries at once (see below). |
DELETE |
/api/ips/:id |
user (write) | Remove an IP entry. |
POST |
/api/ips/bulk-delete |
user (write) | Remove many IP entries at once ({ids: [...]}, see below). |
POST |
/api/urls/ |
user (write) | Add a URL entry. |
POST |
/api/urls/bulk |
user (write) | Add many URL entries at once (see below). |
DELETE |
/api/urls/:id |
user (write) | Remove a URL entry. |
POST |
/api/urls/bulk-delete |
user (write) | Remove many URL entries at once ({ids: [...]}, see below). |
GET |
/api/user/ |
user | List users. |
GET |
/api/user/:id |
user | Get a user. |
POST |
/api/user/ |
admin | Create a user ({username, password, name, is_admin}). |
PUT |
/api/user/:id |
admin | Update a user. |
PATCH |
/api/user/:id/admin |
admin | Grant or revoke a user's admin flag ({is_admin}). |
DELETE |
/api/user/:id |
admin | Delete a user. |
Example
# Log in
curl -s localhost:8080/auth/login \
-d '{"username":"admin","password":"admin"}' | tee /tmp/tok.json
TOKEN=$(jq -r .access_token /tmp/tok.json)
# Create an IP group and add an entry
GID=$(curl -s localhost:8080/api/groups/ -H "Authorization: Bearer $TOKEN" \
-d '{"name":"blocklist","type":"ip"}' | jq -r .group.id)
curl -s localhost:8080/api/ips/ -H "Authorization: Bearer $TOKEN" \
-d "{\"group_id\":$GID,\"address\":\"203.0.113.7\",\"comment\":\"bad host\"}"
# The firewall feed
curl -s localhost:8080/edl/blocklist/ips.txt
Input validation
Entry values are validated before they are stored:
- IP entries (
address) must be a single IP address (IPv4 or IPv6) or an IP/subnet in CIDR notation — e.g.203.0.113.7,2001:db8::1, or198.51.100.0/24. Validation is performed with Go'snet/netipon the exact value submitted, so surrounding whitespace is rejected rather than trimmed. (CIDR entries with host bits set, like198.51.100.7/24, are accepted, matching PAN-OS.) - URL entries (
url) must be non-empty and contain no whitespace or control characters. PAN-OS URL lists use bare host/path patterns without a scheme (e.g.evil.example.com/path), so no scheme or full-URL grammar is enforced.
On the single-entry endpoints an invalid value returns 400 Bad Request. On the bulk endpoints an invalid value is reported in the failed list (see below) and does not affect the other entries.
Bulk entry creation
POST /api/ips/bulk and POST /api/urls/bulk add many entries to a single group in one request. The body carries one shared group_id and a data array:
// POST /api/ips/bulk
{
"group_id": 1,
"data": [
{"address": "203.0.113.7", "comment": "bad host"},
{"address": "198.51.100.0/24", "comment": "bad net"}
]
}
// POST /api/urls/bulk — same shape, using "url" instead of "address"
{
"group_id": 2,
"data": [{"url": "evil.example.com/path", "comment": ""}]
}
Entries are inserted one-by-one, not in a transaction — this is partial-success by design. A failing entry (an invalid value, or a duplicate address/url in that group) is reported, and the remaining entries are still inserted. The response always returns 201 Created with the successfully created entries plus a failed list:
{
"ips": [ /* created Ip rows */ ],
"failed": [
{"address": "203.0.113.7", "error": "UNIQUE constraint failed: ips.group_id, ips.address"}
]
}
The URL response uses "urls" instead of "ips", and each failed item carries url instead of address. An empty data array returns 400, and an unknown group_id returns 404.
Bulk entry deletion
POST /api/ips/bulk-delete and POST /api/urls/bulk-delete remove many entries in one request. They use POST rather than DELETE because the request carries a body, whose semantics are undefined for DELETE. The body carries a list of entry IDs:
// POST /api/ips/bulk-delete (or /api/urls/bulk-delete — same shape)
{
"ids": [1, 2, 3]
}
Like bulk creation, deletes happen one-by-one and are partial-success. Each ID is scoped to the caller's write permissions; an ID that is missing, or belongs to a group the user cannot write, is reported as not found rather than revealing whether it exists. The response returns 200 OK with the IDs that were deleted plus a failed list:
{
"deleted": [1, 3],
"failed": [
{"id": 2, "error": "not found"}
]
}
An empty ids array returns 400.
Admin management
New users are created with the is_admin flag from the request body, so an admin can create other admins by setting "is_admin": true on POST /api/user/. To change an existing user's admin status, use PATCH /api/user/:id/admin with an explicit {"is_admin": true|false} (the field is required). Demoting the last remaining admin is refused with 409 Conflict to avoid locking everyone out.
Data model
- users — credentials (argon2id hash) and
is_adminflag. - groups — one EDL feed;
typeisiporurl;(name, type)is unique. - ips / urls — entries belonging to a group; cascade-deleted with their group; unique per group.
- group_permissions — per-user
read/writeACL on a group (default deny). - refresh_tokens — SHA-256 hashes of opaque refresh tokens with expiry.
- audit_logs — append-only trail of actions;
user_idsurvives user deletion asNULL.
Schema migrations live in sqlfiles/schema/ and are embedded into the binary; queries in sqlfiles/queries/ are compiled to type-safe Go with sqlc (see sqlc.yaml) into internal/store/.
Project layout
cmd/server/ entrypoint, DB setup, migrations, seeding
internal/config/ configuration loading and log formatting
internal/router/ route definitions and middleware wiring
internal/handler/ HTTP handlers, auth/admin middleware
internal/store/ sqlc-generated query code
sqlfiles/ embedded migrations (schema/) and sqlc queries (queries/)
Build & deploy
go build -o edlgo ./cmd/server
A sample systemd unit is provided in edlgo.service (hardened: runs as a dedicated edlgo user, ProtectSystem=strict, no capabilities). It expects:
- the binary at
/opt/edlgo/edlgowith working directory/opt/edlgo, - environment (e.g.
JWT_SECRET,ADMIN_PASSWORD) in/etc/edlgo/edlgo.env.
sudo install -o edlgo -g edlgo edlgo /opt/edlgo/edlgo
sudo cp edlgo.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now edlgo
Notes
- SQLite is opened with a single connection (
max 1) and WAL mode: one writer, concurrent readers — fine for the expected EDL workload. - Serve behind TLS (a reverse proxy is the simplest option) so tokens and the firewall feeds aren't exposed in cleartext.