Registry testing - part 9

This commit is contained in:
Maurus Decimus
2026-03-19 19:29:17 +01:00
parent 7ae6654735
commit a67921f18c
57 changed files with 2886 additions and 1312 deletions

View File

@@ -0,0 +1,167 @@
# Stalwart Test Infrastructure
Ephemeral Docker Compose stack for testing Stalwart against external services.
All data is lost on `docker compose down` every restart is a clean slate.
## Quick Start
```bash
cd stalwart-test
docker compose up -d
```
Wait ~30 seconds for all services to initialize (Keycloak takes the longest).
## Connection Reference
| Service | Host | Port(s) | Credentials / Notes |
|----------------|-------------------|-----------------|--------------------------------------------|
| PostgreSQL | localhost | 5432 | `stalwart` / `stalwart`, db: `stalwart` |
| MySQL | localhost | 3306 | `stalwart` / `stalwart`, db: `stalwart` |
| FoundationDB | localhost | 4500 | Cluster file from container |
| Redis | localhost | 6379 | No auth |
| OpenSearch | localhost | 9200 | No auth, security plugin disabled |
| Meilisearch | localhost | 7700 | Master key: `stalwart-master-key` |
| MinIO (S3) | localhost | 9000 / 9001 | `minioadmin` / `minioadmin`, bucket: `stalwart` |
| Keycloak (OIDC)| localhost | 9080 | Admin: `admin` / `admin` |
| OpenLDAP | localhost | 389 / 636 (TLS) | Admin DN: `cn=admin,dc=stalwart,dc=test`, pw: `admin` |
| Pebble (ACME) | localhost | 14000 / 15000 | Self-signed TLS, auto-valid challenges |
| PowerDNS | localhost | 5300 / 8081 | API key: `stalwart-api-key` |
| NATS | localhost | 4222 / 8222 | No auth |
## OIDC (Keycloak) Details
- **OIDC Discovery**: `http://localhost:9080/realms/stalwart/.well-known/openid-configuration`
- **Token Endpoint**: `http://localhost:9080/realms/stalwart/protocol/openid-connect/token`
- **Client ID**: `stalwart`
- **Client Secret**: `stalwart-secret`
- **Realm**: `stalwart`
### Test Users
| Username | Password | Groups |
|---------------------------|--------------------------|----------------------------------------|
| john.doe@example.org | this is an OIDC password | sales@example.org |
| jane.smith@example.org | this is an OIDC password | sales@example.org, corporate@example.org |
| bill.foobar@example.org | this is an OIDC password | corporate@example.org |
### Example: Get a Token
```bash
curl -X POST http://localhost:9080/realms/stalwart/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=stalwart" \
-d "client_secret=stalwart-secret" \
-d "username=john.doe@example.org" \
-d "password=this is an OIDC password"
```
## LDAP Details
- **Base DN**: `dc=stalwart,dc=test`
- **Admin DN**: `cn=admin,dc=stalwart,dc=test`
- **Admin Password**: `admin`
- **Read-only DN**: `cn=readonly,dc=stalwart,dc=test`
- **Read-only Password**: `readonly`
- **User DN pattern**: `uid={username},ou=users,dc=stalwart,dc=test`
### Test Users
| DN | Mail | Password |
|------------------------------------------------|--------------------------|--------------------------|
| uid=john.doe,ou=users,dc=stalwart,dc=test | john.doe@example.org | this is an LDAP password |
| uid=jane.smith,ou=users,dc=stalwart,dc=test | jane.smith@example.org | this is an LDAP password |
| uid=bill.foobar,ou=users,dc=stalwart,dc=test | bill.foobar@example.org | this is an LDAP password |
### Groups
| DN | Mail | Members |
|---------------------------------------------|-------------------------|------------------|
| cn=sales,ou=groups,dc=stalwart,dc=test | sales@example.org | john.doe, jane.smith |
| cn=corporate,ou=groups,dc=stalwart,dc=test | corporate@example.org | bill.foobar, jane.smith |
### Example: Search by Email
```bash
ldapsearch -x -H ldap://localhost:389 \
-D "cn=admin,dc=stalwart,dc=test" -w admin \
-b "dc=stalwart,dc=test" "(mail=john.doe@example.org)"
```
## S3 (MinIO) Details
- **Endpoint**: `http://localhost:9000`
- **Access Key**: `minioadmin`
- **Secret Key**: `minioadmin`
- **Bucket**: `stalwart`
- **Console**: `http://localhost:9001`
- **Region**: `us-east-1` (MinIO default)
## DNS (PowerDNS) Details
- **DNS port**: 5300 (TCP+UDP)
- **API**: `http://localhost:8081` (API key: `stalwart-api-key`)
- **Zone**: `stalwart.test`
- **TSIG key name**: `stalwart-update-key`
- **TSIG algorithm**: `hmac-sha256`
- **TSIG secret (base64)**: `c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw`
> **Note on SIG(0):** PowerDNS does not support SIG(0) authentication for RFC2136
> updates. Only BIND has (limited) SIG(0) support. If you need to test SIG(0),
> a separate BIND instance would be required.
### Example: Query TLSA Record
```bash
dig @localhost -p 5300 _25._tcp.mail.stalwart.test TLSA
```
### Example: RFC2136 Dynamic Update
```bash
nsupdate -y hmac-sha256:stalwart-update-key:c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw <<EOF
server 127.0.0.1 5300
zone stalwart.test
update add test.stalwart.test 300 A 192.168.1.100
send
EOF
```
## ACME (Pebble) Details
- **Directory URL**: `https://localhost:14000/dir`
- **Management URL**: `https://localhost:15000`
- **TLS**: Self-signed (use `PEBBLE_VA_ALWAYS_VALID=1` — all challenges auto-pass)
- Stalwart must be configured to trust the Pebble CA or skip TLS verification.
## Self-Signed TLS Certificate
A shared self-signed certificate is generated at startup and mounted into services
that need it. The cert is valid for:
- `localhost`, `keycloak`, `openldap`, `pebble`, `*.stalwart.test`, `127.0.0.1`
To extract the cert for use with Stalwart:
```bash
docker compose cp cert-init:/certs/cert.pem ./test-cert.pem
docker compose cp cert-init:/certs/key.pem ./test-key.pem
```
## Troubleshooting
```bash
# Check all services are running
docker compose ps
# View logs for a specific service
docker compose logs -f keycloak
# Restart everything fresh
docker compose down && docker compose up -d
# Check FoundationDB status
docker compose exec foundationdb fdbcli --exec "status"
# Verify TSIG key is loaded
docker compose exec powerdns pdnsutil list-tsig-keys
```

View File

@@ -0,0 +1,248 @@
###############################################################################
# Stalwart Test Infrastructure
# All services are ephemeral (no volumes = fresh on every restart)
# Ports exposed on localhost; 80/443/8080 are remapped
###############################################################################
services:
# ---------------------------------------------------------------------------
# Init: generate shared self-signed TLS certificates
# ---------------------------------------------------------------------------
cert-init:
image: alpine/openssl:latest
volumes:
- certs:/certs
- ./scripts/gen-certs.sh:/gen-certs.sh:ro
entrypoint: [ "sh", "/gen-certs.sh" ]
# ---------------------------------------------------------------------------
# PostgreSQL
# ---------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: stalwart
POSTGRES_PASSWORD: stalwart
POSTGRES_DB: stalwart
ports:
- "127.0.0.1:5432:5432"
tmpfs:
- /var/lib/postgresql/data
# ---------------------------------------------------------------------------
# MySQL
# ---------------------------------------------------------------------------
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: stalwart
MYSQL_DATABASE: stalwart
MYSQL_USER: stalwart
MYSQL_PASSWORD: stalwart
ports:
- "127.0.0.1:3306:3306"
tmpfs:
- /var/lib/mysql
command: --default-authentication-plugin=mysql_native_password
# ---------------------------------------------------------------------------
# FoundationDB
# ---------------------------------------------------------------------------
foundationdb:
image: foundationdb/foundationdb:7.3.43
platform: linux/amd64
ports:
- "127.0.0.1:4500:4500"
environment:
FDB_NETWORKING_MODE: container
volumes:
- fdb-config:/var/fdb
tmpfs:
- /var/fdb/data
- /var/fdb/logs
fdb-init:
image: foundationdb/foundationdb:7.3.43
platform: linux/amd64
depends_on:
- foundationdb
volumes:
- fdb-config:/var/fdb
- ./scripts/init-fdb.sh:/init-fdb.sh:ro
entrypoint: [ "bash", "/init-fdb.sh" ]
# ---------------------------------------------------------------------------
# Redis
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
ports:
- "127.0.0.1:6379:6379"
command: redis-server --save "" --appendonly no
# ---------------------------------------------------------------------------
# OpenSearch (ElasticSearch-compatible)
# ---------------------------------------------------------------------------
opensearch:
image: opensearchproject/opensearch:2
environment:
discovery.type: single-node
DISABLE_SECURITY_PLUGIN: "true"
OPENSEARCH_JAVA_OPTS: "-Xms256m -Xmx256m"
DISABLE_INSTALL_DEMO_CONFIG: "true"
ports:
- "127.0.0.1:9200:9200"
tmpfs:
- /usr/share/opensearch/data:uid=1000,gid=1000
# ---------------------------------------------------------------------------
# Meilisearch
# ---------------------------------------------------------------------------
meilisearch:
image: getmeili/meilisearch:latest
environment:
MEILI_ENV: development
MEILI_NO_ANALYTICS: "true"
MEILI_MASTER_KEY: stalwart-master-key
ports:
- "127.0.0.1:7700:7700"
tmpfs:
- /meili_data
# ---------------------------------------------------------------------------
# MinIO (S3-compatible)
# ---------------------------------------------------------------------------
minio:
image: minio/minio:latest
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
command: server /data --console-address ":9001"
tmpfs:
- /data
minio-init:
image: minio/mc:latest
depends_on:
- minio
volumes:
- ./scripts/init-minio.sh:/init-minio.sh:ro
entrypoint: [ "bash", "/init-minio.sh" ]
# ---------------------------------------------------------------------------
# Keycloak (OIDC Provider) port 9080 (moved from 8080)
# ---------------------------------------------------------------------------
keycloak:
image: quay.io/keycloak/keycloak:latest
depends_on:
cert-init:
condition: service_completed_successfully
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_HTTP_PORT: 9080
KC_HEALTH_ENABLED: "true"
ports:
- "127.0.0.1:9080:9080"
volumes:
- ./keycloak/stalwart-realm.json:/opt/keycloak/data/import/stalwart-realm.json:ro
- certs:/certs:ro
command: >
start-dev --import-realm
tmpfs:
- /opt/keycloak/data:uid=1000,gid=1000
# ---------------------------------------------------------------------------
# OpenLDAP
# ---------------------------------------------------------------------------
openldap:
image: osixia/openldap:1.5.0
depends_on:
cert-init:
condition: service_completed_successfully
environment:
LDAP_ORGANISATION: "Stalwart Test"
LDAP_DOMAIN: "stalwart.test"
LDAP_BASE_DN: "dc=stalwart,dc=test"
LDAP_ADMIN_PASSWORD: "admin"
LDAP_READONLY_USER: "true"
LDAP_READONLY_USER_USERNAME: "readonly"
LDAP_READONLY_USER_PASSWORD: "readonly"
LDAP_TLS: "true"
LDAP_TLS_CRT_FILENAME: "cert.pem"
LDAP_TLS_KEY_FILENAME: "key.pem"
LDAP_TLS_CA_CRT_FILENAME: "cert.pem"
LDAP_TLS_VERIFY_CLIENT: "never"
LDAP_SEED_INTERNAL_LDIF_PATH: "/seed"
ports:
- "127.0.0.1:389:389"
- "127.0.0.1:636:636"
volumes:
- ./ldap/seed.ldif:/seed/50-stalwart.ldif:ro
- certs:/certs-shared:ro
entrypoint: [ "/bin/bash", "-c", "cp /certs-shared/* /container/service/slapd/assets/certs/ 2>/dev/null; exec /container/tool/run" ]
# ---------------------------------------------------------------------------
# Pebble (ACME server) ports 14000 (directory) + 15000 (management)
# ---------------------------------------------------------------------------
pebble:
image: ghcr.io/letsencrypt/pebble:latest
environment:
PEBBLE_VA_NOSLEEP: "1"
PEBBLE_VA_ALWAYS_VALID: "1"
ports:
- "127.0.0.1:14000:14000"
- "127.0.0.1:15000:15000"
volumes:
- ./pebble/pebble-config.json:/test/config/pebble-config.json:ro
command: -config /test/config/pebble-config.json
# ---------------------------------------------------------------------------
# PowerDNS (DNS with TLSA + RFC2136) port 5300 (moved from 53)
# ---------------------------------------------------------------------------
powerdns:
image: powerdns/pdns-auth-49:latest
environment:
PDNS_AUTH_API_KEY: stalwart-api-key
ports:
- "127.0.0.1:5300:53/tcp"
- "127.0.0.1:5300:53/udp"
- "127.0.0.1:8081:8081"
volumes:
- ./powerdns/pdns.conf:/etc/powerdns/pdns.d/stalwart.conf:ro
- ./powerdns/init-zone.sh:/etc/powerdns/init-zone.sh:ro
tmpfs:
- /var/lib/powerdns
powerdns-init:
image: powerdns/pdns-auth-49:latest
depends_on:
- powerdns
volumes:
- ./powerdns/entrypoint.sh:/init.sh:ro
- ./powerdns/init-zone.sh:/etc/powerdns/init-zone.sh:ro
entrypoint: [ "bash", "/init.sh" ]
network_mode: "service:powerdns"
# ---------------------------------------------------------------------------
# NATS (message queue, core mode)
# ---------------------------------------------------------------------------
nats:
image: nats:latest
ports:
- "127.0.0.1:4222:4222"
- "127.0.0.1:8222:8222"
command: "--addr 0.0.0.0 --port 4222 --http_port 8222"
# =============================================================================
# Shared volumes (ephemeral docker compose down removes them)
# =============================================================================
volumes:
certs:
driver: local
fdb-config:
driver: local

View File

@@ -0,0 +1,113 @@
{
"realm": "stalwart",
"enabled": true,
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"sslRequired": "none",
"clients": [
{
"clientId": "stalwart",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "stalwart-secret",
"redirectUris": ["*"],
"webOrigins": ["*"],
"publicClient": false,
"protocol": "openid-connect",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"serviceAccountsEnabled": true,
"defaultClientScopes": ["openid", "email", "profile", "roles"],
"protocolMappers": [
{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"full.path": "false",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "groups",
"userinfo.token.claim": "true"
}
},
{
"name": "email-claim",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"userinfo.token.claim": "true",
"jsonType.label": "String"
}
}
]
}
],
"users": [
{
"username": "john.doe@example.org",
"enabled": true,
"email": "john.doe@example.org",
"emailVerified": true,
"firstName": "John",
"lastName": "Doe",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": ["/sales@example.org"]
},
{
"username": "jane.smith@example.org",
"enabled": true,
"email": "jane.smith@example.org",
"emailVerified": true,
"firstName": "Jane",
"lastName": "Smith",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": ["/sales@example.org", "/corporate@example.org"]
},
{
"username": "bill.foobar@example.org",
"enabled": true,
"email": "bill.foobar@example.org",
"emailVerified": true,
"firstName": "Bill",
"lastName": "Foobar",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": ["/corporate@example.org"]
}
],
"groups": [
{
"name": "sales@example.org",
"path": "/sales@example.org"
},
{
"name": "corporate@example.org",
"path": "/corporate@example.org"
}
]
}

View File

@@ -0,0 +1,69 @@
# Organizational Units
dn: ou=users,dc=stalwart,dc=test
objectClass: organizationalUnit
ou: users
dn: ou=groups,dc=stalwart,dc=test
objectClass: organizationalUnit
ou: groups
# Users
dn: uid=john.doe,ou=users,dc=stalwart,dc=test
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: john.doe
cn: John Doe
sn: Doe
givenName: John
mail: john.doe@example.org
userPassword: this is an LDAP password
uidNumber: 10001
gidNumber: 10001
homeDirectory: /home/john.doe
loginShell: /bin/bash
dn: uid=jane.smith,ou=users,dc=stalwart,dc=test
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: jane.smith
cn: Jane Smith
sn: Smith
givenName: Jane
mail: jane.smith@example.org
userPassword: this is an LDAP password
uidNumber: 10002
gidNumber: 10002
homeDirectory: /home/jane.smith
loginShell: /bin/bash
dn: uid=bill.foobar,ou=users,dc=stalwart,dc=test
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: bill.foobar
cn: Bill Foobar
sn: Foobar
givenName: Bill
mail: bill.foobar@example.org
userPassword: this is an LDAP password
uidNumber: 10003
gidNumber: 10003
homeDirectory: /home/bill.foobar
loginShell: /bin/bash
# Groups with email addresses
dn: cn=sales,ou=groups,dc=stalwart,dc=test
objectClass: groupOfNames
cn: sales
mail: sales@example.org
member: uid=john.doe,ou=users,dc=stalwart,dc=test
member: uid=jane.smith,ou=users,dc=stalwart,dc=test
dn: cn=corporate,ou=groups,dc=stalwart,dc=test
objectClass: groupOfNames
cn: corporate
mail: corporate@example.org
member: uid=bill.foobar,ou=users,dc=stalwart,dc=test
member: uid=jane.smith,ou=users,dc=stalwart,dc=test

View File

@@ -0,0 +1,17 @@
{
"pebble": {
"listenAddress": "0.0.0.0:14000",
"managementListenAddress": "0.0.0.0:15000",
"certificate": "/test/certs/localhost/cert.pem",
"privateKey": "/test/certs/localhost/key.pem",
"httpPort": 5002,
"tlsPort": 5001,
"ocspResponderURL": "",
"externalAccountBindingRequired": false,
"domainBlocklist": [],
"retryAfter": {
"authz": 3,
"order": 5
}
}
}

View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -e
# Wait for PowerDNS to be ready (started by default entrypoint)
echo "Waiting for PowerDNS to start..."
for i in $(seq 1 30); do
if pdnsutil list-all-zones 2>/dev/null; then
break
fi
sleep 1
done
# Run zone initialization
bash /etc/powerdns/init-zone.sh

View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -e
# Wait for the SQLite database to be ready
sleep 2
# Create the zone
pdnsutil create-zone stalwart.test ns1.stalwart.test
pdnsutil set-kind stalwart.test native
# Add basic records
pdnsutil add-record stalwart.test '' SOA 'ns1.stalwart.test. admin.stalwart.test. 2024010101 3600 900 604800 86400'
pdnsutil add-record stalwart.test '' NS 'ns1.stalwart.test.'
pdnsutil add-record stalwart.test 'ns1' A '127.0.0.1'
pdnsutil add-record stalwart.test '' A '127.0.0.1'
pdnsutil add-record stalwart.test '' MX '10 mail.stalwart.test.'
pdnsutil add-record stalwart.test 'mail' A '127.0.0.1'
# Add a sample TLSA record
# Usage=3 (DANE-EE), Selector=1 (SubjectPublicKeyInfo), Matching=1 (SHA-256)
pdnsutil add-record stalwart.test '_25._tcp.mail' TLSA '3 1 1 0000000000000000000000000000000000000000000000000000000000000000'
# Import static TSIG key for RFC2136 dynamic updates
# Key: stalwart-update-key / HMAC-SHA256
# Base64 secret: c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw
pdnsutil import-tsig-key stalwart-update-key hmac-sha256 'c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw'
pdnsutil activate-tsig-key stalwart.test stalwart-update-key master
pdnsutil set-meta stalwart.test TSIG-ALLOW-DNSUPDATE stalwart-update-key
pdnsutil set-meta stalwart.test ALLOW-DNSUPDATE-FROM '0.0.0.0/0'
echo "PowerDNS zone setup complete."
echo "TSIG key name: stalwart-update-key"
echo "TSIG algorithm: hmac-sha256"
echo "TSIG secret (b64): c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldC1rZXkxMjM0NTY3ODkw"

View File

@@ -0,0 +1,2 @@
dnsupdate=yes
allow-dnsupdate-from=0.0.0.0/0

View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -e
CERT_DIR=/certs
if [ ! -f "$CERT_DIR/cert.pem" ]; then
echo "Generating self-signed certificate..."
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$CERT_DIR/key.pem" \
-out "$CERT_DIR/cert.pem" \
-days 365 \
-subj "/CN=localhost/O=Stalwart Test/C=US" \
-addext "subjectAltName=DNS:localhost,DNS:keycloak,DNS:openldap,DNS:pebble,DNS:*.stalwart.test,IP:127.0.0.1"
# Create combined PEM for services that need it
cat "$CERT_DIR/cert.pem" "$CERT_DIR/key.pem" > "$CERT_DIR/combined.pem"
# Create PKCS12 for Keycloak
openssl pkcs12 -export -in "$CERT_DIR/cert.pem" -inkey "$CERT_DIR/key.pem" \
-out "$CERT_DIR/keystore.p12" -name localhost -password pass:changeit
chmod 644 "$CERT_DIR"/*
echo "Certificates generated."
else
echo "Certificates already exist."
fi

View File

@@ -0,0 +1,7 @@
#!/bin/bash
set -e
fdbcli --exec "configure new single memory"
echo "FoundationDB configured."
exit 0

View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -e
# Wait for MinIO to be ready
echo "Waiting for MinIO..."
until mc alias set local http://minio:9000 minioadmin minioadmin 2>/dev/null; do
sleep 1
done
# Create the stalwart bucket
mc mb local/stalwart --ignore-existing
echo "MinIO bucket 'stalwart' created."