Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
# 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, uses challtestsrv |
| Challtestsrv | localhost | 8055 | ACME challenge test server (management API)|
| 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 "[email protected]" \
-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" "([email protected])"
```
## 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 + Challenge Test Server) Details
- **Directory URL**: `https://localhost:14000/dir`
- **Pebble Management URL**: `https://localhost:15000`
- **Challenge Test Server API**: `http://localhost:8055`
- **TLS**: Self-signed — Stalwart must trust the Pebble CA or skip TLS verification
- Pebble uses the challenge test server (`pebble-challtestsrv`) as its DNS resolver,
so challenge validation goes through controllable DNS/HTTP/TLS-ALPN responders.
### Challenge Test Server (challtestsrv)
The challenge test server provides a management API on port 8055 to programmatically
control DNS records and challenge responses used during ACME validation.
**Default behavior**: All A/AAAA queries resolve to `host.docker.internal` (the Docker
host), so Pebble can reach your test server on localhost automatically. Tests only need
to add challenge-specific records (TXT for DNS-01, HTTP tokens, etc.).
### How Pebble Reaches Your Test Server
When Pebble validates an HTTP-01 or TLS-ALPN-01 challenge, it:
1. Resolves the domain via challtestsrv — by default all domains resolve to the Docker host
2. Connects to the resolved IP on port **5002** (HTTP-01) or **5001** (TLS-ALPN-01)
These ports are configured in `pebble/pebble-config.json` (`httpPort` / `tlsPort`).
Change them to match whatever port your test Stalwart instance listens on.
#### Management API Examples
```bash
# Add a DNS-01 TXT challenge response
curl -s -X POST http://localhost:8055/add-dns \
-d '{"host": "_acme-challenge.mail.stalwart.test.", "value": "dns-challenge-token"}'
# Remove a DNS-01 TXT challenge response
curl -s -X POST http://localhost:8055/del-dns \
-d '{"host": "_acme-challenge.mail.stalwart.test."}'
# Add an HTTP-01 challenge response (served by challtestsrv itself)
curl -s -X POST http://localhost:8055/add-http \
-d '{"token": "challenge-token", "content": "challenge-key-authorization"}'
# Remove an HTTP-01 challenge response
curl -s -X POST http://localhost:8055/del-http \
-d '{"token": "challenge-token"}'
# Add a TLS-ALPN-01 challenge response (served by challtestsrv itself)
curl -s -X POST http://localhost:8055/add-tlsalpn \
-d '{"host": "mail.stalwart.test", "content": "base64-encoded-key-authz"}'
# Remove a TLS-ALPN-01 challenge response
curl -s -X POST http://localhost:8055/del-tlsalpn \
-d '{"host": "mail.stalwart.test"}'
# Clear all mock DNS/challenge data
curl -s -X POST http://localhost:8055/clear-request-count
```
## 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
```
+276
View File
@@ -0,0 +1,276 @@
###############################################################################
# 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.4.6
ports:
- "127.0.0.1:4500:4500"
environment:
FDB_NETWORKING_MODE: container
volumes:
- fdb-config:/var/fdb
tmpfs:
- /var/fdb/data
- /var/fdb/logs
healthcheck:
test: [ "CMD-SHELL", "fdbcli --exec 'status' --timeout 3 >/dev/null 2>&1" ]
interval: 2s
timeout: 5s
retries: 30
start_period: 5s
fdb-init:
image: foundationdb/foundationdb:7.4.6
depends_on:
foundationdb:
condition: service_healthy
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"
ports:
- "127.0.0.1:389:389"
- "127.0.0.1:636:636"
volumes:
- ./ldap/50-users.ldif:/seed/50-users.ldif:ro
- ./ldap/60-groups.ldif:/seed/60-groups.ldif:ro
- certs:/certs-shared:ro
entrypoint: [ "/bin/bash", "-c", "mkdir -p /container/service/slapd/assets/config/bootstrap/ldif/custom && cp /seed/*.ldif /container/service/slapd/assets/config/bootstrap/ldif/custom/ && cp /certs-shared/* /container/service/slapd/assets/certs/ 2>/dev/null; exec /container/tool/run" ]
# ---------------------------------------------------------------------------
# Pebble Challenge Test Server port 8055 (management API)
# Provides controllable DNS, HTTP, and TLS-ALPN challenge responders.
# Use the management API to add/remove challenge responses before requesting
# certificates from Pebble.
# ---------------------------------------------------------------------------
pebble-challtestsrv:
build:
context: ./pebble
dockerfile: Dockerfile.challtestsrv
image: stalwart-pebble-challtestsrv:local
ports:
- "127.0.0.1:8055:8055"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
# ---------------------------------------------------------------------------
# Pebble (ACME server) ports 14000 (directory) + 15000 (management)
# ---------------------------------------------------------------------------
pebble:
image: ghcr.io/letsencrypt/pebble:latest
depends_on:
- pebble-challtestsrv
environment:
PEBBLE_VA_NOSLEEP: "1"
PEBBLE_WFE_NONCEREJECT: "0"
ports:
- "127.0.0.1:14000:14000"
- "127.0.0.1:15000:15000"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./pebble/pebble-config.json:/test/config/pebble-config.json:ro
command: -config /test/config/pebble-config.json -dnsserver pebble-challtestsrv:8053
# ---------------------------------------------------------------------------
# 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
- pdns-data:/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
- pdns-data:/var/lib/powerdns
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
pdns-data:
driver: local
+205
View File
@@ -0,0 +1,205 @@
{
"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"
}
},
{
"name": "audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "stalwart",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
},
{
"clientId": "stalwart-fallback",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "stalwart-fallback-secret",
"redirectUris": [
"*"
],
"webOrigins": [
"*"
],
"publicClient": false,
"protocol": "openid-connect",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": true,
"serviceAccountsEnabled": true,
"defaultClientScopes": [
"openid"
],
"optionalClientScopes": [
"email",
"profile",
"roles"
],
"protocolMappers": [
{
"name": "email-claim-userinfo-only",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "email",
"id.token.claim": "false",
"access.token.claim": "false",
"claim.name": "email",
"userinfo.token.claim": "true",
"jsonType.label": "String"
}
},
{
"name": "groups-userinfo-only",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"full.path": "false",
"id.token.claim": "false",
"access.token.claim": "false",
"claim.name": "groups",
"userinfo.token.claim": "true"
}
},
{
"name": "audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "stalwart",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"users": [
{
"username": "[email protected]",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"firstName": "John",
"lastName": "Doe",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": [
"/[email protected]"
]
},
{
"username": "[email protected]",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"firstName": "Jane",
"lastName": "Smith",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": [
"/[email protected]",
"/[email protected]"
]
},
{
"username": "[email protected]",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"firstName": "Bill",
"lastName": "Foobar",
"credentials": [
{
"type": "password",
"value": "this is an OIDC password",
"temporary": false
}
],
"groups": [
"/[email protected]"
]
}
],
"groups": [
{
"name": "[email protected]",
"path": "/[email protected]"
},
{
"name": "[email protected]",
"path": "/[email protected]"
}
]
}
+71
View File
@@ -0,0 +1,71 @@
dn: ou=users,dc=stalwart,dc=test
objectClass: organizationalUnit
ou: users
dn: uid=john.doe,ou=users,dc=stalwart,dc=test
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
objectClass: extensibleObject
uid: john.doe
cn: John Doe
sn: Doe
givenName: John
mail: [email protected]
mailAlias: [email protected]
userPassword: this is John's LDAP password
shadowLastChange: 19723
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: [email protected]
userPassword: this is Jane's LDAP password
shadowLastChange: 19724
uidNumber: 10002
gidNumber: 10002
homeDirectory: /home/jane.smith
loginShell: /bin/bash
dn: uid=multi.mail,ou=users,dc=stalwart,dc=test
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: multi.mail
cn: Multi Mail
sn: Mail
givenName: Multi
mail: [email protected]
mail: [email protected]
mail: [email protected]
userPassword: this is Multi's LDAP password
uidNumber: 10004
gidNumber: 10004
homeDirectory: /home/multi.mail
loginShell: /bin/bash
dn: uid=bill.foobar,ou=users,dc=stalwart,dc=test
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
objectClass: extensibleObject
uid: bill.foobar
cn: Bill Foobar
sn: Foobar
givenName: Bill
mail: [email protected]
mailAlias: [email protected]
userPassword: this is Bill's LDAP password
uidNumber: 10003
gidNumber: 10003
homeDirectory: /home/bill.foobar
loginShell: /bin/bash
+20
View File
@@ -0,0 +1,20 @@
dn: ou=groups,dc=stalwart,dc=test
objectClass: organizationalUnit
ou: groups
dn: cn=sales,ou=groups,dc=stalwart,dc=test
objectClass: groupOfNames
objectClass: extensibleObject
cn: sales
mail: [email protected]
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
objectClass: extensibleObject
cn: corporate
mail: [email protected]
mailAlias: [email protected]
member: uid=bill.foobar,ou=users,dc=stalwart,dc=test
member: uid=jane.smith,ou=users,dc=stalwart,dc=test
@@ -0,0 +1,5 @@
FROM ghcr.io/letsencrypt/pebble-challtestsrv:latest AS upstream
FROM alpine:latest
COPY --from=upstream /app /app
ENTRYPOINT ["/bin/sh", "-c", "HOSTIP=$(getent hosts host.docker.internal 2>/dev/null | awk '{print $1}' | head -n1); exec /app -defaultIPv6 \"\" -defaultIPv4 \"${HOSTIP:-127.0.0.1}\""]
+17
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": 8898,
"tlsPort": 8899,
"ocspResponderURL": "",
"externalAccountBindingRequired": false,
"domainBlocklist": [],
"retryAfter": {
"authz": 3,
"order": 5
}
}
}
+14
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
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
set -e
# Wait for the SQLite database to be ready
sleep 2
# Skip if already initialized
if pdnsutil list-zone stalwart.test >/dev/null 2>&1; then
echo "Zone stalwart.test already exists; skipping init."
exit 0
fi
# Create the zone with default SOA + NS
pdnsutil create-zone stalwart.test ns1.stalwart.test
pdnsutil set-kind stalwart.test native
# Replace the default SOA with our own
pdnsutil replace-rrset stalwart.test '' SOA 'ns1.stalwart.test. admin.stalwart.test. 2024010101 3600 900 604800 86400'
# Add basic records
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 primary
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"
+3
View File
@@ -0,0 +1,3 @@
dnsupdate=yes
allow-dnsupdate-from=0.0.0.0/0
zone-cache-refresh-interval=0
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir \
"scim2-tester==0.2.8" \
"scim2-client==0.7.5" \
"scim2-models==0.6.12" \
"httpx==0.28.1"
CMD ["sleep", "infinity"]
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""Drives the third party SCIM client libraries against a Stalwart service provider."""
import argparse
import json
import sys
import traceback
import uuid
import httpx
from scim2_client.engines.httpx import SyncSCIMClient
from scim2_models import (
Context,
Error,
ListResponse,
PatchOp,
PatchOperation,
SearchRequest,
ServiceProviderConfig,
)
from scim2_tester import check_server
def allow_post_rfc7643_service_provider_config_attributes():
"""RFC 9865 adds a top level `pagination` attribute to ServiceProviderConfig and
draft-zollner-scim-interop-profile adds `interopProfileConformant`. scim2-models
validates against RFC 7643 alone and forbids both."""
ServiceProviderConfig.model_config["extra"] = "ignore"
ServiceProviderConfig.model_rebuild(force=True)
def build_client(url, token):
return SyncSCIMClient(
httpx.Client(
base_url=url,
headers={"Authorization": "Bearer " + token},
verify=False,
timeout=120.0,
)
)
def conformance(client, _domain):
results = check_server(client, raise_exceptions=False)
return {
"checks": [
{
"status": result.status.name,
"title": result.title,
"reason": result.reason,
"tags": sorted(result.tags) if result.tags else [],
"resource_type": result.resource_type,
}
for result in results
]
}
def lifecycle(client, domain):
steps = []
def record(name, detail=None):
steps.append({"step": name, "ok": True, "detail": detail})
user_model = client.get_resource_model("User")
group_model = client.get_resource_model("Group")
tag = uuid.uuid4().hex[:8]
user_name = "lifecycle-{}@{}".format(tag, domain)
renamed = "lifecycle-{}-renamed@{}".format(tag, domain)
alias = "lifecycle-{}-alias@{}".format(tag, domain)
display_name = "Lifecycle Person {}".format(tag)
group_name = "Lifecycle Team {}".format(tag)
user_id = None
group_id = None
try:
config = client.service_provider_config
assert config.patch.supported is True
assert config.etag.supported is True
assert config.sort.supported is True
assert config.change_password.supported is False
assert config.bulk.max_operations == 1000, config.bulk
assert config.filter.max_results == 200, config.filter
record("discover", {
"resource_types": sorted(
resource_type.id for resource_type in client.resource_types
),
"resource_models": sorted(
model.__name__ for model in client.resource_models
),
})
created = client.create(
user_model(
user_name=user_name,
display_name=display_name,
external_id="lifecycle-" + tag,
emails=[{"value": alias}],
),
expected_status_codes=[201],
raise_scim_errors=True,
)
user_id = created.id
assert created.user_name == user_name, created.user_name
assert created.display_name == display_name, created.display_name
assert created.meta.location.endswith("/Users/" + user_id), created.meta.location
assert created.meta.version, "the create response carried no version"
assert "last_modified" not in type(created.meta).model_fields, "meta.lastModified was published"
record("create_user", {"id": user_id, "version": created.meta.version})
fetched = client.query(
user_model, user_id, expected_status_codes=[200], raise_scim_errors=True
)
assert fetched.id == user_id
assert fetched.meta.version == created.meta.version
assert [email.value for email in fetched.emails] == [user_name, alias], fetched.emails
record("query_user_by_id")
response = client.client.get(
"/Users", params={"filter": 'userName eq "{}"'.format(user_name)}
)
assert response.status_code == 200, response.text
listed = ListResponse[user_model].model_validate(
response.json(), scim_ctx=Context.RESOURCE_QUERY_RESPONSE
)
assert listed.total_results == 1, listed.total_results
assert listed.resources[0].id == user_id
record("query_user_by_filter")
searched = client.search(
search_request=SearchRequest(filter='userName eq "{}"'.format(user_name)),
expected_status_codes=[200],
raise_scim_errors=True,
)
assert searched.total_results == 1, searched.total_results
record("search_endpoint")
patched = client.modify(
user_model,
user_id,
PatchOp[user_model](
operations=[
PatchOperation(op="replace", path="displayName", value="Patched " + tag),
PatchOperation(op="replace", path="active", value=False),
]
),
expected_status_codes=[200],
raise_scim_errors=True,
)
assert patched.display_name == "Patched " + tag, patched.display_name
assert patched.active is False, patched.active
assert patched.meta.version != created.meta.version, "the version did not change"
record("patch_user")
replaced = client.replace(
user_model(id=user_id, user_name=renamed),
expected_status_codes=[200],
raise_scim_errors=True,
)
assert replaced.user_name == renamed, replaced.user_name
assert replaced.active is True, "a replace must reset active to its default"
assert replaced.display_name is None, replaced.display_name
record("replace_user")
group = client.create(
group_model(display_name=group_name, members=[{"value": user_id}]),
expected_status_codes=[201],
raise_scim_errors=True,
)
group_id = group.id
assert group.display_name == group_name
assert [member.value for member in group.members] == [user_id], group.members
record("create_group_with_member", {"id": group_id})
member_view = client.query(
user_model, user_id, expected_status_codes=[200], raise_scim_errors=True
)
assert [entry.value for entry in member_view.groups] == [group_id], member_view.groups
record("membership_visible_on_user")
emptied = client.modify(
group_model,
group_id,
PatchOp[group_model](
operations=[PatchOperation(op="remove", path="members")]
),
expected_status_codes=[200],
raise_scim_errors=True,
)
assert not emptied.members, emptied.members
record("patch_group_members")
client.delete(group_model, group_id, expected_status_codes=[204], raise_scim_errors=True)
group_id = None
record("delete_group")
client.delete(user_model, user_id, expected_status_codes=[204], raise_scim_errors=True)
gone = client.query(
user_model,
user_id,
expected_status_codes=[404],
raise_scim_errors=False,
)
assert isinstance(gone, Error), gone
assert gone.status == 404, gone
user_id = None
record("delete_user")
except Exception as err:
steps.append(
{
"step": "aborted",
"ok": False,
"detail": "".join(
traceback.format_exception(type(err), err, err.__traceback__)
),
}
)
finally:
for model, resource_id in ((group_model, group_id), (user_model, user_id)):
if resource_id:
try:
client.delete(model, resource_id)
except Exception:
pass
return {"steps": steps}
def clients(client, domain):
"""Replays the payload shapes that Okta and the Keycloak community extensions send."""
steps = []
user_model = client.get_resource_model("User")
tag = uuid.uuid4().hex[:8]
created = []
def check(name, fn):
try:
fn()
steps.append({"step": name, "ok": True, "detail": None})
except Exception as err:
steps.append(
{
"step": name,
"ok": False,
"detail": "".join(
traceback.format_exception(type(err), err, err.__traceback__)
),
}
)
def post(name, payload):
def run():
response = client.client.post("/Users", json=payload)
assert response.status_code == 201, "{} {}".format(
response.status_code, response.text
)
body = response.json()
created.append(body["id"])
assert body["userName"] == payload["userName"], body
assert "password" not in response.text, body
if payload.get("displayName"):
assert body["displayName"] == payload["displayName"], body
elif payload.get("name", {}).get("givenName"):
expected = " ".join(
part
for part in (
payload["name"].get("givenName"),
payload["name"].get("familyName"),
)
if part
)
assert body["displayName"] == expected, body
user_model.model_validate(body, scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
check(name, run)
okta_name = "okta-{}@{}".format(tag, domain)
post(
"okta_create_user",
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": okta_name,
"name": {"givenName": "Barbara", "familyName": "Jensen"},
"emails": [{"primary": True, "value": okta_name, "type": "work"}],
"displayName": "Barbara Jensen",
"locale": "en-US",
"externalId": "00u" + tag,
"groups": [],
"password": "correct horse battery staple",
"active": True,
"title": "Vice President",
"userType": "Employee",
"phoneNumbers": [{"value": "555-0100", "type": "work"}],
},
)
keycloak_name = "keycloak-{}@{}".format(tag, domain)
post(
"keycloak_create_user",
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": keycloak_name,
"name": {"givenName": "Wile", "familyName": "Coyote"},
"emails": [{"value": keycloak_name, "primary": True}],
"active": True,
"externalId": "kc-" + tag,
},
)
entra_name = "entra-{}@{}".format(tag, domain)
post(
"entra_create_user",
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
],
"userName": entra_name,
"name": {"givenName": "Road", "familyName": "Runner"},
"displayName": "Road Runner",
"emails": [{"value": entra_name, "type": "work", "primary": True}],
"active": True,
"externalId": "entra-" + tag,
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
"department": "Sales",
"employeeNumber": "42",
},
},
)
if created:
okta_id = created[0]
def okta_put():
response = client.client.put(
"/Users/" + okta_id,
json={
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": okta_id,
"userName": okta_name,
"name": {"givenName": "Babs", "familyName": "Jensen"},
"emails": [{"primary": True, "value": okta_name, "type": "work"}],
"displayName": "Babs Jensen",
"active": False,
"password": "correct horse battery staple",
"title": "President",
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["displayName"] == "Babs Jensen", body
assert body["active"] is False, body
check("okta_replace_user", okta_put)
def keycloak_patch():
response = client.client.patch(
"/Users/" + created[1],
json={
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "value": {"active": "false"}}],
},
)
assert response.status_code == 200, response.text
assert response.json()["active"] is False, response.text
check("keycloak_deactivate_user", keycloak_patch)
def entra_patch():
response = client.client.patch(
"/Users/" + created[2],
json={
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{"op": "replace", "path": "name.givenName", "value": "Wile"},
{"op": "replace", "path": "displayName", "value": "Wile Runner"},
{
"op": "add",
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
"value": "Marketing",
},
],
},
)
assert response.status_code == 200, response.text
assert response.json()["displayName"] == "Wile Runner", response.text
check("entra_patch_user", entra_patch)
def okta_lookup():
response = client.client.get(
"/Users", params={"filter": 'userName eq "{}"'.format(okta_name)}
)
assert response.status_code == 200, response.text
listed = ListResponse[user_model].model_validate(
response.json(), scim_ctx=Context.RESOURCE_QUERY_RESPONSE
)
assert listed.total_results == 1, listed.total_results
check("okta_lookup_by_username", okta_lookup)
def rejects_typos():
response = client.client.post(
"/Users",
json={
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "typo-{}@{}".format(tag, domain),
"dispalyName": "Typo",
},
)
assert response.status_code == 400, response.text
assert response.json()["scimType"] == "invalidSyntax", response.text
check("misspelled_attributes_are_still_rejected", rejects_typos)
for resource_id in created:
try:
client.client.delete("/Users/" + resource_id)
except Exception:
pass
return {"steps": steps}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", required=True)
parser.add_argument("--token", required=True)
parser.add_argument("--domain", required=True)
parser.add_argument(
"--mode", required=True, choices=["conformance", "lifecycle", "clients"]
)
args = parser.parse_args()
allow_post_rfc7643_service_provider_config_attributes()
client = build_client(args.url, args.token)
try:
client.discover()
except Exception as err:
json.dump(
{"error": "discovery failed: " + "".join(
traceback.format_exception(type(err), err, err.__traceback__)
)},
sys.stdout,
)
sys.stdout.write("\n")
return
runner = {"conformance": conformance, "lifecycle": lifecycle, "clients": clients}[
args.mode
]
json.dump(runner(client, args.domain), sys.stdout)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
+26
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
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -eu
for i in $(seq 1 30); do
if fdbcli --exec 'status minimal' --timeout 5 2>&1 | grep -q "The database is available"; then
echo "FoundationDB already configured."
exit 0
fi
if fdbcli --exec 'configure new single memory' --timeout 5 2>&1 | grep -q "Database created"; then
echo "FoundationDB configured."
exit 0
fi
echo "Waiting for FoundationDB to be ready (attempt $i)..."
sleep 2
done
echo "ERROR: Failed to configure FoundationDB after retries" >&2
exit 1
+12
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."