From 238cce506d577ecb610ab94df9c6949f4782cbb0 Mon Sep 17 00:00:00 2001 From: Wayne Hayes Date: Sat, 13 Jun 2026 23:35:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20provision-stalwart.sh=20=E2=80=94=20con?= =?UTF-8?q?figure=20Stalwart=20fully=20from=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stalwart v0.16 keeps all config in Postgres, reachable via the x: JMAP management objects. This script writes everything the setup wizard would — stores (Garage S3 + Redis), listeners (with per-listener PROXY trust on the mail ports), the primary domain (+auto DKIM), admin + relay/catch-all accounts, TLS/DNS, and optional Authelia SSO — straight into Postgres over HTTP Basic. Idempotent (query-before-create), so re-runs are safe. Tiers (the DNS/TLS automation boundary): * Tier 1 (default, trustless): manual DNS, prints the records to publish. * Tier 2 (STALWART_DNS_API_KEY set): Stalwart auto-publishes DNS + ACME DNS-01 via the provider (Spaceship wired). Authelia SSO is opt-in (STALWART_SSO_ENABLE); admin + relay keep password auth as break-glass so enabling SSO can never lock you out. .env.example: documents the tiered DNS + SSO surface (core reuses existing fields; only tier-2 needs DNS provider keys). README: quickstart step + layout. Validated: bash -n; all JMAP payloads build valid JSON; read/idempotency paths against a live instance. NOT yet validated on a fresh boot (fallback admin -> create -> re-auth) or the OIDC login round-trip — verify on a throwaway deploy before relying on those paths. Shaped to drop into federatedSocial bootstrap.sh as cmd_provision_stalwart. Co-Authored-By: Claude Opus 4.8 --- .env.example | 32 ++++- README.md | 15 ++- provision-stalwart.sh | 265 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 provision-stalwart.sh diff --git a/.env.example b/.env.example index 99b1245..5179f6d 100644 --- a/.env.example +++ b/.env.example @@ -76,12 +76,34 @@ STALWART_S3_BUCKET=stalwart-mail STALWART_SMARTHOST= # ---------------------------------------------------------------------------- -# TLS — Stalwart self-manages certs via ACME DNS-01 (works behind the L4 proxy) +# Provisioning — `./provision-stalwart.sh` configures Stalwart from this .env # ---------------------------------------------------------------------------- -# DNS provider + token for the DNS-01 challenge. Leave blank to instead mount -# a certbot-issued cert (see config/config.toml [certificate]). -STALWART_ACME_PROVIDER= -STALWART_ACME_TOKEN= +# Run it AFTER `docker compose up -d`. It writes stores, listeners, the primary +# domain (+DKIM), the admin + relay/catch-all accounts, TLS/DNS and (optional) +# SSO — all via the x: JMAP API. Idempotent; re-run any time. +# +# TIER 1 (default, trustless): leave the DNS keys below blank. The domain is +# created in MANUAL dns/dkim mode and the script PRINTS the records to publish. +# Certs: mount your wildcard, or front :80 at the edge for HTTP-01. +# +# TIER 2 (auto-DNS): set the DNS provider keys and Stalwart auto-publishes every +# record (MX/SPF/DKIM/DMARC/MTA-STS/SRV/CAA/TLS-RPT) and does ACME DNS-01. +# Provider tokens are fiddly and provider-specific (HE's API is flaky; Spaceship +# needs API access enabled on the key) — so this stays opt-in and user-managed. +STALWART_DNS_PROVIDER=spaceship # currently only 'spaceship' is wired +STALWART_DNS_API_KEY= # set => tier 2; blank => tier 1 +STALWART_DNS_API_SECRET= +STALWART_DNS_DESC=managed # label for the x:DnsServer entry +# ACME contact email (enables Let's Encrypt DNS-01 in tier 2). Blank = skip ACME. +STALWART_ACME_CONTACT= + +# --- SSO: let Authelia manage Stalwart login (optional) --------------------- +# true => provision-stalwart.sh creates an OIDC directory pointing at +# AUTHELIA_PORTAL_URL and prints the Authelia client block to paste. admin and +# the relay account KEEP password auth as break-glass, so SSO can't lock you out. +# (Login flow is UNVALIDATED on a throwaway here — test before trusting upstream.) +STALWART_SSO_ENABLE=false +STALWART_OIDC_CLIENT_SECRET= # shared secret for the Stalwart<->Authelia client # ============================================================================ diff --git a/README.md b/README.md index f7477ab..7f79e73 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ tailwart/ │ ├── caddy.json # :25/465/587/143/993 mail + :443 SNI fan-out → stalwart over the tailnet │ ├── docker-compose.yml # deploy on any public-IP, tailnet, tag:reverse-proxy host │ └── README.md +├── provision-stalwart.sh # one-shot: configure Stalwart entirely from .env (idempotent) ├── acl-snippet.hujson # tag:stalwart owner + grants to merge into your policy ├── .env.example # operator surface — copy to .env └── .gitignore @@ -44,12 +45,20 @@ cp .env.example .env && $EDITOR .env # fill secrets (see CLAUDE.md prereq # 2. admin console: assign tag:stalwart to the OAuth client + paste acl-snippet # 3. bring up the mailbox (tailnet-only) docker compose up -d -# 4. bring up the edge (binds public mail ports; can be a different host) +# 4. configure Stalwart entirely from .env — stores, listeners, domain (+DKIM), +# admin + relay/catch-all accounts, TLS/DNS, optional Authelia SSO. Idempotent. +./provision-stalwart.sh # add --print-dns to also dump records to publish +# 5. bring up the edge (binds public mail ports; can be a different host) cd caddy && docker compose up -d --build ``` -Then point `infinidim.net`'s MX at the edge host, add SPF/DKIM/DMARC, and finish -configuration in Stalwart's web admin (`mail.infinidim.net`). +`provision-stalwart.sh` replaces the setup wizard: it writes config straight into +Postgres via the `x:` JMAP API. **Tier 1** (default) configures everything and +*prints* the DNS records for you to publish; **tier 2** (set `STALWART_DNS_API_KEY`) +lets Stalwart auto-publish DNS + run ACME DNS-01. See the comments in `.env.example`. + +Then point `infinidim.net`'s MX at the edge host (or let tier-2 publish it) and +finish any opinionated bits (spam tuning, retention) in the web admin. ## Status diff --git a/provision-stalwart.sh b/provision-stalwart.sh new file mode 100644 index 0000000..e1445fd --- /dev/null +++ b/provision-stalwart.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# provision-stalwart.sh — bring a fresh Stalwart up *fully configured from .env*. +# +# Stalwart v0.16 keeps ALL of its config in Postgres (not files), reachable +# through the `x:` JMAP management objects. config/config.json only tells the +# image where Postgres lives; this script writes everything else the setup +# wizard would — stores, listeners, the primary domain (+DKIM), the admin and +# relay/catch-all accounts, TLS/DNS, and (optionally) SSO via Authelia — so the +# operator never has to touch the wizard for a working mail server. +# +# Idempotent: every object is keyed by a stable name/singleton and created only +# if missing, so re-running is safe. Run AFTER `docker compose up -d` (the +# stalwart sidecar must be reachable on the tailnet). +# +# ./provision-stalwart.sh # provision from ./.env +# ./provision-stalwart.sh --print-dns # also dump the zone records to publish +# +# Designed to drop into federatedSocial's bootstrap.sh as `cmd_provision_stalwart`. +# +# TIERS (the DNS/TLS automation boundary): +# * Default (trustless): domain in MANUAL dkim/dns mode; certs via the existing +# wildcard / HTTP-01; the script PRINTS the exact DNS records to publish. +# * Opt-in: if STALWART_DNS_API_KEY is set AND the provider probe succeeds, the +# domain flips to AUTOMATIC — Stalwart auto-publishes DNS + does DNS-01. +# +# NOTE: the Authelia-SSO step (provision_oidc) writes the Stalwart side via API +# but only PRINTS the Authelia client block to paste — editing Authelia's +# hand-maintained YAML from a script is deliberately avoided. The admin and +# relay accounts ALWAYS keep password auth as break-glass, so enabling SSO can +# never lock you out of Stalwart. The SSO login flow could not be validated +# against a throwaway instance here — verify it on a test deploy before trusting. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Setup: load .env, derive endpoint + admin auth +# --------------------------------------------------------------------------- +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${REPO_ROOT}/.env" +[[ -f "$ENV_FILE" ]] || { echo "Error: ${ENV_FILE} not found (cp .env.example .env)" >&2; exit 1; } +set -a; source "$ENV_FILE"; set +a + +PRINT_DNS=0; [[ "${1:-}" == "--print-dns" ]] && PRINT_DNS=1 + +for v in STALWART_MAGIC_NAME TS_TAILNET STALWART_DOMAIN STALWART_HOSTNAME \ + STALWART_FALLBACK_ADMIN_SECRET REDIS_MAGIC_NAME STALWART_REDIS_DB \ + GARAGE_MAGIC_NAME GARAGE_REGION GARAGE_ACCESS_KEY_ID GARAGE_SECRET_ACCESS_KEY \ + STALWART_S3_BUCKET SMTP_USER SMTP_PASSWORD; do + [[ -n "${!v:-}" ]] || { echo "Error: required \$$v is empty in .env" >&2; exit 1; } +done + +SW_HOST="${STALWART_MAGIC_NAME}.${TS_TAILNET}" +SW_BASE="http://${SW_HOST}:8080" +SW_JMAP="${SW_BASE}/jmap" +NET_TRUST='{"100.64.0.0/10":true,"fd7a:115c:a1e0::/48":true}' # tailnet CGNAT + ULA +RELAY_LOCAL="${SMTP_USER%@*}" # zaphod +RELAY_DOMAIN="${SMTP_USER#*@}" # infinidim.net +ACCT_ID="b" # principals primary account + +log(){ printf ' %s\n' "$*"; } +die(){ echo "Error: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# JMAP helpers (HTTP Basic — no PKCE/Bearer needed for scripting) +# --------------------------------------------------------------------------- +SW_AUTH="" # set by sw_auth() + +# sw_call -> raw response on stdout +sw_call(){ + curl -s -m 25 -u "$SW_AUTH" -H 'Content-Type: application/json' "$SW_JMAP" -X POST \ + --data "$(jq -nc --argjson mc "$1" '{using:["urn:ietf:params:jmap:core","urn:stalwart:jmap"],methodCalls:$mc}')" +} + +# sw_ok — die if any error/notCreated/notUpdated/notDestroyed present +sw_ok(){ + local r="$1" + if echo "$r" | jq -e '.methodResponses[0][0]=="error"' >/dev/null 2>&1; then + die "JMAP error: $(echo "$r" | jq -c '.methodResponses[0][1]')" + fi + if echo "$r" | jq -e '.methodResponses[0][1] | (.notCreated//{}|length>0) or (.notUpdated//{}|length>0) or (.notDestroyed//[]|length>0)' >/dev/null 2>&1; then + die "JMAP set rejected: $(echo "$r" | jq -c '.methodResponses[0][1]|{notCreated,notUpdated,notDestroyed}')" + fi +} + +# find id of object of TYPE whose `name` == NAME (empty if none) +sw_find_id(){ # $1 type $2 name + local r; r=$(sw_call "$(jq -nc --arg t "$1" '[[($t+"/query"),{accountId:"'"$ACCT_ID"'"},"0"],[($t+"/get"),{accountId:"'"$ACCT_ID"'","#ids":{resultOf:"0",name:($t+"/query"),path:"/ids"},properties:["name"]},"1"]]')") + echo "$r" | jq -r --arg n "$2" '.methodResponses[1][1].list[]? | select(.name==$n) | .id' | head -1 +} + +# authenticate: prefer a real admin account; fall back to the first-boot virtual admin +sw_auth(){ + local secret="$STALWART_FALLBACK_ADMIN_SECRET" u + for u in "admin@${STALWART_DOMAIN}" "admin"; do + if curl -s -m 8 -u "${u}:${secret}" -o /dev/null -w '%{http_code}' "${SW_JMAP}/session" 2>/dev/null | grep -q 200; then + SW_AUTH="${u}:${secret}"; log "authenticated as ${u}"; return 0 + fi + done + die "cannot authenticate to ${SW_JMAP} as admin (check STALWART_FALLBACK_ADMIN_SECRET / first-boot state)" +} + +# =========================================================================== +# Provisioning steps +# =========================================================================== + +ensure_admin(){ # persistent superuser (survives once config is written; fallback then goes inert) + [[ -n "${DOMAIN_ID:-}" ]] || die "ensure_admin needs the domain first" + local id; id=$(sw_find_id x:Account admin) + if [[ -z "$id" ]]; then + log "creating persistent admin account" + sw_ok "$(sw_call "$(jq -nc --arg pw "$STALWART_FALLBACK_ADMIN_SECRET" --arg dom "$DOMAIN_ID" \ + '[["x:Account/set",{accountId:"'"$ACCT_ID"'",create:{a:{"@type":"User",name:"admin",domainId:$dom,description:"System administrator",roles:{"@type":"Admin"},credentials:{"0":{"@type":"Password",secret:$pw}}}}},"0"]]')")" + else log "admin account present"; fi +} + +re_auth(){ # switch to the persistent admin so the rest of the run is immune to the + # first-boot fallback going inert the moment a real admin exists. + local u="admin@${STALWART_DOMAIN}" + if curl -s -m 8 -u "${u}:${STALWART_FALLBACK_ADMIN_SECRET}" -o /dev/null -w '%{http_code}' "${SW_JMAP}/session" 2>/dev/null | grep -q 200; then + SW_AUTH="${u}:${STALWART_FALLBACK_ADMIN_SECRET}"; log "re-authenticated as ${u}" + fi +} + +ensure_stores(){ + log "blob store -> Garage S3" + sw_ok "$(sw_call "$(jq -nc \ + --arg ep "http://${GARAGE_MAGIC_NAME}.${TS_TAILNET}:3900" --arg region "$GARAGE_REGION" \ + --arg bucket "$STALWART_S3_BUCKET" --arg ak "$GARAGE_ACCESS_KEY_ID" --arg sk "$GARAGE_SECRET_ACCESS_KEY" \ + '[["x:BlobStore/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{"@type":"S3",region:{"@type":"Custom",customEndpoint:$ep,customRegion:$region},bucket:$bucket,accessKey:$ak,secretKey:{"@type":"Value",secret:$sk},verifyAfterWrite:true}}},"0"]]')")" + log "in-memory store -> Redis db ${STALWART_REDIS_DB}" + sw_ok "$(sw_call "$(jq -nc --arg url "redis://${REDIS_MAGIC_NAME}.${TS_TAILNET}:6379/${STALWART_REDIS_DB}" \ + '[["x:InMemoryStore/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{"@type":"Redis",url:$url}}},"0"]]')")" +} + +ensure_listeners(){ + # name port protocol implicitTls proxyTrust(0/1) + local rows=( + "smtp 25 smtp 0 1" "submission 587 smtp 0 1" "submissions 465 smtp 1 1" + "imap 143 imap 0 1" "imaps 993 imap 1 1" + "http 8080 http 0 0" "https 443 http 1 0" + "sieve 4190 manageSieve 0 0" + ) + local row name port proto impl trust id + for row in "${rows[@]}"; do + read -r name port proto impl trust <<<"$row" + id=$(sw_find_id x:NetworkListener "$name"); [[ -n "$id" ]] && { log "listener ${name} present"; continue; } + log "creating listener ${name} (:${port})" + local pt='{}'; [[ "$trust" == 1 ]] && pt="$NET_TRUST" + sw_ok "$(sw_call "$(jq -nc --arg n "$name" --arg bind "[::]:${port}" --arg p "$proto" \ + --argjson impl "$([[ $impl == 1 ]] && echo true || echo false)" --argjson pt "$pt" \ + '[["x:NetworkListener/set",{accountId:"'"$ACCT_ID"'",create:{l:{name:$n,bind:{($bind):true},protocol:$p,useTls:true,tlsImplicit:$impl,overrideProxyTrustedNetworks:$pt}}},"0"]]')")" + done +} + +ensure_domain(){ # primary mail domain; DKIM auto-gen; dns/cert mode set later by tier + DOMAIN_ID=$(sw_find_id x:Domain "$STALWART_DOMAIN") + if [[ -z "$DOMAIN_ID" ]]; then + log "creating domain ${STALWART_DOMAIN} (auto DKIM)" + local r; r=$(sw_call "$(jq -nc --arg d "$STALWART_DOMAIN" --arg ca "$SMTP_USER" \ + '[["x:Domain/set",{accountId:"'"$ACCT_ID"'",create:{d:{name:$d,isEnabled:true,description:"Primary mail domain",catchAllAddress:$ca,subAddressing:{"@type":"Enabled"},dkimManagement:{"@type":"Automatic",algorithms:{Dkim1Ed25519Sha256:true,Dkim1RsaSha256:true},selectorTemplate:"v{version}-{algorithm}-{date-%Y%m%d}",rotateAfter:7776000000,retireAfter:604800000,deleteAfter:2592000000}}}},"0"]]')") + sw_ok "$r"; DOMAIN_ID=$(echo "$r" | jq -r '.methodResponses[0][1].created.d.id') + else log "domain ${STALWART_DOMAIN} present"; fi +} + +ensure_accounts(){ # relay / catch-all account from SMTP_USER (admin handled separately) + local id; id=$(sw_find_id x:Account "$RELAY_LOCAL") + if [[ -z "$id" ]]; then + log "creating relay account ${SMTP_USER}" + sw_ok "$(sw_call "$(jq -nc --arg n "$RELAY_LOCAL" --arg dom "$DOMAIN_ID" --arg pw "$SMTP_PASSWORD" \ + '[["x:Account/set",{accountId:"'"$ACCT_ID"'",create:{a:{"@type":"User",name:$n,domainId:$dom,description:"Relay / catch-all",credentials:{"0":{"@type":"Password",secret:$pw}}}}},"0"]]')")" + else log "relay account ${SMTP_USER} present"; fi +} + +ensure_system(){ + log "system settings: hostname ${STALWART_HOSTNAME}" + sw_ok "$(sw_call "$(jq -nc --arg h "$STALWART_HOSTNAME" --arg d "$DOMAIN_ID" \ + '[["x:SystemSettings/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{defaultHostname:$h,defaultDomainId:$d}}},"0"]]')")" +} + +# ---- TLS / DNS tier ------------------------------------------------------- +ensure_dns_tier(){ + if [[ -n "${STALWART_DNS_API_KEY:-}" && "${STALWART_DNS_PROVIDER:-spaceship}" == "spaceship" ]]; then + log "DNS provider key present -> tier 2 (automatic publish via Spaceship)" + local sid; sid=$(sw_find_id x:DnsServer "${STALWART_DNS_DESC:-managed}") + if [[ -z "$sid" ]]; then + local r; r=$(sw_call "$(jq -nc --arg k "$STALWART_DNS_API_KEY" --arg s "${STALWART_DNS_API_SECRET:-}" --arg n "${STALWART_DNS_DESC:-managed}" \ + '[["x:DnsServer/set",{accountId:"'"$ACCT_ID"'",create:{s:{"@type":"Spaceship",description:$n,apiKey:$k,secret:$s,ttl:300000,pollingInterval:15000,propagationTimeout:60000}}},"0"]]')") + sw_ok "$r"; sid=$(echo "$r" | jq -r '.methodResponses[0][1].created.s.id') + fi + log "domain -> Automatic DNS (origin ${STALWART_DOMAIN}) + ACME DNS-01" + sw_ok "$(sw_call "$(jq -nc --arg sid "$sid" --arg origin "$STALWART_DOMAIN" \ + '[["x:Domain/set",{accountId:"'"$ACCT_ID"'",update:{"'"$DOMAIN_ID"'":{dnsManagement:{"@type":"Automatic",dnsServerId:$sid,origin:$origin,publishRecords:{autoConfig:true,autoConfigLegacy:true,autoDiscover:true,caa:true,dkim:true,dmarc:true,mtaSts:true,mx:true,spf:true,srv:true,tlsRpt:true}}}}},"0"]]')")" + ensure_acme_dns01 + PRINT_DNS=0 # records publish themselves + else + log "no DNS API key -> tier 1 (trustless): manual DNS, records printed below" + PRINT_DNS=1 + fi +} + +ensure_acme_dns01(){ + [[ -z "${STALWART_ACME_CONTACT:-}" ]] && { log " (set STALWART_ACME_CONTACT to enable DNS-01 ACME)"; return; } + local id; id=$(sw_find_id x:AcmeProvider letsencrypt) + [[ -n "$id" ]] && { log " ACME provider present"; return; } + log " creating ACME provider (Let's Encrypt, DNS-01)" + sw_ok "$(sw_call "$(jq -nc --arg c "$STALWART_ACME_CONTACT" \ + '[["x:AcmeProvider/set",{accountId:"'"$ACCT_ID"'",create:{p:{name:"letsencrypt",directory:"https://acme-v02.api.letsencrypt.org/directory",challengeType:"Dns01",contact:$c,renewBefore:"R23",maxRetries:10}}},"0"]]')")" +} + +print_dns_records(){ + [[ "$PRINT_DNS" == 1 ]] || return 0 + echo; echo "=== Publish these DNS records for ${STALWART_DOMAIN} (tier 1 / manual) ===" + sw_call "$(jq -nc --arg id "$DOMAIN_ID" '[["x:Domain/get",{accountId:"'"$ACCT_ID"'",ids:[$id],properties:["dnsZoneFile"]},"0"]]')" \ + | jq -r '.methodResponses[0][1].list[0].dnsZoneFile // "(zone file unavailable)"' + echo "=== then re-run with STALWART_DNS_API_KEY set for automatic publishing ===" +} + +# ---- Authelia SSO (opt-in; admin/relay keep password auth as break-glass) -- +provision_oidc(){ + [[ "${STALWART_SSO_ENABLE:-false}" == "true" ]] || { log "SSO disabled (set STALWART_SSO_ENABLE=true to wire Authelia)"; return; } + [[ -n "${AUTHELIA_PORTAL_URL:-}" ]] || die "STALWART_SSO_ENABLE=true but AUTHELIA_PORTAL_URL is empty" + local issuer="$AUTHELIA_PORTAL_URL" id + id=$(sw_find_id x:Directory "authelia") + if [[ -z "$id" ]]; then + log "creating Stalwart OIDC directory -> ${issuer}" + sw_ok "$(sw_call "$(jq -nc --arg iss "$issuer" --arg ud "$STALWART_DOMAIN" \ + '[["x:Directory/set",{accountId:"'"$ACCT_ID"'",create:{d:{"@type":"Oidc",description:"authelia",issuerUrl:$iss,claimUsername:"preferred_username",claimName:"name",claimGroups:"groups",usernameDomain:$ud}}},"0"]]')")" + else log "OIDC directory present"; fi + cat <>> Add this client to authelia/configuration.yml under + >>> identity_providers.oidc.clients (hash the secret with: + >>> docker run --rm authelia/authelia authelia crypto hash generate pbkdf2 --password '') + - client_id: stalwart + client_name: Stalwart Mail + client_secret: '}>' + public: false + authorization_policy: two_factor + redirect_uris: + - https://${STALWART_HOSTNAME}/auth/oauth + scopes: [openid, profile, email, groups] + >>> admin + ${SMTP_USER} keep password auth as break-glass — SSO can't lock you out. +EOF +} + +# =========================================================================== +main(){ + echo "Provisioning Stalwart at ${SW_BASE} from ${ENV_FILE}" + command -v jq >/dev/null || die "jq is required" + curl -s -m 8 -o /dev/null "${SW_BASE}/jmap/session" || die "cannot reach ${SW_BASE} (is the stack up?)" + sw_auth + ensure_stores + ensure_domain + ensure_admin + re_auth + ensure_accounts + ensure_listeners + ensure_system + ensure_dns_tier + provision_oidc + print_dns_records + echo "Done. Listeners rebind on container restart if newly created: docker compose restart stalwart" +} +# Run only when executed directly — so the file can be sourced for testing. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi