No description
Find a file
2026-06-19 22:31:20 +07:00
cmd/server fix: module name 2026-06-19 20:53:00 +07:00
internal feat: admin role management and POST-based bulk delete 2026-06-19 22:31:20 +07:00
sqlfiles feat: admin role management and POST-based bulk delete 2026-06-19 22:31:20 +07:00
.gitignore initial commit 2026-06-15 22:20:25 +07:00
edlgo.service fix: module name 2026-06-19 20:53:00 +07:00
go.mod fix: module name 2026-06-19 20:53:00 +07:00
go.sum initial commit 2026-06-15 22:20:25 +07:00
openapi.yaml feat: admin role management and POST-based bulk delete 2026-06-19 22:31:20 +07:00
README.md feat: admin role management and POST-based bulk delete 2026-06-19 22:31:20 +07:00
sqlc.yaml initial commit 2026-06-15 22:20:25 +07:00

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 ip or url.
  • 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 read or write on 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:

  1. Create edlgo.db (SQLite, WAL mode) in the working directory.
  2. Run schema migrations.
  3. Seed an admin user 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 102465535.

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, or 198.51.100.0/24. Validation is performed with Go's net/netip on the exact value submitted, so surrounding whitespace is rejected rather than trimmed. (CIDR entries with host bits set, like 198.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_admin flag.
  • groups — one EDL feed; type is ip or url; (name, type) is unique.
  • ips / urls — entries belonging to a group; cascade-deleted with their group; unique per group.
  • group_permissions — per-user read/write ACL on a group (default deny).
  • refresh_tokens — SHA-256 hashes of opaque refresh tokens with expiry.
  • audit_logs — append-only trail of actions; user_id survives user deletion as NULL.

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/edlgo with 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.