Postfix-Submission policy server that checks sender IPs and blocks senders, if they come from too many countries or if they come from too many IP addresses.
- GeoIP policy service for Postfix
- Custom settings to define different limits for IPs and countries per sender
- LDAP support (optional)
- REST interface to interact with the service on the fly
- Actions
- Prometheus metrics and OpenTelemetry export (optional)
The service is configured in Postfix like this...
smtpd_sender_restrictions =
...
check_policy_service inet:127.0.0.1:4646
...
... if you use the docker-compose.yml file as provided.
Back to table of contents
You can specify custom settings, which must be written in valid JSON. The format is:
{
"data": [
{
"comment": "Whatever comment you like...",
"sender": "localpart@domain.tld",
"ips": NUMBER,
"countries": NUMBER
},
...
]
}
It is possible to only specify ips or countries. The missing parameter will be set to its default. Furthermore, the data structure is read one by one and the rules are evaluated as first match wins. By redefining a sender more than once, only the first will be used.
Back to table of contents
Builds require Go 1.27.1 or a newer patched Go 1.27 toolchain. Module metadata,
CI, release packaging, and the container builder use Go 1.27.1. Dependencies
are vendored; after updates, run go mod tidy, go mod vendor, and
make guardrails. Run make smoke-observability to verify the local HTTP,
Redis, Prometheus, and OTLP integration after dependency updates.
The Docker image uses Alpine 3.24 and runs as UID/GID 10001:10001.
Mounted GeoIP databases, custom settings, TLS keys, CA files, and action
templates must be readable by that identity, with searchable parent directories.
Grant access narrowly, especially for private keys. The systemd package
continues to use its existing DynamicUser identity.
Buildx selects the target architecture for both bundled executables. The OCI
export below requires a Buildx builder with the docker-container driver:
docker buildx build --platform linux/amd64,linux/arm64 --output type=oci,dest=/tmp/geoip-policyd.tar .There is no built-in container health check. Operators must configure checks for their enabled listeners, addresses, TLS, and authentication settings; either listener can be disabled. A process-only check does not verify policy admission or Redis/LDAP availability. Use the endpoint test client below only against a dedicated test instance when exercising mutating endpoints.
The simplest way to use the program is by using a docker image. You can build your own, as the default repository is not public for other people.
cd /path/to/Dockerfile
docker build -t geoip-policyd:latest .You need to change the docker-compose.yml file as well. If you prefer, you can add a Redis service and run the * geoip-policyd* container in bridged mode.
For a complete example see here
Back to table of contents
geoip-policyd reads country data from a local MMDB file. Two providers are supported; the file is never bundled with the binary, Docker image, or package, so operators download and update it themselves.
| Provider | Typical file | Record layout used | License |
|---|---|---|---|
| MaxMind | GeoLite2-City.mmdb, GeoLite2-Country.mmdb, GeoIP2-* |
country.iso_code |
GeoLite2 EULA |
| IPinfo Lite | ipinfo_lite.mmdb |
country_code |
CC BY-SA 4.0 (attribution) |
Any other database with a MaxMind-compatible country.iso_code layout, such as
DB-IP Country Lite, uses the MaxMind schema.
--geoip-provider (GEOIPPOLICYD_GEOIP_PROVIDER) selects how records are
decoded:
auto(default) reads thedatabase_typemetadata. IPinfo Lite databases use the IPinfo schema, other IPinfo products (for example the legacycountry_asn.mmdb) are rejected, and everything else uses the MaxMind schema.maxmindoripinfoenforce one schema.
Every load verifies the schema by decoding the first networks of the file. A
database without usable country codes, such as a GeoLite2-ASN file or a file
that does not match the enforced provider, is rejected with a schema_mismatch
error instead of silently returning empty country codes.
On startup, a load failure terminates the process. On reload, either through the
five-minute file modification check or GET /reload, a failure keeps the active
database in service, increments geoip_policyd_geoip_reloads_total with
result="error" or result="schema_mismatch", and the file check retries on the
next tick. The initial load at startup is not counted as a reload. Each successful load logs the provider, database type, and build time,
and warns when the build is older than 30 days.
Create a free GeoLite account at https://www.maxmind.com/en/geolite2/signup and
keep the database current with MaxMind's
geoipupdate, which replaces files
atomically. Configure EditionIDs GeoLite2-City (or GeoLite2-Country) and point
GEOIPPOLICYD_GEOIP_PATH at the resulting file, by default
/usr/share/GeoIP/GeoLite2-City.mmdb.
Create a free account at https://ipinfo.io/lite to obtain a download token.
contrib/update-ipinfo-lite.sh downloads the database and replaces the target
atomically:
IPINFO_TOKEN=... contrib/update-ipinfo-lite.sh /usr/share/GeoIP/ipinfo_lite.mmdb| Setting | Default |
|---|---|
| target argument | /usr/share/GeoIP/ipinfo_lite.mmdb |
IPINFO_TOKEN |
required |
IPINFO_URL |
https://ipinfo.io/data/ipinfo_lite.mmdb |
IPINFO_MIN_SIZE |
1048576 bytes; smaller downloads are refused |
Downloads time out after 10 minutes and are retried up to three times. Files without the MMDB metadata marker, such as HTML error pages, are refused.
The script writes a temporary file in the target directory, checks its size,
sets mode 0644, and moves it over the target. It needs write access to the
target directory and passes the token to curl via standard input, so the token
does not show up in the process list. Run it from cron or a systemd timer; IPinfo
refreshes the data daily. geoip-policyd picks up the new file automatically.
Then point geoip-policyd at the file:
GEOIPPOLICYD_GEOIP_PATH="/usr/share/GeoIP/ipinfo_lite.mmdb"
GEOIPPOLICYD_GEOIP_PROVIDER="ipinfo" # optional, auto detects it as wellIPinfo Lite data is licensed under CC BY-SA 4.0. If you publish or pass on data derived from it, credit IPinfo, for example with "IP address data powered by IPinfo". geoip-policyd itself does not redistribute the data.
Back to table of contents
geoip-policyd server --helpproduces the following output:
...
Arguments:
-h --help Print help information
-a --server-address IPv4 or IPv6 address for the policy service. Default: 127.0.0.1
-p --server-port Port for the policy service. Default: 4646
--disable-policy-service Do not start the policy TCP service. Default: false
--disable-http-service Do not start the HTTP service. Default: false
--http-address HTTP address for incoming requests. Default: 127.0.0.1
--http-port HTTP port for incoming requests. Default 8080
--sasl-username Use 'sasl_username' instead of the 'sender' attribute. Default: false
-A --redis-address IPv4 or IPv6 address for the Redis service. Default: 127.0.0.1
-P --redis-port Port for the Redis service. Default: 6379
--redis-username Redis username. Default:
--redis-password Redis password. Default:
--redis-replica-address IPv4 or IPv6 address for a Redis service (replica). Default: 127.0.0.1
--redis-replica-port Port for a Redis service (replica). Default: 6379
--redis-sentinels List of space seperated sentinel servers. Default:
--redis-sentinel-master-name Sentinel master name. Default:
--redis-sentinel-username Redis sentinel username. Default:
--redis-sentinel-password Redis sentinel password. Defailt:
--redis-prefix Redis prefix. Default: geopol_
--redis-database-number Redis database number. Default: 0
--redis-ttl Redis TTL in seconds. Default: 3600
-g --geoip-path Full path to the GeoIP database file. Default: /usr/share/GeoIP/GeoLite2-City.mmdb
--geoip-provider GeoIP database provider: auto, maxmind or ipinfo. Default: auto
--max-countries Maximum number of countries before rejecting e-mails. Default: 3
--max-ips Maximum number of IP addresses before rejecting e-mails. Default: 10
--home-countries List of known home country codes. Default:
--max-home-countries Maximum number of home countries before rejecting e-mails. Default: 3
--max-home-ips Maximum number of home IP addresses before rejecting e-mails. Default: 10
--block-permanent Do not expire senders from Redis, if they were blocked in the past. Default: false
--force-user-known Senders are already known by an upstream service. Default: false
-c --custom-settings-path Custom settings with different IP and country limits. Default:
--http-use-basic-auth Enable basic HTTP auth. Default: false
--http-use-ssl Enable HTTPS. Default: false
--http-basic-auth-username HTTP basic auth username. Default:
--http-basic-auth-password HTTP basic auth password. Default:
--http-tls-cert HTTP TLS server certificate (full chain). Default: /localhost.pem
--http-tls-key HTTP TLS server key. Default: /localhost-key.pem
--prometheus-enabled Enable Prometheus metrics on the HTTP service. Default: false
--prometheus-path HTTP path for Prometheus metrics. Default: /metrics
--prometheus-runtime-metrics Include Go runtime and process metrics. Default: false
--otel-enabled Enable OpenTelemetry export. Default: false
--otel-traces-enabled Export OpenTelemetry traces when OTel is enabled. Default: false
--otel-metrics-enabled Export OpenTelemetry metrics when OTel is enabled. Default: false
--otel-service-name OpenTelemetry service.name resource attribute. Default: geoip-policyd
--otel-service-version OpenTelemetry service.version resource attribute. Default: current binary version
--otel-exporter-otlp-endpoint OTLP HTTP endpoint as host:port or base URL. Default:
--otel-exporter-otlp-headers Comma-separated OTLP HTTP headers as key=value pairs. Default:
--otel-exporter-otlp-insecure Use insecure OTLP HTTP transport. Default: false
--otel-sample-ratio OpenTelemetry trace sampling ratio between 0.0 and 1.0. Default: 1.0
--use-ldap Enable LDAP support. Default: false
--ldap-server-uri Server URI. Specify multiple times, if you need more than one server. Default: [ldap://127.0.0.1:389/]
--ldap-basedn Base DN. Default:
--ldap-binddn Bind DN. Default:
--ldap-bindpw Bind password. Default:
--ldap-filter Filter with %s placeholder. Default: (&(objectClass=*)(mailAlias=%s))
--ldap-result-attribute Result attribute for the requested mail sender. Default: mailAccount
--ldap-starttls If this option is given, use StartTLS. Default: false
--ldap-skip-tls-verify Skip TLS server name verification. Default: false
--ldap-tls-cafile File containing TLS CA certificate(s). Default:
--ldap-tls-client-cert File containing a TLS client certificate. Default:
--ldap-tls-client-key File containing a TLS client key. Default:
--ldap-sasl-external Use SASL/EXTERNAL instead of a simple bind. Default: false
--ldap-scope LDAP search scope [base, one, sub]. Default: sub
--ldap-idle-pool-size LDAP pre-forked (idle) pool size. Default 3
--ldap-pool-size LDAP max pool size. Default: 10
--run-actions Run actions, if a sender is over limits. Default: false
--run-action-operator Run the operator action. Default: false
--operator-to E-Mail To-header for the operator action. Default:
--operator-from E-Mail From-header for the operator action. Default:
--operator-subject E-Mail Subject-header for the operator action. Default: [geoip-policyd] An e-mail account was compromised
--operator-message-ct E-Mail Content-Type-header for the operator action. Default: text/plain
--operator-message-path Full path to the e-mail message file for the operator action. Default:
--mail-server-address E-mail server address for notifications. Default:
--mail-server-port E-mail server port number. Default:
--mail-helo E-mail server HELO/EHLO hostname. Default: localhost
--mail-port E-mail server port number. Default: 587
--mail-username E-mail server username. Default:
--mail-password E-mail server password. Default:
--mail-ssl-on-connect Use TLS on connect for the e-mail server. Default: false
-v --verbose Verbose mode. Repeat this for an increased log level
--version Current version
Back to table of contents
The following environment variables can be used to configure the policy service. This is especially useful, if you plan on running the service as a docker service.
| Variable | Description |
|---|---|
| GEOIPPOLICYD_DISABLE_POLICY_SERVICE | Do not start the policy TCP service; default(false) |
| GEOIPPOLICYD_SERVER_ADDRESS | IPv4 or IPv6 address for the policy service; default(127.0.0.1) |
| GEOIPPOLICYD_SERVER_PORT | Port for the policy service; default(4646) |
| GEOIPPOLICYD_DISABLE_HTTP_SERVICE | Do not start the HTTP service; default(false) |
| GEOIPPOLICYD_HTTP_ADDRESS | HTTP address for incoming requests; default(127.0.0.1:8080) |
| GEOIPPOLICYD_HTTP_PORT | HTTP port for incoming requests; default(8080) |
| GEOIPPOLICYD_USE_SASL_USERNAME | Use 'sasl_username' instead of the 'sender' attribute; default(false) |
| GEOIPPOLICYD_REDIS_ADDRESS | IPv4 or IPv6 address for the Redis service; default(127.0.0.1) |
| GEOIPPOLICYD_REDIS_PORT | Port for the Redis service; default(6379) |
| GEOIPPOLICYD_REDIS_USERNAME | Redis username |
| GEOIPPOLICYD_REDIS_PASSWORD | Redis password |
| GEOIPPOLICYD_REDIS_REPLICA_ADDRESS | IPv4 or IPv6 address for a Redis service (replica) |
| GEOIPPOLICYD_REDIS_REPLICA_PORT | Port for a Redis service (replica) |
| GEOIPPOLICYD_REDIS_SENTINELS | List of space seperated sentinel servers |
| GEOIPPOLICYD_REDIS_SENTINEL_MASTER_NAME | Sentinel master name |
| GEOIPPOLICYD_REDIS_SENTINEL_USERNAME | Redis sentinel username |
| GEOIPPOLICYD_REDIS_SENTINEL_PASSWORD | Redis sentinel password |
| GEOIPPOLICYD_REDIS_PREFIX | Redis prefix; default(geopol_) |
| GEOIPPOLICYD_REDIS_DATABASE_NUMBER | Redis database number |
| GEOIPPOLICYD_REDIS_TTL | Redis TTL; default(3600) |
| GEOIPPOLICYD_GEOIP_PATH | Full path to the GeoIP database file; default(/usr/share/GeoIP/GeoLite2-City.mmdb) |
| GEOIPPOLICYD_GEOIP_PROVIDER | GeoIP database provider: auto, maxmind or ipinfo; default(auto) |
| GEOIPPOLICYD_MAX_COUNTRIES | Maximum number of countries before rejecting e-mails; default(3) |
| GEOIPPOLICYD_MAX_IPS | Maximum number of IP addresses before rejecting e-mails; default(10) |
| GEOIPPOLICYD_HOME_COUNTRIES | List of known home country codes |
| GEOIPPOLICYD_MAX_HOME_COUNTRIES | Maximum number of home countries before rejecting e-mails; default(3) |
| GEOIPPOLICYD_MAX_HOME_IPS | Maximum number of home IP addresses before rejecting e-mails; default(10) |
| GEOIPPOLICYD_BLOCK_PERMANENT | Do not expire senders from Redis, if they were blocked in the past |
| GEOIPPOLICYD_CUSTOM_SETTINGS_PATH | Custom settings with different IP and country limits |
| GEOIPPOLICYD_HTTP_USE_BASIC_AUTH | Enable basic HTTP auth; default(false) |
| GEOIPPOLICYD_HTTP_USE_SSL | Enable HTTPS; default(false) |
| GEOIPPOLICYD_HTTP_BASIC_AUTH_USERNAME | HTTP basic auth username |
| GEOIPPOLICYD_HTTP_BASIC_AUTH_PASSWORD | HTTP basic auth password |
| GEOIPPOLICYD_HTTP_TLS_CERT | HTTP TLS server certificate (full chain); default(/localhost.pem) |
| GEOIPPOLICYD_HTTP_TLS_KEY | HTTP TLS server key; default(/localhost-key.pem) |
| GEOIPPOLICYD_PROMETHEUS_ENABLED | Enable Prometheus metrics on the HTTP service; default(false) |
| GEOIPPOLICYD_PROMETHEUS_PATH | HTTP path for Prometheus metrics; default(/metrics) |
| GEOIPPOLICYD_PROMETHEUS_RUNTIME_METRICS | Include Go runtime and process metrics; default(false) |
| GEOIPPOLICYD_OTEL_ENABLED | Enable OpenTelemetry OTLP HTTP export; default(false) |
| GEOIPPOLICYD_OTEL_TRACES_ENABLED | Export OpenTelemetry traces when OTel is enabled; default(false) |
| GEOIPPOLICYD_OTEL_METRICS_ENABLED | Export OpenTelemetry metrics when OTel is enabled; default(false) |
| GEOIPPOLICYD_OTEL_SERVICE_NAME | OpenTelemetry service.name resource attribute; default(geoip-policyd) |
| GEOIPPOLICYD_OTEL_SERVICE_VERSION | OpenTelemetry service.version resource attribute; default(current binary version) |
| GEOIPPOLICYD_OTEL_EXPORTER_OTLP_ENDPOINT | OTLP HTTP endpoint as host:port or base URL |
| GEOIPPOLICYD_OTEL_EXPORTER_OTLP_HEADERS | Comma-separated OTLP HTTP headers as key=value pairs |
| GEOIPPOLICYD_OTEL_EXPORTER_OTLP_INSECURE | Use insecure OTLP HTTP transport; default(false) |
| GEOIPPOLICYD_OTEL_SAMPLE_RATIO | OpenTelemetry trace sampling ratio between 0.0 and 1.0; default(1.0) |
| GEOIPPOLICYD_USE_LDAP | Enable LDAP support; default(false) |
| GEOIPPOLICYD_LDAP_SERVER_URIS | Server URI. Specify multiple times, if you need more than one server; default(ldap://127.0.0.1:389/) |
| GEOIPPOLICYD_LDAP_BASEDN | Base DN |
| GEOIPPOLICYD_LDAP_BINDPW | Bind PW |
| GEOIPPOLICYD_LDAP_FILTER | Filter with %s placeholder; default( (&(objectClass=*)(mailAlias=%s)) ) |
| GEOIPPOLICYD_LDAP_RESULT_ATTRIBUTE | Result attribute for the requested mail sender; default(mailAccount) |
| GEOIPPOLICYD_LDAP_STARTTLS | If this option is given, use StartTLS |
| GEOIPPOLICYD_LDAP_TLS_SKIP_VERIFY | Skip TLS server name verification |
| GEOIPPOLICYD_LDAP_TLS_CAFILE | File containing TLS CA certificate(s) |
| GEOIPPOLICYD_LDAP_TLS_CLIENT_CERT | File containing a TLS client certificate |
| GEOIPPOLICYD_LDAP_TLS_CLIENT_KEY | File containing a TLS client key |
| GEOIPPOLICYD_LDAP_SASL_EXTERNAL | Use SASL/EXTERNAL instead of a simple bind; default(false) |
| GEOIPPOLICYD_LDAP_SCOPE | LDAP search scope [base, one, sub]; default(sub) |
| GEOIPPOLICYD_LDAP_IDLE_POOL_SIZE | LDAP pre-forked (idle) pool size; default(3) |
| GEOIPPOLICYD_LDAP_POOL_SIZE | LDAP max pool size; default(10) |
| GEOIPPOLICYD_RUN_ACTIONS | Run actions, if a sender is over limits; default(false) |
| GEOIPPOLICYD_RUN_ACTION_OPERATOR | Run the operator action; default(false) |
| GEOIPPOLICYD_OPERATOR_TO | E-Mail To-header for the operator action |
| GEOIPPOLICYD_OPERATOR_FROM | E-Mail From-header for the operator action |
| GEOIPPOLICYD_OPERATOR_SUBJECT | E-Mail Subject-header for the operator action; default([geoip-policyd] An e-mail account was compromised) |
| GEOIPPOLICYD_OPERATOR_MESSAGE_CT | E-Mail Content-Type-header for the operator action; default(text/plain) |
| GEOIPPOLICYD_OPERATOR_MESSAGE_PATH | Full path to the e-mail message file for the operator action |
| GEOIPPOLICYD_MAIL_SERVER_ADDRESS | E-mail server address for notifications |
| GEOIPPOLICYD_MAIL_SERVER_PORT | E-mail server port number |
| GEOIPPOLICYD_MAIL_HELO | E-mail server HELO/EHLO hostname; default(localhost) |
| GEOIPPOLICYD_MAIL_PORT | E-mail server port number; default(587) |
| GEOIPPOLICYD_MAIL_USERNAME | E-mail server username |
| GEOIPPOLICYD_MAIL_PASSWORD | E-mail server password |
| GEOIPPOLICYD_MAIL_SSL_ON_CONNECT | Use TLS on connect for the e-mail server; default(false) |
| GEOIPPOLICYD_VERBOSE_LEVEL | Log level. One of 'none', 'info' or 'debug' |
At least one listener must remain enabled. Do not set both
GEOIPPOLICYD_DISABLE_POLICY_SERVICE=true and
GEOIPPOLICYD_DISABLE_HTTP_SERVICE=true. Prometheus export needs the HTTP
service because the scrape endpoint is registered there.
Back to table of contents
Observability is disabled by default. Enabling it adds process-local metrics and optional OTLP HTTP export so operators can observe HTTP requests, Postfix policy requests, policy decisions, Redis operations, LDAP operations and pool state, GeoIP lookups and reloads, CDB lookups, operator actions, and TCP connection lifecycle events.
Metric and trace labels intentionally do not include sender addresses, client IP addresses, Redis keys, or raw LDAP filters. This keeps label cardinality predictable and avoids exporting sensitive request identifiers.
Enable the Prometheus endpoint on the existing HTTP service:
geoip-policyd server --prometheus-enabledThe default endpoint is:
http://127.0.0.1:8080/metrics
--disable-http-service cannot be combined with --prometheus-enabled.
If HTTP basic auth is enabled, the metrics endpoint uses the same credentials as the REST API:
curl -u testuser:testsecret http://127.0.0.1:8080/metricsUseful options:
| Option | Default | Description |
|---|---|---|
| --prometheus-enabled | false | Enables the /metrics endpoint |
| --prometheus-path | /metrics | Changes the metrics endpoint path |
| --prometheus-runtime-metrics | false | Adds Go runtime and process collectors |
Side effects:
- The HTTP listener serves one additional route when Prometheus is enabled.
- Go runtime and process collectors add standard
go_*andprocess_*metrics only when explicitly enabled. /metricsis not instrumented by the HTTP middleware to avoid self-scrape noise.
The repository ships an importable Grafana 11 dashboard at
contrib/grafana/geoip-policyd-grafana11-dashboard.json. It visualizes the
Prometheus metrics for policy decisions, HTTP routes, GeoIP lookups and reloads,
Redis operations, LDAP operations and pool state, CDB lookups, operator actions,
TCP policy-service connections, and optional Go runtime and process collectors.
Prerequisites:
| Requirement | Default or note |
|---|---|
| Grafana | Dashboard JSON targets Grafana 11 schema version 41 |
| Prometheus datasource | Selected through the DS_PROMETHEUS dashboard datasource variable |
| geoip-policyd Prometheus | Start the service with --prometheus-enabled |
| Runtime/process panels | Also enable --prometheus-runtime-metrics; otherwise these stay empty |
Import it through the Grafana UI with Dashboards > New > Import. For API imports, wrap the dashboard JSON for Grafana's create/update endpoint:
python3 -c 'import json, sys; dashboard = json.load(open(sys.argv[1], encoding="utf-8")); print(json.dumps({"dashboard": dashboard, "overwrite": True, "message": "Import geoip-policyd dashboard"}))' \
contrib/grafana/geoip-policyd-grafana11-dashboard.json |
curl -X POST \
-H "Authorization: Bearer ${GRAFANA_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary @- \
http://127.0.0.1:3000/api/dashboards/dbAfter import, choose the Prometheus datasource and, if needed, narrow the
dashboard variables for job, instance, policy source, and HTTP route.
Side effects:
- Importing the JSON creates or updates only the Grafana dashboard with UID
geoip-policyd-g11. - Dashboard queries read from Prometheus only and do not call geoip-policyd.
- Runtime panels are intentionally optional because
go_*andprocess_*metrics are exported only when runtime metrics are enabled.
OpenTelemetry uses OTLP over HTTP. Enable it with an endpoint:
geoip-policyd server \
--otel-enabled \
--otel-traces-enabled \
--otel-exporter-otlp-endpoint http://127.0.0.1:4318The endpoint may be host:port or a base URL such as http://collector.example:4318. The exporter uses the standard
OTLP HTTP paths for traces and metrics. At least one of --otel-traces-enabled or --otel-metrics-enabled must be set
when --otel-enabled is set.
Useful options:
| Option | Default | Description |
|---|---|---|
| --otel-enabled | false | Enables OpenTelemetry export |
| --otel-traces-enabled | false | Exports traces when OTel is enabled |
| --otel-metrics-enabled | false | Exports OTel metrics when OTel is enabled |
| --otel-service-name | geoip-policyd | Sets the service.name resource attribute |
| --otel-service-version | current version | Sets the service.version resource attribute |
| --otel-exporter-otlp-endpoint | empty | OTLP HTTP collector endpoint; required when OTel is enabled |
| --otel-exporter-otlp-headers | empty | Comma-separated key=value headers for the OTLP HTTP exporter |
| --otel-exporter-otlp-insecure | false | Uses insecure transport for OTLP HTTP |
| --otel-sample-ratio | 1.0 | Trace sampling ratio from 0.0 to 1.0 |
Example with headers:
geoip-policyd server \
--otel-enabled \
--otel-traces-enabled \
--otel-exporter-otlp-endpoint https://collector.example:4318 \
--otel-exporter-otlp-headers "authorization=Bearer token"Side effects:
- The process opens outbound HTTP connections to the configured collector.
- SIGINT and SIGTERM trigger a best-effort telemetry flush before process exit.
- If OTel is enabled, at least one of traces or metrics must remain enabled and the OTLP endpoint is required.
Back to table of contents
Request: reload
Response: No results
The GeoIP database is reloaded through the same verified loader as on startup.
If it cannot be loaded, the endpoint returns 500 and the active database stays
in service (see GeoIP data sources).
Example:
# Plain http without basic auth
curl "http://localhost:8080/reload"
# Plain with basic auth
curl "http://localhost:8080/reload" -u testuser:testsecret
# Secured with basic auth
curl -k "https://localhost:8443/reload" -u testuser:testsecretBack to table of contents
Request: get current custom settings in JSON format
Response: JSON output of the currently loaded custom settings
Example:
# Plain http without basic auth
curl "http://localhost:8080/custom-settings" | jq
# Plain with basic auth
curl "http://localhost:8080/custom-settings" -u testuser:testsecret | jq
# Secured with basic auth
curl -k "https://localhost:8443/custom-settings" -u testuser:testsecret | jqExample result from default custom.json:
[
{
"comment": "Allow only two countries and a maximum of 5 IP addresses",
"sender": "christian@roessner.email",
"ips": 5,
"countries": 2
},
{
"comment": "Allow at least 4 countries and go with the default IP address limit",
"sender": "test1@example.com",
"ips": 0,
"countries": 4
},
{
"comment": "Go with the default country limit, but allow up to 30 IP addresses",
"sender": "test2@example.com",
"ips": 30,
"countries": 0
}
]Back to table of contents
Request: Submit an email account that should be unlocked
Response: No results
Example:
# Plain http without basic auth
curl -d '{"key":"sender","value":"user@example.com"}' -H "Content-Type: application/json" -X POST "http://localhost:8080/remove"
# Plain with basic auth
curl -d '{"key":"sender","value":"user@example.com"}' -H "Content-Type: application/json" -X POST "http://localhost:8080/remove" -u testuser:testsecret
# Secured with basic auth
curl -k -d '{"key":"sender","value":"user@example.com}"' -H "Content-Type: application/json" -X POST "https://localhost:8443/remove" -u testuser:testsecretBack to table of contents
Request: Submit a client address and a sender name to get a policy result Request format: JSON Response: JSON formatted policy decision
Example request:
# Plain http without basic auth
curl -d '{ "key": "client", "value": { "address": "1.2.3.4", "sender": "user@example.com" } }' -H "Content-Type: application/json" -X POST "http://localhost:8080/query"
# Plain with basic auth
curl -d '{ "key": "client", "value": { "address": "1.2.3.4", "sender": "user@example.com" } }' -H "Content-Type: application/json" -X POST "http://localhost:8080/query" -u testuser:testsecret
# Secured with basic auth
curl -k -d '{ "key": "client", "value": { "address": "1.2.3.4", "sender": "user@example.com" } }' -H "Content-Type: application/json" -X POST "https://localhost:8443/query" -u testuser:testsecretAllowed policy response example:
{
"guid": "2FOLGkYQwUB8XTQhdTY3csRkNV2",
"object": "client",
"operation": "query",
"result": true
}Forbidden policy response example:
{
"guid": "2FOLGkYQwUB8XTQhdTY3csRkNV2",
"object": "client",
"operation": "query",
"result": false
}If "?info=1" is appended to the query-string of the HTTP request, the service will only return the country code for the current client IP address.
Back to table of contents
Request: Set custom settings. This will overwrite a custom settings file or initiates settings, if there have not been
any settings before (no config file given).
Response: No results
Note
If you use a custom settings file and send new data with a PUT request, the settings are updated in memory. But if you do a GET request afterwards and reloading data, the settings from the file will be loaded again!
Example:
# Plain http without basic auth
curl -d '{"data":[{ "sender":"christian@roessner.email","ips":3,"countries":1},{"sender":"test1@example.com","countries":1},{"sender":"test2@example.com","ips":20}]}' -H "Content-Type: application/json" -X PUT "http://localhost:8080/update"
# Plain with basic auth
curl -d '{"data":[{ "sender":"christian@roessner.email","ips":3,"countries":1},{"sender":"test1@example.com","countries":1},{"sender":"test2@example.com","ips":20}]}' -H "Content-Type: application/json" -X PUT "http://localhost:8080/update" -u testuser:testsecret
# Secured with basic auth
curl -k -d '{"data":[{ "sender":"christian@roessner.email","ips":3,"countries":1},{"sender":"test1@example.com","countries":1},{"sender":"test2@example.com","ips":20}]}' -H "Content-Type: application/json" -X PUT "https://localhost:8443/update" -u testuser:testsecretBack to table of contents
Request: Send changed settings for a given sender. If the sender does not exist, add a new record to the custom
settings.
Response: No results
Example:
# Plain http without basic auth
curl -d '{"key":"sender","value":{"comment":"Test","sender":"christian@roessner.email","ips":100,"countries":100}}' -H "Content-Type: application/json" -X PATCH "http://localhost:8080/modify"
# Plain with basic auth
curl -d '{"key":"sender","value":{"comment":"Test","sender":"christian@roessner.email","ips":100,"countries":100}}' -H "Content-Type: application/json" -X PATCH "http://localhost:8080/modify" -u testuser:testsecret
# Secured with basic auth
curl -k -d '{"key":"sender","value":{"comment":"Test","sender":"christian@roessner.email","ips":100,"countries":100}}"' -H "Content-Type: application/json" -X PATCH "https://localhost:8443/modify" -u testuser:testsecretNote:
This endpoint is not yet implemented nor tested for home countries!
Back to table of contents
Request: Remove an entry from the custom settings by using the sender as the key.
Response: No results
Example:
# Plain http without basic auth
curl -d '{"key":"sender","value":"christian@roessner.email"}' -H "Content-Type: application/json" -X DELETE "http://localhost:8080/remove"
# Plain with basic auth
curl -d '{"key":"sender","value":"christian@roessner.email"}' -H "Content-Type: application/json" -X DELETE "http://localhost:8080/remove" -u testuser:testsecret
# Secured with basic auth
curl -k -d '{"key":"sender","value":"christian@roessner.email"}"' -H "Content-Type: application/json" -X DELETE "https://localhost:8443/remove" -u testuser:testsecretBack to table of contents
The contrib/geoip-policyd-test.py script exercises the REST interface and the
raw Postfix policy socket with useful local defaults. It uses only the Python
standard library and does not require a virtual environment.
The repository contains testdata/GeoIP2-City-Test.mmdb for local smoke tests
that need a valid MaxMind database without depending on an operator-provided
GeoLite file. testdata/IPinfo-Lite-Test.mmdb is a small synthetic IPinfo Lite
database (see MMDB test fixtures).
Default targets:
| Option | Default |
|---|---|
--base-url |
http://127.0.0.1:8080 |
--policy-host |
127.0.0.1 |
--policy-port |
4646 |
--sender |
geoip-policyd-test@example.com |
--address |
127.0.0.1 |
--recipient |
postmaster@example.com |
Run a single endpoint check:
contrib/geoip-policyd-test.py --address 8.8.8.8 --sender user@example.com query
contrib/geoip-policyd-test.py --address 8.8.8.8 dovecotpolicy --command allow
contrib/geoip-policyd-test.py --address 8.8.8.8 policyRun the complete endpoint suite:
contrib/geoip-policyd-test.py allThe all command checks GET /custom-settings, POST /query,
POST /dovecotpolicy?command=report, POST /dovecotpolicy?command=allow,
the raw Postfix policy socket, PUT /update, PATCH /modify,
DELETE /remove, POST /remove, and GET /reload. The suite intentionally
touches mutating custom-settings and unlock endpoints, so run it against a
dedicated test instance or with a sender and Redis prefix that are safe to
modify.
For HTTPS with a local or self-signed certificate, add --insecure. For HTTP
basic authentication, add --username and --password.
Example against isolated local test ports:
contrib/geoip-policyd-test.py \
--base-url http://127.0.0.1:18080 \
--policy-port 14646 \
--address 8.8.8.8 \
allThe output is a compact table with the endpoint name, method, target, status,
expected status, result, and a short response summary. The process exits with
status 0 only when all selected checks pass.
The contrib/otel_prometheus_smoke.go helper runs an external observability
smoke test. It starts a fake Redis server, a fake OTLP/HTTP collector, and a
real geoip-policyd server process on random loopback ports. It then sends a
real POST /query, scrapes Prometheus, terminates the child process cleanly,
and validates the exported OTLP protobuf payloads.
Run it through the Makefile:
make smoke-observabilityThe smoke checks that Prometheus contains HTTP, policy, Redis, and GeoIP metrics. It also checks that OTLP traces contain this graph:
HTTP POST /query
`-- policy.request
|-- geoip.lookup
| `-- geoip.mmdb.lookup
|-- redis.command GET
`-- redis.command SET
OTLP metrics are checked for geoip_policyd_http_requests,
geoip_policyd_policy_requests, geoip_policyd_redis_operations, and
geoip_policyd_geoip_lookups.
The helper uses only local loopback ports and temporary files. To run it with an existing binary or a different GeoIP database, use:
go run -mod=vendor ./contrib \
--binary /path/to/geoip-policyd \
--geoip-path ./GeoIP2-Country.mmdb \
--address 8.8.8.8Both GeoIP spans carry the geoip.provider attribute (maxmind or ipinfo).
contrib/mmdbfixture generates the synthetic databases used by the unit tests:
| File | Purpose |
|---|---|
testdata/IPinfo-Lite-Test.mmdb |
IPinfo Lite layout, IPv4 and IPv6 networks, one without ASN |
testdata/IPinfo-Legacy-Test.mmdb |
Legacy IPinfo country_asn layout, must be rejected |
testdata/GeoLite2-ASN-Test.mmdb |
ASN-only database, must fail schema verification |
The generator is a separate Go module with its own go.mod, so its
github.com/maxmind/mmdbwriter dependency never enters the vendored runtime
module or ./.... Regenerate the fixtures with:
make fixturesThe target downloads the generator dependencies into the Go module cache,
overwrites the three files in testdata/, and changes nothing else. Commit the
regenerated files together with generator changes.
Back to table of contents
You can activate actions that will be taken, if a sender was declared compromised. At the moment you can send a notification to an e-mail operator. To do this, you must activate actions in general as well as the operator action. You need also to define all the required operator parameters as To, From, Subject, CT and of course an e-mail server ( including all required settings) to get things done.
Example:
geoip-policyd ...other-options... \
--run-actions \
--run-action-operator \
--operator-to "<operator@example.com>" \
--operator-from "<no-reply@submission.example.com>" \
--operator-message-ct "text/plain" \
--operator-message-path ./mailtemplate.txt \
--mail-server submission.example.com \
--mail-port 587 \
--mail-username "some_username" \
--mail-password some-secretBack to table of contents
You can use LDAP to send the sender attribute and to retrieve whatever that makes your request unique. If you have customers that use virtual aliases and that belong to exactly one account, this may help you to aggregate e-mail sender requests.
Example:
| virtual alias | real account |
|---|---|
| user1@example.com | unique@account.net |
| foo@bar.org | unique@account.net |
Both belong to one and the same account. Without LDAP this would result in two records in Redis. With LDAP it results into the real unique account.
It is also possible to not retrieve another unique mail account from LDAP. You can also return the entryUUID field or some other field like uid or uniqueIdentifier (LDAP overlay unique to enforce uniqueness!).
Here is my personal example of a docker-compose.yml file that makes use of LDAP:
version: "3.8"
services:
geoip-policyd:
image: ...whatever.../geoip-policyd:latest
logging:
driver: journald
options:
tag: geoip-policyd
network_mode: host
environment:
VERBOSE: "debug"
SERVER_ADDRESS: "127.0.0.1"
SERVER_PORT: 4646
HTTP_ADDRESS: "127.0.0.1:8080"
REDIS_ADDRESS: "127.0.0.1"
REDIS_PORT: 6379
REDIS_DATABASE_NUMBER: 0
GEOIP_PATH: "/GeoLite2-City.mmdb"
CUSTOM_SETTINGS_PATH: "/custom.json"
USE_LDAP: "true"
LDAP_STARTTLS: "true"
LDAP_SASL_EXTERNAL: "true"
LDAP_SERVER_URIS: "ldap://****:389/, ldap://****:389/"
LDAP_BASEDN: "ou=people,..."
LDAP_TLS_CAFILE: "/cacert.pem"
LDAP_TLS_CLIENT_CERT: "/cert.pem"
LDAP_TLS_CLIENT_KEY: "/key.pem"
LDAP_FILTER: "(&(objectClass=rnsMSDovecotAccount)(objectClass=rnsMSPostfixAccount)(rnsMSRecipientAddress=%s))"
LDAP_RESULT_ATTRIBUTE: "uid"
volumes:
- /usr/share/GeoIP/GeoLite2-City.mmdb:/GeoLite2-City.mmdb:ro,Z
- ./custom.json:/custom.json:ro,Z
- /etc/pki/tls/certs/cacert.pem:/cacert.pem:ro,Z
- /etc/ssl/certs/cert.pem:/cert.pem:ro,Z
- /etc/ssl/private/key.pem:/key.pem:ro,ZA result in the logs looks like this:
geoip-policyd_1 | 2021/09/14 06:53:28 Info: sender=<2F7032A7-D2BE-4178-87B2-A8D3AC0F32F1>; countries=[DE]; ip_addresses=[x.x.x.x]; #countries=1/1; #ip_addresses=1/1; action=DUNNO
Redis-result:
127.0.0.1:6379> get geopol_2F7032A7-D2BE-4178-87B2-A8D3AC0F32F1
"{\"Ips\":[\"x.x.x.x\"],\"Countries\":[\"DE\"]}"This way you get some pseudo anonymization.
If you do so, you also have to modify your custom.json file, if you use one:
{
"data": [
{
"comment": "Some comment",
"sender": "4FFDDFD3-BE1B-4639-8465-32A9A709F4CF",
"ips": 5,
"countries": 2
},
{
"comment": "Whatever else",
"sender": "2F7032A7-D2BE-4178-87B2-A8D3AC0F32F1",
"ips": 1,
"countries": 1
},
{
"comment": "And another one goes here",
"sender": "6B806FF8-8BA5-40CC-A0FE-602CF2AEEDE2",
"countries": 1
}
]
}Back to table of contents
This project is licensed under the GPLv3 License - see the LICENSE file for details.
Note
The license has changed from AGPL-3 to GPL-3! This step is required to provide docker images.