第 13b 章 客户端、Groupware & CLI 管理:#pConfigFile

配置即代码:导出、计划、应用与 Git 流水线

整个服务器状态以 YAML 文件形式保存在 Git 中:租户、域(DKIM 选择器、MTA-STS、路由)、含别名、转发和自动回复的邮箱、通讯组列表、共享邮箱、会议室、全局地址列表和资源规则、共享和委派、SMTP、智能主机、路由、传输规则、垃圾邮件和病毒防护、防火墙和自动封禁、TLS、身份源、API 密钥(仅元数据)、Webhook、通知、运维告警、POP3、文件、预约、归档以及 DNS 故障转移。nextxch-cli config plan 会像 terraform plan 一样显示将要创建、更改和删除的内容;apply 会先检查全部内容,再按固定顺序应用。管理:系统 > 以文件形式配置。

运维提示与架构说明

机密信息绝不写入文件,也绝不导出:密码、令牌和密钥写作 secretRef: {env: NAME} 或 secretRef: {file: path},并在应用时解析(在 CI 运行器上由 nextxch-cli 解析;在服务器上仅限 NEXTXCH_SECRET_* 变量以及 <data>/secrets/ 下的文件)。DKIM 密钥、证书和密码哈希保留在服务器上。与 API v1 一样属于 Business 功能;JSON Schema 免费。

1. 文件格式:apiVersion、kind、metadata.name、spec

一个文件包含多个文档(以 --- 分隔),或一个文件夹中每个领域一个文件(config export --split)。文件中缺少的字段在服务器上保持不变;列表(别名、成员)作为整体计算。

参考 / 配置
# yaml-language-server: $schema=https://nextxch.com/schemas/nextxch-config-v1.schema.json
apiVersion: nextxch/v1
kind: Manifest            # optional: areas in which --prune may delete
metadata: {name: nextxch}
spec: {areas: [domains, accounts, sharing, transport]}
---
apiVersion: nextxch/v1
kind: Domain
metadata: {name: company.com}
spec:
  tenant: default
  catchAll: info@company.com
  dkim: {mode: dual, rsaSelector: s2026, ed25519Selector: ed2026}   # keys stay on the server
  mtaSts: {mode: enforce, maxAgeSeconds: 604800, mxHosts: [mail.company.com]}
---
apiVersion: nextxch/v1
kind: Account
metadata: {name: anna.smith@company.com}
spec:
  displayName: Anna Smith
  role: User                      # User, TenantAdmin, ServerAdmin, ComplianceAuditor
  quotaMb: 10240
  aliases: [anna@company.com]
  forwarding: {address: anna@private.example, keepCopy: true}
  outOfOffice: {enabled: false}
  password: {secretRef: {env: ANNA_PASSWORD}}   # never the password itself
---
apiVersion: nextxch/v1
kind: Account
metadata: {name: info@company.com}
spec: {type: shared, displayName: Info mailbox}   # user, shared, room, equipment
---
apiVersion: nextxch/v1
kind: Share
metadata: {name: "info@company.com/mailbox -> anna.smith@company.com"}
spec: {permissions: [read, write, sendAs]}
---
apiVersion: nextxch/v1
kind: SmtpSettings
metadata: {name: server}             # server-wide settings are always named "server"
spec:
  outboundSmartHost: smtp.provider.example
  outboundSmartPort: 587
  smartHostPassword: {secretRef: {file: secrets/smarthost.txt}}

2. 使用 nextxch-cli 的工作流

先导出并提交,然后通过拉取请求进行更改。计划输出示例:

参考 / 配置
export NEXTXCH_URL=https://mail.company.com
export NEXTXCH_API_KEY=smk_...            # key with config.read or config.write
nextxch-cli config export --split nextxch/
nextxch-cli config validate -f nextxch/    # offline: kinds and fields
nextxch-cli config plan -f nextxch/

  ~ Domain company.com
      mtaSts.mode: "testing" -> "enforce"
  ~ Account anna.smith@company.com
      quotaMb: 10240 -> 20480
      aliases: ["anna@company.com"] -> ["a.smith@company.com","anna@company.com"]
      ~ password: (secret changed)
  + Account info@company.com
      type: "shared"
      displayName: "Info mailbox"

Not in the file (1), deleted only with --prune:
    Account old@company.com

Plan: 1 to create, 2 to update, 0 to delete, 41 unchanged.
# exit code 2 = changes

nextxch-cli config apply -f nextxch/ --prune --yes
nextxch-cli config history

3. GitHub Actions:拉取请求时执行 plan,合并时执行 apply

包含注释步骤的完整文件:仓库中的 deploy/gitops/github-actions.yml。API 密钥来自仓库机密,绝不来自该文件。

参考 / 配置
name: nextxch config
on:
  pull_request: {paths: ["nextxch/**"]}
  push: {branches: [main], paths: ["nextxch/**"]}
permissions: {contents: read, pull-requests: write}
concurrency: {group: nextxch-config, cancel-in-progress: false}
env: {NEXTXCH_IMAGE: "nextxch/server:latest"}
jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Plan
        env:
          NEXTXCH_URL: ${{ vars.NEXTXCH_URL }}
          NEXTXCH_API_KEY: ${{ secrets.NEXTXCH_CONFIG_READ_KEY }}
        run: |
          set +e
          docker run --rm -e NEXTXCH_URL -e NEXTXCH_API_KEY -e NO_COLOR=1 -v "$PWD:/work" -w /work \
            --entrypoint nextxch-cli "$NEXTXCH_IMAGE" config plan -f nextxch/ --prune > plan.txt 2>&1
          code=$?; cat plan.txt; [ "$code" -ne 1 ]
      # danach: actions/github-script postet plan.txt als Kommentar (siehe deploy/gitops)
  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - env:
          NEXTXCH_URL: ${{ vars.NEXTXCH_URL }}
          NEXTXCH_API_KEY: ${{ secrets.NEXTXCH_CONFIG_WRITE_KEY }}
          SMARTHOST_PASSWORD: ${{ secrets.SMARTHOST_PASSWORD }}
        run: |
          docker run --rm -e NEXTXCH_URL -e NEXTXCH_API_KEY -e SMARTHOST_PASSWORD -v "$PWD:/work" -w /work \
            --entrypoint nextxch-cli "$NEXTXCH_IMAGE" config apply -f nextxch/ --prune --yes

4. GitLab CI:以合并请求备注形式输出 plan,在默认分支上执行 apply

完整文件:deploy/gitops/gitlab-ci.yml。密钥为已屏蔽、受保护的 CI/CD 变量;resource_group 可防止两次 apply 同时运行。

参考 / 配置
stages: [validate, plan, apply]
variables: {CONFIG_DIR: nextxch, NO_COLOR: "1"}
.nextxch-cli:
  image: {name: "nextxch/server:latest", entrypoint: [""]}
validate:
  extends: .nextxch-cli
  stage: validate
  script: [nextxch-cli config validate -f "$CONFIG_DIR"]
plan:
  extends: .nextxch-cli
  stage: plan
  rules: [{if: '$CI_PIPELINE_SOURCE == "merge_request_event"'}]
  variables: {NEXTXCH_API_KEY: $NEXTXCH_CONFIG_READ_KEY}
  script:
    - set +e
    - nextxch-cli config plan -f "$CONFIG_DIR" --prune > plan.txt 2>&1; code=$?
    - cat plan.txt; test "$code" -ne 1
  artifacts: {when: always, paths: [plan.txt]}
# plan-comment: curl auf /merge_requests/:iid/notes mit plan.txt (siehe deploy/gitops)
apply:
  extends: .nextxch-cli
  stage: apply
  resource_group: nextxch-config
  environment: production
  rules: [{if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'}]
  variables: {NEXTXCH_API_KEY: $NEXTXCH_CONFIG_WRITE_KEY}
  script: [nextxch-cli config apply -f "$CONFIG_DIR" --prune --yes]

5. 编辑器中的 JSON Schema 与 REST 调用

VS Code(Red Hat "YAML" 扩展)和 JetBrains IDE 会在输入时检查文件。每台服务器还在 /api/v1/config/schema 提供该架构。

参考 / 配置
# .vscode/settings.json
{
  "yaml.schemas": {
    "https://nextxch.com/schemas/nextxch-config-v1.schema.json": ["nextxch/*.yaml"]
  }
}

# Plan ohne CLI, direkt per REST (YAML als Body):
curl -s -X POST "$NEXTXCH_URL/api/v1/config/plan?prune=true" \
  -H "Authorization: Bearer $NEXTXCH_API_KEY" \
  -H "Content-Type: application/yaml" --data-binary @nextxch.yaml

6. 列表中的密钥与无需重启的 ACME

带认证头和来自流水线的 HMAC 密钥的 MTA 钩子、带 Webhook 密钥的告警目标,以及切换到使用 DNS-01 的 Let's Encrypt:一次应用,无需重启。

参考 / 配置
apiVersion: nextxch/v1
kind: MtaFilters
metadata: {name: server}
spec:
  hooks:
    - name: spamcheck
      url: https://hooks.company.com/check
      stages: [data]
      authHeaderName: Authorization
      authHeaderValue: {secretRef: {env: HOOK_TOKEN}}   # in the plan: hooks[spamcheck].authHeaderValue
      hmacSecret: {secretRef: {file: secrets/hook-hmac.txt}}
---
apiVersion: nextxch/v1
kind: OpsAlerts
metadata: {name: server}
spec:
  targets:
    - name: pager
      type: webhook
      address: https://pager.company.com/hook
      secret: {secretRef: {env: PAGER_SECRET}}
---
apiVersion: nextxch/v1
kind: TlsSettings
metadata: {name: server}
spec:
  acme:
    enabled: true
    directoryUrl: https://acme-v02.api.letsencrypt.org/directory
    contactEmail: postmaster@company.com
    challenge: Dns01
    renewBeforeExpiryDays: 30

设置、默认值与最佳实践

设置 / 位置 说明 默认值 生产环境最佳实践
config export [--area a,b] [--split ordner/] [-o datei] 以确定性方式导出服务器状态:固定的文档顺序、排序后的列表、无时间戳。两次导出得到完全相同的字节,Git diff 只显示真实的更改。 alle Bereiche 对代码仓库使用 --split(每个领域一个文件:domains.yaml、accounts.yaml ...);小型拉取请求依然易于阅读。
config plan -f datei|ordner [--prune] 按对象显示 + 创建、~ 更新(字段:旧值 -> 新值)和 - 删除。退出代码 0 = 无变更,2 = 有变更,1 = 错误。机密信息仅显示为 "(secret changed)"。 ohne Löschen 在拉取请求上运行,并将结果作为评论发布(示例见下文)。
config apply -f ... [--prune] [--yes] [--force] [--dry-run] 先检查所有内容(字段、引用、机密、安全规则),然后按固定顺序创建和更新(租户、域、账户、列表、规则、共享、传输……),删除则按相反顺序进行。如果某一步失败,已完成的步骤会被撤销。每项更改都会写入审计日志。 fragt nach 在流水线中请始终传入 --yes;否则在没有终端时 apply 会拒绝执行。--dry-run 等同于 plan。
--prune und Manifest 删除仅在使用 --prune 时发生,且仅限 Manifest 文档所列的范围(没有 Manifest 时:仅限文件中出现的类型)。服务器设置永远不会被删除。 aus 导出会写入 Manifest;如果您只管理部分内容,请缩小范围(areas)。
Schutz: letzter Admin, Domain mit Postfächern 如果计划会导致没有任何活动的服务器管理员,或要删除仍有邮箱的域,则会被拒绝。--force 可强制执行(此时该域会被停用而不是删除)。API 密钥不能吊销自身。 aktiv 切勿在流水线中硬编码 --force。
secretRef: {env: NAME} | {file: pfad} 引用机密而非其值。nextxch-cli 在运行器上解析它(任意变量,或相对于配置的文件)。服务器自身只读取 NEXTXCH_SECRET_* 以及 <data>/secrets/ 下的文件。再次应用相同的值不算作更改。 - 将机密从 GitHub/GitLab 机密存储以环境变量形式传递给作业。
Scopes config.read / config.write REST:GET /api/v1/config/export、POST /api/v1/config/plan、POST /api/v1/config/apply、GET /api/v1/config/history、GET /api/v1/config/schema(免费)。密钥:租户集成密钥;仅当其创建者仍是管理员时才有效。服务器管理员的密钥可查看整个服务器,租户管理员的密钥仅能查看该租户。config.read 足以用于导出和规划,应用则需要 config.write。 - 两个密钥:config.read 用于拉取请求中的 plan,config.write 仅用于 main 分支上的 apply 作业,并尽可能限制为 Runner 的 IP。
Mandanten-Admins 租户管理员(会话或其某个密钥)只能导出、规划和应用其租户内的对象:Domain(仅修改,不可创建或删除)、Account、DistributionList、Share、GalVisibilityRule、ResourceAccessRule 和 Webhook。服务器设置、租户、身份源和 API 密钥仍由服务器管理员管理;角色为 ServerAdmin 或 ComplianceAuditor 的账户不可操作。--prune 仅在自己的租户内删除,history 仅显示自己的应用记录。 - 适用于经销商和部门:每个租户使用自己的仓库和自己的密钥。"tenant" 字段可以省略,系统会自动填入自己的租户。
secretRef in Listen 列表条目同样通过 secretRef 获取密钥:MtaFilters hooks[].authHeaderValue 和 hooks[].hmacSecret、OpsAlerts targets[].secret(Webhook 目标的 HMAC)。计划通过条目的 id 或名称来指代它,例如 hooks[spamcheck].authHeaderValue;列表中的明文值会被拒绝。 - 没有 id 的条目会在首次应用时获得一个 id;导出后请在文件中保留这些 id,这样在列表重新排序时,密钥仍会保留在正确的条目上。
ACME zur Laufzeit TlsSettings.acme(enabled、directoryUrl、contactEmail、质询 Http01/TlsAlpn01/Dns01、renewBeforeExpiryDays、reusePrivateKeyForDane)无需重启即可生效:appsettings.json 仍为基础配置,差异保存在 <data>/acme-settings.json 中,并应用于整个集群。更改目录 URL 会在下一次订单时创建新的 ACME 账户。 appsettings.json 请先使用 Let's Encrypt 测试(staging)目录进行测试,然后再切换到生产环境。
Arten (kinds) Tenant、Domain、Account、DistributionList、GalVisibilityRule、ResourceAccessRule、Share、SmtpSettings、Route、TransportRules、SpamFilter、MtaFilters、FirewallRule、TlsSettings、IdentitySource、ApiKey、Webhook、Notifications、OpsAlerts、DnsFailover、Pop3、FileStorage、Booking、ArchivePolicy。所有字段:JSON Schema。 - API 密钥不会从文件创建(其密钥只存在一次);文件可更改标签和权限范围,并通过 --prune 吊销。会议室和预订设置在每个集群节点上保持一致。