inbuxa-cli: the registry from the command line, and plans #1
No files matched your search
@@ -0,0 +1,20 @@
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
# Announce each published release on the community forum, in this project's
|
||||
# Announcements category (coffey-labs/actions discourse-release; the repo ->
|
||||
# category map is its release-map.json). Safe to re-run: one topic per tag.
|
||||
name: announce
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
runs-on: light
|
||||
steps:
|
||||
- uses: coffey-labs/actions/discourse-release@6f0888c791db2ad0c40adb53e49b7b35aea3def6
|
||||
with:
|
||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
@@ -0,0 +1,99 @@
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
# CI on the self-hosted Gitea. Gitea reads .gitea/workflows and ignores
|
||||
# .github/ once this directory exists; .github/workflows is GitHub's side of
|
||||
# the switch below.
|
||||
#
|
||||
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||
# and the only actions used are coffey-labs/actions ones pinned by SHA. The
|
||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||
# unreviewed can be pulled in.
|
||||
#
|
||||
# BUILD ON GITHUB. The org variable BUILD_ON decides which forge builds.
|
||||
# Set to 'github', the test job below skips and .github/workflows/ci.yml does
|
||||
# the work on GitHub's runners -- GitHub holds a push mirror of this
|
||||
# repository, updated on every commit -- and reports back as the commit status
|
||||
# "github/ci (branch)" or "github/ci (tag)". The `github` job waits for that
|
||||
# status and passes or fails with it, so a run here still says whether the
|
||||
# commit is good. Unset (or anything but 'github'), the tests run here.
|
||||
#
|
||||
# Releases are built only on GitHub: each architecture is compiled on a
|
||||
# native runner there, and these runners are amd64 only. With BUILD_ON unset
|
||||
# a v* tag is tested here but not released.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# The unit tests. They need no server; the checks against a live one are
|
||||
# by hand (README, "Testing").
|
||||
test:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: cargo test --locked
|
||||
|
||||
# Stands in for test and release while GitHub builds: waits for the status
|
||||
# GitHub's ci.yml posts on this commit and passes or fails with it. A pull
|
||||
# request is checked at its head commit, which is what GitHub built when
|
||||
# the branch reached the mirror. Two and a half hours covers a slow queue;
|
||||
# a timeout here with BUILD_ON=github usually means GitHub never got the
|
||||
# push -- check the mirror's last error in the repository settings.
|
||||
github:
|
||||
if: ${{ vars.BUILD_ON == 'github' }}
|
||||
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||
runs-on: wait
|
||||
timeout-minutes: 150
|
||||
container:
|
||||
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apt-get update -qq && apt-get install -y -qq --no-install-recommends jq >/dev/null
|
||||
ctx="github/ci (branch)"; [ "$IS_TAG" = true ] && ctx="github/ci (tag)"
|
||||
echo "waiting for '$ctx' on $SHA"
|
||||
while :; do
|
||||
s="$(curl -fsS -H "Authorization: token $TOKEN" \
|
||||
"$CI_SERVER_INTERNAL/api/v1/repos/$REPO/commits/$SHA/statuses?limit=50" \
|
||||
| jq -c --arg c "$ctx" '[.[] | select(.context == $c)] | sort_by(.id) | last // empty')" || s=""
|
||||
state="$(printf '%s' "$s" | jq -r '.status // .state // empty')"
|
||||
case "$state" in
|
||||
success) echo "GitHub: success"; exit 0 ;;
|
||||
failure|error) echo "GitHub: $state -- $(jq -r '.target_url' <<<"$s")"; exit 1 ;;
|
||||
esac
|
||||
sleep 20
|
||||
done
|
||||
|
||||
# GitHub makes the Release and publishes it only once its files are
|
||||
# attached; this waits for GitHub's success and announces then. The action
|
||||
# makes one topic per tag, so announce.yml firing for the same Release is a
|
||||
# no-op.
|
||||
announce:
|
||||
needs: [github]
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') && needs.github.result == 'success' }}
|
||||
runs-on: light
|
||||
steps:
|
||||
- uses: coffey-labs/actions/discourse-release@6f0888c791db2ad0c40adb53e49b7b35aea3def6
|
||||
with:
|
||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
tag: ${{ github.ref_name }}
|
||||
@@ -0,0 +1,273 @@
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
# CI on GitHub Actions, for the GitHub copy of this repository.
|
||||
#
|
||||
# The repository lives on the self-hosted Gitea; GitHub holds a push mirror
|
||||
# that Gitea updates on every commit. Nothing is merged here -- pull requests
|
||||
# happen on Gitea, and their branches reach GitHub as ordinary pushes, which is
|
||||
# why this workflow runs on `push` and not on `pull_request`.
|
||||
#
|
||||
# Which forge does the building is one switch, the variable BUILD_ON, set on
|
||||
# both forges at the organization level:
|
||||
#
|
||||
# BUILD_ON=github every job here runs; .gitea/workflows/ci.yml skips its
|
||||
# test job and waits for the status this workflow reports
|
||||
# back instead.
|
||||
# unset / other every job here skips; Gitea runs the tests. Releases need
|
||||
# GitHub: they are built on native amd64 and arm64 runners.
|
||||
#
|
||||
# The result goes back to Gitea as one commit status, "github/ci (branch)" or
|
||||
# "github/ci (tag)", which is what Gitea's `github` job waits on.
|
||||
#
|
||||
# v* tags build a release: the tag must be on main and name the version in
|
||||
# Cargo.toml; each architecture is built on its own native runner by
|
||||
# scripts/build-release.sh, and the archives plus SHA256SUMS are attached to
|
||||
# the Gitea Release -- Gitea is where the install guide points. The Release is
|
||||
# created as a draft, filled, then published, so it is never visible with
|
||||
# assets missing; if an upload fails, a Release this run created is deleted.
|
||||
# Gitea announces the release once this run has reported success.
|
||||
#
|
||||
# The binaries link glibc dynamically, so they are built on Ubuntu 22.04: the
|
||||
# oldest glibc GitHub offers, and so the widest range of servers they run on.
|
||||
#
|
||||
# Configuration, all at the organization level on GitHub: variables BUILD_ON,
|
||||
# GITEA_URL; secret GITEA_TOKEN (write:repository on Gitea).
|
||||
#
|
||||
# Every `uses:` is pinned to a full commit SHA, with the release it was in the
|
||||
# trailing comment. Nothing schedules here: schedules belong to Gitea.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
# For a run GitHub queued and then orphaned. Run it from a tag to redo that
|
||||
# tag's release; attaching skips any file already on the Release.
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||
CONTEXT: github/ci (${{ github.ref_type }})
|
||||
CARGO_TERM_COLOR: never
|
||||
|
||||
jobs:
|
||||
# Tells Gitea a result is on its way, so a status that is still missing
|
||||
# reads as "running" rather than "never started".
|
||||
pending:
|
||||
if: vars.BUILD_ON == 'github'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
|
||||
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
|
||||
-d "$(jq -n --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}')"
|
||||
|
||||
# The unit tests. They need no server; the checks against a live one are
|
||||
# by hand (README, "Testing").
|
||||
test:
|
||||
if: vars.BUILD_ON == 'github'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-test-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }}
|
||||
restore-keys: cargo-test-${{ runner.os }}-${{ runner.arch }}-
|
||||
# rustup is on the runner; rust-toolchain.toml picks the toolchain.
|
||||
- run: rustup show active-toolchain || rustup toolchain install
|
||||
- run: cargo test --locked
|
||||
|
||||
# Guards shared by both architectures, checked once.
|
||||
version:
|
||||
if: vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
# Full history: the ancestry check cannot be answered from a shallow
|
||||
# clone.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# A release can never describe code that was not reviewed onto main,
|
||||
# and its tag must be the version the binary reports.
|
||||
- run: |
|
||||
git fetch --quiet origin main
|
||||
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|
||||
|| { echo "::error::$TAG is not on main"; exit 1; }
|
||||
want="v$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')"
|
||||
[ "$TAG" = "$want" ] || { echo "::error::$TAG does not match Cargo.toml ($want)"; exit 1; }
|
||||
|
||||
build:
|
||||
if: vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
needs: [test, version]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-22.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: rustup show active-toolchain || rustup toolchain install
|
||||
- run: |
|
||||
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist
|
||||
sha256sum dist/*.tar.gz
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: archive-${{ matrix.arch }}
|
||||
path: dist/*.tar.gz
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
if: vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
# The release notes are this version's section of CHANGELOG.md, so the
|
||||
# release, and the announcement made from it, say what changed. Checked
|
||||
# out first: a checkout cleans the workspace, and would take dist/ with
|
||||
# it if it ran after the download.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: CHANGELOG.md
|
||||
sparse-checkout-cone-mode: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: dist
|
||||
pattern: archive-*
|
||||
merge-multiple: true
|
||||
- run: |
|
||||
(cd dist && sha256sum ./*.tar.gz | sed 's| \./| |' > SHA256SUMS)
|
||||
cat dist/SHA256SUMS
|
||||
- env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
auth=(-H "Authorization: token $GITEA_TOKEN")
|
||||
# Everything the release carries has to be here before a release is made.
|
||||
for f in inbuxa-cli-linux-amd64.tar.gz inbuxa-cli-linux-arm64.tar.gz SHA256SUMS; do
|
||||
[ -s "dist/$f" ] || { echo "::error::dist/$f is missing; not creating a release"; exit 1; }
|
||||
done
|
||||
files="Binaries for linux/amd64 and linux/arm64. Verify with SHA256SUMS."
|
||||
notes="$(awk -v v="${TAG#v}" 'index($0, "## [" v "]") == 1 {f = 1; next}
|
||||
f && (/^## / || /^---$/) {exit}
|
||||
f' CHANGELOG.md | sed -e '/./,$!d')"
|
||||
if [ -n "$notes" ]; then notes="$notes"$'\n\n'"$files"; else notes="$files"; fi
|
||||
# Reuse the Release if the tag already has one (a re-run), else make a
|
||||
# draft of our own.
|
||||
created=0
|
||||
id="$(curl -fsS "${auth[@]}" "$API/releases/tags/$TAG" 2>/dev/null | jq -r '.id // empty' || true)"
|
||||
if [ -z "$id" ]; then
|
||||
id="$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg t "$TAG" --arg b "$notes" '{tag_name:$t, name:$t, draft:true, body:$b}')" \
|
||||
"$API/releases" | jq -r '.id // empty')"
|
||||
[ -n "$id" ] || { echo "::error::could not create the release"; exit 1; }
|
||||
created=1
|
||||
fi
|
||||
have="$(curl -fsS "${auth[@]}" "$API/releases/$id/assets" | jq -r '.[].name')"
|
||||
for f in dist/*; do
|
||||
n="$(basename "$f")"
|
||||
if grep -qxF "$n" <<<"$have"; then echo "already attached: $n"; continue; fi
|
||||
echo "uploading $n"
|
||||
curl -fsS -o /dev/null "${auth[@]}" --form "attachment=@$f" "$API/releases/$id/assets?name=$n" || {
|
||||
[ "$created" = 1 ] && curl -sS -o /dev/null "${auth[@]}" -X DELETE "$API/releases/$id"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
if [ "$created" = 1 ]; then
|
||||
curl -fsS -o /dev/null "${auth[@]}" -H 'Content-Type: application/json' -X PATCH \
|
||||
-d '{"draft":false}' "$API/releases/$id"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------- github release ------
|
||||
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
|
||||
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
|
||||
# release is the real one; this is left out of the report to Gitea, so a
|
||||
# failure here cannot fail a release. PR and issue numbers in the notes are
|
||||
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
|
||||
github-release:
|
||||
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "GitHub already has a release for $TAG"; exit 0
|
||||
fi
|
||||
# The Gitea release exists by now if this run made it; allow a few
|
||||
# minutes either way.
|
||||
code=0
|
||||
for _ in $(seq 1 15); do
|
||||
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
|
||||
[ "$code" = 200 ] && break
|
||||
sleep 20
|
||||
done
|
||||
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
|
||||
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
|
||||
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
|
||||
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
|
||||
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
|
||||
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
|
||||
files=()
|
||||
mkdir -p files
|
||||
while IFS=$'\t' read -r name url; do
|
||||
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
|
||||
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
|
||||
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
|
||||
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
|
||||
--notes-file notes.md "$kind" "${files[@]}"
|
||||
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
|
||||
|
||||
report:
|
||||
if: always() && vars.BUILD_ON == 'github'
|
||||
needs: [pending, test, version, build, release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
|
||||
run: |
|
||||
# A cancelled run was superseded by a newer run for the same commit (the
|
||||
# mirror can push one commit twice); that run reports. Posting "failure"
|
||||
# here would fail the Gitea check while the real build is still going.
|
||||
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
|
||||
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
|
||||
-d "$(jq -n --arg s "$STATE" --arg c "$CONTEXT" --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}')"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [2026.10.26]
|
||||
|
||||
The first release.
|
||||
|
||||
- Lists, reads, creates, updates and deletes any registry type the server
|
||||
describes, naming objects by id or by key.
|
||||
- `snapshot` writes the server's settings as a plan; `apply` makes a server
|
||||
match one, with references between objects written by key so a plan moves
|
||||
between servers.
|
||||
- Settings are applied on the running server after a change, with a note when
|
||||
a store needs a restart.
|
||||
Generated
+1318
File diff suppressed because it is too large.
Load diff
+26
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "inbuxa-cli"
|
||||
description = "Command-line administration for the inbuxa mail server"
|
||||
version = "2026.10.26"
|
||||
authors = ["Coffey Labs LLC <[email protected]>"]
|
||||
license = "AGPL-3.0-only"
|
||||
repository = "https://git.coffeylabs.org/inbuxa/inbuxa-cli"
|
||||
homepage = "https://git.coffeylabs.org/inbuxa/inbuxa-cli"
|
||||
edition = "2024"
|
||||
readme = "README.md"
|
||||
|
||||
[[bin]]
|
||||
name = "inbuxa-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.1", features = ["derive", "env"] }
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
thiserror = "2.0.18"
|
||||
ureq = { version = "3.3.0", default-features = false, features = ["json", "gzip", "rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "std", "tls12", "logging"] }
|
||||
base64 = "0.22"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
@@ -0,0 +1,172 @@
|
||||
# inbuxa-cli
|
||||
|
||||
> [!NOTE]
|
||||
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-cli](https://git.coffeylabs.org/inbuxa/inbuxa-cli); the copy on GitHub is a read-only mirror.
|
||||
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-cli/issues](https://git.coffeylabs.org/inbuxa/inbuxa-cli/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
|
||||
|
||||
Administer an **inbuxa** server from the command line, and keep its settings
|
||||
in a file.
|
||||
|
||||
inbuxa-cli list Domain what's there
|
||||
inbuxa-cli create Domain name=example.com add to it
|
||||
inbuxa-cli snapshot -o server.json the settings, as a plan
|
||||
inbuxa-cli apply server.json --dry-run what a plan would change
|
||||
|
||||
Everything the console manages lives in the server's registry: domains,
|
||||
accounts, listeners, spam rules, tracers, about a hundred types in all.
|
||||
inbuxa-cli reads the server's own description of those types, so it knows
|
||||
every one the server knows, including types added after this program was
|
||||
built.
|
||||
|
||||
## Installing
|
||||
|
||||
Download the archive for your machine from the
|
||||
[releases](https://git.coffeylabs.org/inbuxa/inbuxa-cli/releases), check it
|
||||
against `SHA256SUMS`, and put `inbuxa-cli` on your `PATH`:
|
||||
|
||||
curl -LO https://git.coffeylabs.org/inbuxa/inbuxa-cli/releases/latest/download/inbuxa-cli-linux-amd64.tar.gz
|
||||
curl -LO https://git.coffeylabs.org/inbuxa/inbuxa-cli/releases/latest/download/SHA256SUMS
|
||||
sha256sum --check --ignore-missing SHA256SUMS
|
||||
tar xzf inbuxa-cli-linux-amd64.tar.gz
|
||||
sudo install inbuxa-cli /usr/local/bin/
|
||||
|
||||
Or build it: `cargo build --release` leaves it in `target/release/`.
|
||||
|
||||
## Signing in
|
||||
|
||||
Give the server's address with `--url` or `INBUXA_URL`, and an API key in
|
||||
`INBUXA_API_KEY`. Without one, inbuxa-cli asks for it. Make the key in the
|
||||
console under **Account › Credentials › API Keys**, signed in as an
|
||||
administrator.
|
||||
|
||||
export INBUXA_URL=https://mail.example.com
|
||||
export INBUXA_API_KEY=API_…
|
||||
inbuxa-cli types
|
||||
|
||||
The server takes passwords on its management API only in bootstrap and
|
||||
recovery mode, or when it runs with `INBUXA_HTTP_BASIC_AUTH=all`. There,
|
||||
`--user [email protected]` signs in with the password in `INBUXA_PASSWORD`,
|
||||
or asks for it. Secrets never go on the command line, where they would end up
|
||||
in the shell's history.
|
||||
|
||||
`--insecure` skips the TLS certificate check, for a test server with a
|
||||
self-signed certificate.
|
||||
|
||||
## Commands
|
||||
|
||||
A type is named as the console's settings pages name it, with or without the
|
||||
`x:` prefix: `Domain`, `x:Domain` and `domain` are the same. An object is
|
||||
named by its id or by its key: a domain by its name, an account by its
|
||||
address.
|
||||
|
||||
| Command | Does |
|
||||
| --- | --- |
|
||||
| `types` | lists the registry types |
|
||||
| `describe TYPE` | shows a type's properties: which are set on create only, which the server sets, which are secret, which refer to other types |
|
||||
| `list TYPE [--filter JSON] [--json]` | lists the objects of a type |
|
||||
| `get TYPE [ID…]` | prints objects as JSON |
|
||||
| `create TYPE name=value… [--file F]` | creates an object and prints its id |
|
||||
| `update TYPE [ID] name=value… [--file F]` | changes properties; a singleton needs no id |
|
||||
| `delete TYPE ID…` | deletes objects |
|
||||
| `reload [settings\|tls\|lookups\|blocked-ips]` | applies saved settings on the running server |
|
||||
| `snapshot [TYPE…] [-o FILE]` | writes the settings as a plan |
|
||||
| `apply FILE [--dry-run] [--prune] [--yes] [--write-secrets]` | makes the server match a plan |
|
||||
|
||||
A value that reads as JSON is JSON (`true`, `42`, `["a","b"]`,
|
||||
`{"@type":"Stdout"}`); anything else is a string, so `name=example.com` needs
|
||||
no quotes. A name with slashes, such as `limits/maxSize=10`, changes one
|
||||
property inside an object. To refer to another object, write its type and
|
||||
key: `domainId=@x:Domain/example.com`.
|
||||
|
||||
inbuxa-cli create Account @type=User name=alice domainId=@x:Domain/example.com
|
||||
inbuxa-cli update Account alice@example.com description="Alice Liddell"
|
||||
inbuxa-cli update Http usePermissiveCors=false
|
||||
inbuxa-cli delete Account alice@example.com
|
||||
|
||||
Most settings take effect when the server reloads its configuration. A
|
||||
current server does that after every write that needs it, and inbuxa-cli
|
||||
reports when a reload failed. An older one doesn't, and inbuxa-cli sends the
|
||||
reload itself. Stores (data, blob, search and the rest) take effect only when
|
||||
the server restarts, and inbuxa-cli says so.
|
||||
|
||||
## Plans
|
||||
|
||||
A plan is the registry written down as JSON: a type and its objects, or a
|
||||
singleton and its properties.
|
||||
|
||||
```json
|
||||
{
|
||||
"x:Domain": [
|
||||
{ "name": "example.com", "isEnabled": true }
|
||||
],
|
||||
"x:Account": [
|
||||
{ "@type": "Group", "@key": "[email protected]", "name": "staff",
|
||||
"domainId": "@x:Domain/example.com" },
|
||||
{ "@type": "User", "@key": "[email protected]", "name": "alice",
|
||||
"domainId": "@x:Domain/example.com",
|
||||
"memberGroupIds": ["@x:Account/[email protected]"] }
|
||||
],
|
||||
"x:Http": { "usePermissiveCors": false }
|
||||
}
|
||||
```
|
||||
|
||||
`snapshot` writes one from a server, and `apply` makes a server match one. Keep
|
||||
the server's settings in version control, review a change as a diff, and
|
||||
apply the same plan to a second server to set it up the same way.
|
||||
|
||||
- **Matching.** Objects are matched by key: the property that names them, such
|
||||
as a domain's `name`. Where the server sets the key itself, as with an
|
||||
account's address, the plan carries it as `@key`. Lookup keys repeat from
|
||||
one list to the next, so their key is `namespace/key`. A type whose objects
|
||||
have no name, such as tracers, is matched by `@id`, which ties those
|
||||
entries to the server they came from.
|
||||
- **References.** Ids differ from one server to the next, so a property that
|
||||
refers to another object holds `@<type>/<key>`. `apply` creates what's
|
||||
referred to before what refers to it, including objects of the same type
|
||||
(a group before its members).
|
||||
- **Only what's named.** `apply` changes only the properties a plan names and
|
||||
leaves the rest as they are. Inside a nested object it compares property by
|
||||
property. A list or set is replaced whole.
|
||||
- **Secrets.** The server never shows a secret, so a snapshot has none: no
|
||||
passwords, no API keys, no DNS provider credentials. Add them to a plan by
|
||||
hand. `apply` writes them when it creates an object, but not to one that
|
||||
exists, since it can't compare them, unless you pass `--write-secrets`.
|
||||
- **Set once.** A property that can only be set on create (a domain's name) is
|
||||
reported, not changed. Delete the object and apply again.
|
||||
- **Deleting.** `--prune` deletes the objects of each type in the plan that
|
||||
the plan leaves out. It asks first, or takes `--yes`. Try `--dry-run` before
|
||||
either.
|
||||
|
||||
A snapshot leaves out what isn't settings: reports, logs, queued messages,
|
||||
tasks and other records, people's own credentials, and DKIM keys, which the
|
||||
server makes for each domain. Name a type to include it anyway:
|
||||
`inbuxa-cli snapshot DkimSignature`.
|
||||
|
||||
`apply` prints a line per change (`+` created, `~` updated, `-` deleted, `!`
|
||||
failed) and a count of each at the end.
|
||||
|
||||
## Exit status
|
||||
|
||||
| Status | Means |
|
||||
| --- | --- |
|
||||
| 0 | done |
|
||||
| 2 | the command line or the plan asks for something that can't work |
|
||||
| 3 | the server couldn't be reached |
|
||||
| 4 | the server refused the request: bad credentials, a missing permission |
|
||||
| 5 | some changes failed; the rest were made |
|
||||
|
||||
## Testing
|
||||
|
||||
cargo test
|
||||
|
||||
The unit tests need no server. `apply` is tested against a registry held in
|
||||
memory. Before a release, the commands are also run by hand against a
|
||||
throwaway server in a container: a snapshot applied back to the server it
|
||||
came from changes nothing, and one applied after its objects are deleted
|
||||
recreates them with every reference intact.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. The text is in [LICENSES](LICENSES).
|
||||
|
||||
Copyright (C) 2026, Coffey Labs LLC
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 110
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
# Build one release archive for the machine this runs on.
|
||||
#
|
||||
# Usage: scripts/build-release.sh VERSION OUTDIR
|
||||
# scripts/build-release.sh v2026.9.30 dist
|
||||
#
|
||||
# CI runs exactly this on one native runner per architecture (amd64 and
|
||||
# arm64) and joins the results with SHA256SUMS, so a release can be checked
|
||||
# before tagging on any Linux machine with cargo. The archive name carries no
|
||||
# version, so .../releases/latest/download/<name> always means the newest.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: $0 VERSION OUTDIR}"
|
||||
OUT="${2:?usage: $0 VERSION OUTDIR}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64) arch=amd64 ;;
|
||||
aarch64 | arm64) arch=arm64 ;;
|
||||
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# The tag names the version the binary reports, so the two cannot disagree.
|
||||
want="v$(cd "$ROOT" && cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')"
|
||||
[ "$VERSION" = "$want" ] || { echo "tag $VERSION does not match Cargo.toml ($want)" >&2; exit 1; }
|
||||
|
||||
name="inbuxa-cli-linux-$arch"
|
||||
mkdir -p "$OUT"
|
||||
OUT="$(cd "$OUT" && pwd)"
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
echo "==> building $name ($VERSION)"
|
||||
(cd "$ROOT" && cargo build --release --locked)
|
||||
mkdir -p "$STAGE/$name"
|
||||
cp "$ROOT/target/release/inbuxa-cli" "$ROOT/README.md" "$STAGE/$name/"
|
||||
cp -r "$ROOT/LICENSES" "$STAGE/$name/"
|
||||
# Fixed owner, order and time, so the archive's layout and metadata do not
|
||||
# change from one build of a tag to the next.
|
||||
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH:-0}" \
|
||||
-C "$STAGE/$name" -czf "$OUT/$name.tar.gz" inbuxa-cli LICENSES README.md
|
||||
echo "==> $OUT/$name.tar.gz"
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The server's management API: JMAP with the registry capability, and the
|
||||
//! schema that describes every registry type.
|
||||
//!
|
||||
//! Registry objects (`x:` types) live on the signed-in account's primary
|
||||
//! account. Outside DAV the server takes a token, never a password (contract
|
||||
//! C-23), so an API key is the normal way in; a password works in bootstrap
|
||||
//! and recovery mode, or when the server runs with INBUXA_HTTP_BASIC_AUTH=all.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Map, Value, json};
|
||||
use ureq::config::RedirectAuthHeaders;
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub const REGISTRY: &str = "urn:inbuxa:jmap:registry";
|
||||
const CORE: &str = "urn:ietf:params:jmap:core";
|
||||
|
||||
/// The schema is about a megabyte; leave room for it to grow.
|
||||
const MAX_BODY: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Auth {
|
||||
/// An API key (`API_…`) or an OAuth access token.
|
||||
Bearer(String),
|
||||
/// A user name and password.
|
||||
Basic(String, String),
|
||||
}
|
||||
|
||||
impl Auth {
|
||||
fn header(&self) -> String {
|
||||
match self {
|
||||
Auth::Bearer(token) => format!("Bearer {token}"),
|
||||
Auth::Basic(user, pass) => format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"))
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One method call's answer: its name, its arguments and its call id.
|
||||
pub type Response = (String, Value, String);
|
||||
|
||||
pub struct Client {
|
||||
agent: ureq::Agent,
|
||||
base: String,
|
||||
auth: Auth,
|
||||
api_url: String,
|
||||
account_id: String,
|
||||
pub max_objects_in_get: usize,
|
||||
pub max_objects_in_set: usize,
|
||||
pub max_calls_in_request: usize,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Fetches the JMAP session from `base` and checks the server offers the
|
||||
/// registry.
|
||||
pub fn connect(base: &str, auth: Auth, insecure: bool) -> Result<Self> {
|
||||
let base = base.trim_end_matches('/').to_owned();
|
||||
if !base.starts_with("http://") && !base.starts_with("https://") {
|
||||
return Err(Error::Usage(format!(
|
||||
"{base} isn't a URL: give the server's address with http:// or https://"
|
||||
)));
|
||||
}
|
||||
let tls = TlsConfig::builder()
|
||||
.unversioned_rustls_crypto_provider(std::sync::Arc::new(
|
||||
rustls::crypto::aws_lc_rs::default_provider(),
|
||||
))
|
||||
.root_certs(RootCerts::PlatformVerifier)
|
||||
.disable_verification(insecure)
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.tls_config(tls)
|
||||
.http_status_as_error(false)
|
||||
// /.well-known/jmap redirects to the session on the same server,
|
||||
// and the redirect has to keep the credentials.
|
||||
.redirect_auth_headers(RedirectAuthHeaders::SameHost)
|
||||
.timeout_connect(Some(Duration::from_secs(30)))
|
||||
.timeout_recv_response(Some(Duration::from_secs(300)))
|
||||
.user_agent(concat!("inbuxa-cli/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.into();
|
||||
|
||||
let mut client = Client {
|
||||
agent,
|
||||
base,
|
||||
auth,
|
||||
api_url: "/jmap".into(),
|
||||
account_id: String::new(),
|
||||
max_objects_in_get: 500,
|
||||
max_objects_in_set: 500,
|
||||
max_calls_in_request: 16,
|
||||
};
|
||||
let session = client.get_json("/.well-known/jmap")?;
|
||||
client.read_session(&session)?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn read_session(&mut self, session: &Value) -> Result<()> {
|
||||
// The registry is an account capability: the account that holds it is
|
||||
// the one an administrator's session names as its primary for it.
|
||||
let primary = session.get("primaryAccounts").and_then(Value::as_object);
|
||||
self.account_id = primary
|
||||
.and_then(|p| p.get(REGISTRY))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
session.get("accounts").and_then(Value::as_object).and_then(|accounts| {
|
||||
accounts
|
||||
.iter()
|
||||
.find(|(_, a)| {
|
||||
a.get("accountCapabilities")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|c| c.contains_key(REGISTRY))
|
||||
})
|
||||
.map(|(id, _)| id.as_str())
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::Server(format!(
|
||||
"the server doesn't offer {REGISTRY}: either it isn't inbuxa, or this account can't administer it"
|
||||
))
|
||||
})?
|
||||
.to_owned();
|
||||
if let Some(core) = session.get("capabilities").and_then(|c| c.get(CORE)) {
|
||||
let limit = |key: &str, default: usize| {
|
||||
core.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(default, |n| n as usize)
|
||||
.max(1)
|
||||
};
|
||||
self.max_objects_in_get = limit("maxObjectsInGet", 500);
|
||||
self.max_objects_in_set = limit("maxObjectsInSet", 500);
|
||||
self.max_calls_in_request = limit("maxCallsInRequest", 16);
|
||||
}
|
||||
// Only the path: an absolute apiUrl names the server's public
|
||||
// address, which isn't necessarily the one we were given.
|
||||
if let Some(api) = session.get("apiUrl").and_then(Value::as_str) {
|
||||
self.api_url = match api.split_once("://") {
|
||||
Some((_, rest)) => rest.find('/').map_or("/jmap".into(), |i| rest[i..].into()),
|
||||
None => api.into(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The registry's schema: every type, its fields, forms and lists.
|
||||
pub fn schema(&self) -> Result<Value> {
|
||||
self.get_json("/api/schema")
|
||||
}
|
||||
|
||||
fn get_json(&self, path: &str) -> Result<Value> {
|
||||
let url = format!("{}{path}", self.base);
|
||||
let mut response = self
|
||||
.agent
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth.header())
|
||||
.header("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|e| Error::Transport(format!("{url}: {e}")))?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_BODY)
|
||||
.read_to_string()
|
||||
.map_err(|e| Error::Transport(format!("{url}: {e}")))?;
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &body));
|
||||
}
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|e| Error::Transport(format!("{url} didn't answer with JSON: {e}")))
|
||||
}
|
||||
|
||||
/// Sends method calls in one request and returns their answers, in order.
|
||||
/// Every call gets the primary account unless it names one.
|
||||
pub fn request(&self, calls: Vec<(String, Value)>) -> Result<Vec<Response>> {
|
||||
let method_calls: Vec<Value> = calls
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, mut args))| {
|
||||
if let Some(obj) = args.as_object_mut() {
|
||||
obj.entry("accountId")
|
||||
.or_insert_with(|| Value::String(self.account_id.clone()));
|
||||
}
|
||||
json!([name, args, i.to_string()])
|
||||
})
|
||||
.collect();
|
||||
let body = json!({ "using": [CORE, REGISTRY], "methodCalls": method_calls });
|
||||
let url = format!("{}{}", self.base, self.api_url);
|
||||
let mut response = self
|
||||
.agent
|
||||
.post(&url)
|
||||
.header("Authorization", self.auth.header())
|
||||
.send_json(&body)
|
||||
.map_err(|e| Error::Transport(format!("{url}: {e}")))?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_BODY)
|
||||
.read_to_string()
|
||||
.map_err(|e| Error::Transport(format!("{url}: {e}")))?;
|
||||
if status != 200 {
|
||||
return Err(http_error(status, &text));
|
||||
}
|
||||
let value: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| Error::Transport(format!("{url} didn't answer with JSON: {e}")))?;
|
||||
let responses = value
|
||||
.get("methodResponses")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| Error::Transport(format!("{url} answered without methodResponses")))?;
|
||||
responses
|
||||
.iter()
|
||||
.map(|r| match r.as_array().map(Vec::as_slice) {
|
||||
Some([Value::String(name), args, Value::String(id)]) => {
|
||||
Ok((name.clone(), args.clone(), id.clone()))
|
||||
}
|
||||
_ => Err(Error::Transport(
|
||||
"a method response isn't [name, args, id]".into(),
|
||||
)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One method call, its answer, and an error if the server answered
|
||||
/// `error` instead.
|
||||
pub fn call(&self, name: &str, args: Value) -> Result<Value> {
|
||||
let mut responses = self.request(vec![(name.to_owned(), args)])?;
|
||||
let (answer, args, _) = responses
|
||||
.pop()
|
||||
.ok_or_else(|| Error::Transport(format!("no answer to {name}")))?;
|
||||
if answer == "error" {
|
||||
return Err(Error::Server(format!("{name}: {}", method_error(&args))));
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Every id of a type, a page at a time. Singletons have the one id
|
||||
/// `singleton`.
|
||||
pub fn all_ids(&self, object: &str, filter: Option<&Value>) -> Result<Vec<String>> {
|
||||
let mut ids = Vec::new();
|
||||
loop {
|
||||
let mut args = json!({ "position": ids.len(), "limit": self.max_objects_in_get });
|
||||
if let Some(filter) = filter {
|
||||
args["filter"] = filter.clone();
|
||||
}
|
||||
let answer = self.call(&format!("{object}/query"), args)?;
|
||||
let page: Vec<String> = answer
|
||||
.get("ids")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
|
||||
.unwrap_or_default();
|
||||
let done = page.len() < self.max_objects_in_get;
|
||||
ids.extend(page);
|
||||
if done {
|
||||
return Ok(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects with these ids, in chunks the server accepts. Ids it
|
||||
/// doesn't know are left out.
|
||||
pub fn get(&self, object: &str, ids: &[String]) -> Result<Vec<Map<String, Value>>> {
|
||||
self.get_properties(object, ids, None)
|
||||
}
|
||||
|
||||
/// As [`Client::get`], with only the named properties (and the id).
|
||||
pub fn get_properties(
|
||||
&self,
|
||||
object: &str,
|
||||
ids: &[String],
|
||||
properties: Option<&[&str]>,
|
||||
) -> Result<Vec<Map<String, Value>>> {
|
||||
let mut list = Vec::with_capacity(ids.len());
|
||||
for chunk in ids.chunks(self.max_objects_in_get) {
|
||||
let mut args = json!({ "ids": chunk });
|
||||
if let Some(properties) = properties {
|
||||
args["properties"] = json!(properties);
|
||||
}
|
||||
let answer = self.call(&format!("{object}/get"), args)?;
|
||||
if let Some(found) = answer.get("list").and_then(Value::as_array) {
|
||||
list.extend(found.iter().filter_map(|v| v.as_object().cloned()));
|
||||
}
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// Every object of a type.
|
||||
pub fn get_all(&self, object: &str) -> Result<Vec<Map<String, Value>>> {
|
||||
let ids = self.all_ids(object, None)?;
|
||||
self.get(object, &ids)
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's account of a failed request: a JMAP problem document when it
|
||||
/// sent one, otherwise the status.
|
||||
fn http_error(status: u16, body: &str) -> Error {
|
||||
let detail = serde_json::from_str::<Value>(body).ok().and_then(|v| {
|
||||
v.get("detail")
|
||||
.or_else(|| v.get("title"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
});
|
||||
let hint = match status {
|
||||
401 => {
|
||||
" (an API key goes in INBUXA_API_KEY; a password only works in bootstrap or recovery mode, or with INBUXA_HTTP_BASIC_AUTH=all on the server)"
|
||||
}
|
||||
403 => " (the account lacks the permission for this)",
|
||||
_ => "",
|
||||
};
|
||||
Error::Server(match detail {
|
||||
Some(detail) => format!("HTTP {status}: {detail}{hint}"),
|
||||
None => format!("HTTP {status}{hint}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A method-level error (`["error", {type, description}, id]`) in words.
|
||||
pub fn method_error(args: &Value) -> String {
|
||||
let kind = args.get("type").and_then(Value::as_str).unwrap_or("error");
|
||||
match args.get("description").and_then(Value::as_str) {
|
||||
Some(d) if !d.is_empty() => format!("{kind}: {d}"),
|
||||
_ => kind.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A SetError (`notCreated` / `notUpdated` / `notDestroyed` entry) in words.
|
||||
pub fn set_error(err: &Value) -> String {
|
||||
let mut text = method_error(err);
|
||||
if let Some(props) = err.get("properties").and_then(Value::as_array) {
|
||||
let names: Vec<&str> = props.iter().filter_map(Value::as_str).collect();
|
||||
if !names.is_empty() {
|
||||
text.push_str(&format!(" [{}]", names.join(", ")));
|
||||
}
|
||||
}
|
||||
if let Some(linked) = err.get("linkedObjects").and_then(Value::as_array) {
|
||||
let names: Vec<String> = linked
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
Some(format!(
|
||||
"x:{} {}",
|
||||
l.get("object")?.as_str()?,
|
||||
l.get("id")?.as_str()?
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
text.push_str(&format!(" (still used by {})", names.join(", ")));
|
||||
}
|
||||
}
|
||||
if let Some(details) = err.get("validationErrors").or_else(|| err.get("errors"))
|
||||
&& !details.is_null()
|
||||
{
|
||||
text.push_str(&format!(" {details}"));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session(api: &str) -> Value {
|
||||
json!({
|
||||
"capabilities": { CORE: { "maxObjectsInGet": 7, "maxObjectsInSet": 9 } },
|
||||
"primaryAccounts": { CORE: "q", REGISTRY: "p" },
|
||||
"accounts": { "p": { "accountCapabilities": { REGISTRY: {} } }, "q": {} },
|
||||
"apiUrl": api,
|
||||
})
|
||||
}
|
||||
|
||||
fn bare() -> Client {
|
||||
Client {
|
||||
agent: ureq::Agent::new_with_defaults(),
|
||||
base: "http://h".into(),
|
||||
auth: Auth::Bearer("t".into()),
|
||||
api_url: String::new(),
|
||||
account_id: String::new(),
|
||||
max_objects_in_get: 0,
|
||||
max_objects_in_set: 0,
|
||||
max_calls_in_request: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_limits_account_and_api_path() {
|
||||
let mut c = bare();
|
||||
c.read_session(&session("https://mail.example.com/jmap/"))
|
||||
.unwrap();
|
||||
assert_eq!(c.account_id, "p");
|
||||
assert_eq!(c.api_url, "/jmap/");
|
||||
assert_eq!(c.max_objects_in_get, 7);
|
||||
assert_eq!(c.max_objects_in_set, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_api_url_is_kept() {
|
||||
let mut c = bare();
|
||||
c.read_session(&session("/jmap")).unwrap();
|
||||
assert_eq!(c.api_url, "/jmap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registry_account_is_found_without_a_primary() {
|
||||
let mut c = bare();
|
||||
let mut s = session("/jmap");
|
||||
s["primaryAccounts"].as_object_mut().unwrap().remove(REGISTRY);
|
||||
c.read_session(&s).unwrap();
|
||||
assert_eq!(c.account_id, "p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_the_registry_is_refused() {
|
||||
let mut c = bare();
|
||||
let mut s = session("/jmap");
|
||||
s["primaryAccounts"].as_object_mut().unwrap().remove(REGISTRY);
|
||||
s["accounts"]["p"]["accountCapabilities"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(REGISTRY);
|
||||
assert!(matches!(c.read_session(&s), Err(Error::Server(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_auth_is_base64_of_user_and_password() {
|
||||
assert_eq!(Auth::Basic("a".into(), "b".into()).header(), "Basic YTpi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_errors_name_the_properties() {
|
||||
let e = json!({"type": "invalidProperties", "description": "bad", "properties": ["name"]});
|
||||
assert_eq!(set_error(&e), "invalidProperties: bad [name]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_errors_name_what_still_links_to_an_object() {
|
||||
let e = json!({"type": "objectIsLinked", "linkedObjects": [{"object": "DkimSignature", "id": "k1"}]});
|
||||
assert_eq!(set_error(&e), "objectIsLinked (still used by x:DkimSignature k1)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! What can go wrong, and the exit status each kind ends the program with.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// The command line or a plan file asks for something that can't work.
|
||||
#[error("{0}")]
|
||||
Usage(String),
|
||||
|
||||
/// The server couldn't be reached, or answered something that isn't HTTP
|
||||
/// we understand.
|
||||
#[error("{0}")]
|
||||
Transport(String),
|
||||
|
||||
/// The server refused the request as a whole: bad credentials, a missing
|
||||
/// permission, an unknown capability.
|
||||
#[error("{0}")]
|
||||
Server(String),
|
||||
|
||||
/// The request went through, but some of what it asked for didn't.
|
||||
#[error("{0}")]
|
||||
Partial(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
Error::Usage(_) => 2,
|
||||
Error::Transport(_) | Error::Io(_) => 3,
|
||||
Error::Server(_) | Error::Json(_) => 4,
|
||||
Error::Partial(_) => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
+620
@@ -0,0 +1,620 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa-cli: administer an inbuxa server from the command line, and keep
|
||||
//! its settings in a file.
|
||||
|
||||
mod client;
|
||||
mod error;
|
||||
mod plan;
|
||||
mod reload;
|
||||
mod schema;
|
||||
mod secret;
|
||||
mod values;
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
|
||||
/// println! that hands a closed pipe (`| head`) back as an error instead of
|
||||
/// panicking.
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => { writeln!(std::io::stdout(), $($arg)*)? };
|
||||
}
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use client::{Auth, Client, set_error};
|
||||
use error::{Error, Result};
|
||||
use plan::Registry;
|
||||
use reload::Tracker;
|
||||
use schema::{Kind, Schema, Update};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "inbuxa-cli",
|
||||
version,
|
||||
about = "Administer an inbuxa server from the command line"
|
||||
)]
|
||||
#[command(
|
||||
after_help = "Sign in with an API key in INBUXA_API_KEY (asked for when unset), \
|
||||
or with --user and INBUXA_PASSWORD where the server takes passwords."
|
||||
)]
|
||||
struct Cli {
|
||||
/// The server's address, such as https://mail.example.com
|
||||
#[arg(long, short = 'u', env = "INBUXA_URL", global = true)]
|
||||
url: Option<String>,
|
||||
|
||||
/// Sign in with this account's password (from INBUXA_PASSWORD, or asked
|
||||
/// for) instead of an API key
|
||||
#[arg(long, env = "INBUXA_USER", global = true)]
|
||||
user: Option<String>,
|
||||
|
||||
/// Don't check the server's TLS certificate
|
||||
#[arg(long, global = true)]
|
||||
insecure: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// List the registry types
|
||||
Types,
|
||||
|
||||
/// Show a type's properties, and which of them refer to other types
|
||||
Describe { r#type: String },
|
||||
|
||||
/// List the objects of a type
|
||||
List {
|
||||
r#type: String,
|
||||
/// A JMAP filter, as JSON
|
||||
#[arg(long)]
|
||||
filter: Option<String>,
|
||||
/// Print the objects as JSON instead of a table
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Print objects as JSON, by id or key; all of them if none is named
|
||||
Get { r#type: String, ids: Vec<String> },
|
||||
|
||||
/// Create an object from name=value pairs or a JSON file
|
||||
Create {
|
||||
r#type: String,
|
||||
/// name=value; a value that reads as JSON is JSON, anything else a
|
||||
/// string. @x:Domain/example.com stands for that domain's id.
|
||||
pairs: Vec<String>,
|
||||
/// Read the object from this JSON file, or - for standard input
|
||||
#[arg(long, short)]
|
||||
file: Option<String>,
|
||||
},
|
||||
|
||||
/// Change an object's properties (a singleton needs no id)
|
||||
Update {
|
||||
r#type: String,
|
||||
/// The object's id or key, then name=value pairs
|
||||
args: Vec<String>,
|
||||
/// Read the changes from this JSON file, or - for standard input
|
||||
#[arg(long, short)]
|
||||
file: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete objects by id or key
|
||||
Delete {
|
||||
r#type: String,
|
||||
#[arg(required = true)]
|
||||
ids: Vec<String>,
|
||||
},
|
||||
|
||||
/// Apply saved settings on the running server
|
||||
Reload {
|
||||
/// settings, tls, lookups or blocked-ips; settings if none
|
||||
what: Vec<String>,
|
||||
},
|
||||
|
||||
/// Write the registry's settings as a plan
|
||||
Snapshot {
|
||||
/// The types to include; every settings type if none
|
||||
types: Vec<String>,
|
||||
/// Write the plan to this file instead of standard output
|
||||
#[arg(long, short)]
|
||||
output: Option<String>,
|
||||
},
|
||||
|
||||
/// Make the server match a plan
|
||||
Apply {
|
||||
/// The plan, or - for standard input
|
||||
file: String,
|
||||
/// Show what would change, and change nothing
|
||||
#[arg(long, short = 'n')]
|
||||
dry_run: bool,
|
||||
/// Also delete objects of the plan's types that the plan leaves out
|
||||
#[arg(long)]
|
||||
prune: bool,
|
||||
/// Don't ask before pruning
|
||||
#[arg(long, short)]
|
||||
yes: bool,
|
||||
/// Write secrets to objects that exist (they're only set on create
|
||||
/// otherwise, since the server never shows them to compare)
|
||||
#[arg(long)]
|
||||
write_secrets: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
if let Error::Io(io) = &e
|
||||
&& io.kind() == std::io::ErrorKind::BrokenPipe
|
||||
{
|
||||
return;
|
||||
}
|
||||
eprintln!("inbuxa-cli: {e}");
|
||||
std::process::exit(e.exit_code());
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(cli: &Cli) -> Result<(Client, Schema)> {
|
||||
let url = cli
|
||||
.url
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Usage("which server? Give --url or set INBUXA_URL".into()))?;
|
||||
let auth = match &cli.user {
|
||||
Some(user) => Auth::Basic(
|
||||
user.clone(),
|
||||
secret::from_env_or_prompt("INBUXA_PASSWORD", "password")?,
|
||||
),
|
||||
None => Auth::Bearer(secret::from_env_or_prompt("INBUXA_API_KEY", "API key")?),
|
||||
};
|
||||
let client = Client::connect(url, auth, cli.insecure)?;
|
||||
let schema = Schema::new(client.schema()?)?;
|
||||
Ok((client, schema))
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<()> {
|
||||
let (client, schema) = connect(&cli)?;
|
||||
match cli.command {
|
||||
Command::Types => types(&schema),
|
||||
Command::Describe { r#type } => describe(&schema, &schema.resolve(&r#type)?),
|
||||
Command::List { r#type, filter, json } => {
|
||||
let filter = filter
|
||||
.map(|f| {
|
||||
serde_json::from_str::<Value>(&f).map_err(|e| Error::Usage(format!("--filter: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
list(&client, &schema, &schema.resolve(&r#type)?, filter.as_ref(), json)
|
||||
}
|
||||
Command::Get { r#type, ids } => get(&client, &schema, &schema.resolve(&r#type)?, &ids),
|
||||
Command::Create { r#type, pairs, file } => {
|
||||
let object = schema.resolve(&r#type)?;
|
||||
create(&client, &schema, &object, properties(&pairs, file.as_deref())?)
|
||||
}
|
||||
Command::Update { r#type, args, file } => {
|
||||
let object = schema.resolve(&r#type)?;
|
||||
let (id, pairs) = match schema.kind(&object) {
|
||||
Kind::Singleton => ("singleton".to_owned(), &args[..]),
|
||||
Kind::Object => match args.split_first() {
|
||||
Some((target, rest)) if !target.contains('=') => {
|
||||
(plan::find_id(&client, &schema, &object, target)?, rest)
|
||||
}
|
||||
_ => return Err(Error::Usage(format!("which {object}? Give its id or key first"))),
|
||||
},
|
||||
};
|
||||
update(
|
||||
&client,
|
||||
&schema,
|
||||
&object,
|
||||
&id,
|
||||
properties(pairs, file.as_deref())?,
|
||||
)
|
||||
}
|
||||
Command::Delete { r#type, ids } => delete(&client, &schema, &schema.resolve(&r#type)?, &ids),
|
||||
Command::Reload { what } => {
|
||||
let mut actions = Vec::new();
|
||||
for word in if what.is_empty() {
|
||||
vec!["settings".to_owned()]
|
||||
} else {
|
||||
what
|
||||
} {
|
||||
actions.push(reload::Action::from_word(&word).ok_or_else(|| {
|
||||
Error::Usage(format!(
|
||||
"can't reload {word}: settings, tls, lookups or blocked-ips"
|
||||
))
|
||||
})?);
|
||||
}
|
||||
actions.sort();
|
||||
actions.dedup();
|
||||
reload::send(&client, &actions)?;
|
||||
for a in actions {
|
||||
out!("{}: done", a.name());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Command::Snapshot { types, output } => snapshot(&client, &schema, &types, output.as_deref()),
|
||||
Command::Apply {
|
||||
file,
|
||||
dry_run,
|
||||
prune,
|
||||
yes,
|
||||
write_secrets,
|
||||
} => apply(
|
||||
&client,
|
||||
&schema,
|
||||
&file,
|
||||
plan::Options {
|
||||
dry_run,
|
||||
prune,
|
||||
write_secrets,
|
||||
},
|
||||
yes,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The object a command line describes: a file, pairs, or pairs over a file.
|
||||
fn properties(pairs: &[String], file: Option<&str>) -> Result<Map<String, Value>> {
|
||||
let mut object = match file {
|
||||
Some(path) => values::read_object(path)?,
|
||||
None => Map::new(),
|
||||
};
|
||||
object.extend(values::parse_pairs(pairs)?);
|
||||
if object.is_empty() {
|
||||
return Err(Error::Usage("nothing given: name=value pairs, or --file".into()));
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
fn print_json(value: &Value) -> Result<()> {
|
||||
let mut out = std::io::stdout().lock();
|
||||
serde_json::to_writer_pretty(&mut out, value)?;
|
||||
writeln!(out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn types(schema: &Schema) -> Result<()> {
|
||||
let names = schema.registry_types();
|
||||
let width = names.iter().map(|n| n.len()).max().unwrap_or(0);
|
||||
for name in names {
|
||||
let kind = match schema.kind(name) {
|
||||
Kind::Singleton => "singleton",
|
||||
Kind::Object => "",
|
||||
};
|
||||
let description = schema.description(name).lines().next().unwrap_or("");
|
||||
out!("{name:width$} {kind:9} {description}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn describe(schema: &Schema, object: &str) -> Result<()> {
|
||||
out!(
|
||||
"{object}{}",
|
||||
if schema.kind(object) == Kind::Singleton {
|
||||
" (singleton)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
let description = schema.description(object);
|
||||
if !description.is_empty() {
|
||||
out!("{description}");
|
||||
}
|
||||
if let Some(key) = schema.key_property(object) {
|
||||
out!("key: {key}");
|
||||
}
|
||||
let variants = schema.variants(object);
|
||||
let mut sections: Vec<(String, Option<Map<String, Value>>)> = Vec::new();
|
||||
if variants.is_empty() {
|
||||
sections.push((String::new(), None));
|
||||
} else {
|
||||
for (name, label, _) in variants {
|
||||
let mut sample = Map::new();
|
||||
sample.insert("@type".into(), Value::String(name.clone()));
|
||||
sections.push((format!("@type {name} ({label})"), Some(sample)));
|
||||
}
|
||||
}
|
||||
for (heading, sample) in sections {
|
||||
out!();
|
||||
if !heading.is_empty() {
|
||||
out!("{heading}");
|
||||
}
|
||||
let Some(props) = schema.properties(object, sample.as_ref()) else {
|
||||
out!(" (no properties)");
|
||||
continue;
|
||||
};
|
||||
let refs = schema.references(object, sample.as_ref());
|
||||
let width = props.keys().map(String::len).max().unwrap_or(0);
|
||||
for (name, spec) in props {
|
||||
let t = spec.get("type");
|
||||
let mut kind = t
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?")
|
||||
.to_owned();
|
||||
if let Some(class) = t
|
||||
.and_then(|t| t.get("class"))
|
||||
.and_then(|c| c.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
kind = format!("{kind} of {class}");
|
||||
}
|
||||
if let Some(r) = refs.iter().find(|r| &r.property == name) {
|
||||
kind = format!("{kind} -> {}", r.target);
|
||||
}
|
||||
let mut notes = Vec::new();
|
||||
match Schema::update_rule(spec) {
|
||||
Update::ServerSet => notes.push("server-set"),
|
||||
Update::Immutable => notes.push("set on create"),
|
||||
Update::Mutable => {}
|
||||
}
|
||||
if schema.holds_secret(spec) {
|
||||
notes.push("secret");
|
||||
}
|
||||
let notes = if notes.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", notes.join(", "))
|
||||
};
|
||||
out!(" {name:width$} {kind}{notes}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A cell of the list table.
|
||||
fn cell(value: Option<&Value>) -> String {
|
||||
match value {
|
||||
None | Some(Value::Null) => String::new(),
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Array(a)) => format!("[{}]", a.len()),
|
||||
Some(Value::Object(o)) => match o.get("@type") {
|
||||
Some(Value::String(t)) => t.clone(),
|
||||
_ => format!("{{{}}}", o.len()),
|
||||
},
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(client: &Client, schema: &Schema, object: &str, filter: Option<&Value>, as_json: bool) -> Result<()> {
|
||||
let items = if schema.kind(object) == Kind::Singleton {
|
||||
client.get(object, &["singleton".to_owned()])?
|
||||
} else {
|
||||
let ids = client.all_ids(object, filter)?;
|
||||
client.get(object, &ids)?
|
||||
};
|
||||
if as_json {
|
||||
return print_json(&Value::Array(items.into_iter().map(Value::Object).collect()));
|
||||
}
|
||||
let mut columns = vec!["id".to_owned()];
|
||||
for c in schema.columns(object) {
|
||||
if !columns.contains(&c) {
|
||||
columns.push(c);
|
||||
}
|
||||
}
|
||||
if columns.len() == 1 {
|
||||
columns.push("@type".into());
|
||||
}
|
||||
let rows: Vec<Vec<String>> = items
|
||||
.iter()
|
||||
.map(|i| columns.iter().map(|c| cell(i.get(c))).collect())
|
||||
.collect();
|
||||
let widths: Vec<usize> = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(n, c)| {
|
||||
rows.iter()
|
||||
.map(|r| r[n].chars().count())
|
||||
.chain([c.len()])
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.min(48)
|
||||
})
|
||||
.collect();
|
||||
let line = |cells: &[String]| -> Result<()> {
|
||||
let parts: Vec<String> = cells
|
||||
.iter()
|
||||
.zip(&widths)
|
||||
.map(|(c, w)| {
|
||||
let c: String = c.chars().take(*w).collect();
|
||||
format!("{c:w$}")
|
||||
})
|
||||
.collect();
|
||||
out!("{}", parts.join(" ").trim_end());
|
||||
Ok(())
|
||||
};
|
||||
line(&columns)?;
|
||||
for row in &rows {
|
||||
line(row)?;
|
||||
}
|
||||
eprintln!("{} {object}", rows.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(client: &Client, schema: &Schema, object: &str, names: &[String]) -> Result<()> {
|
||||
let items = match schema.kind(object) {
|
||||
Kind::Singleton => client.get(object, &["singleton".to_owned()])?,
|
||||
Kind::Object if names.is_empty() => client.get_all(object)?,
|
||||
Kind::Object => {
|
||||
let ids = names
|
||||
.iter()
|
||||
.map(|n| plan::find_id(client, schema, object, n))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
client.get(object, &ids)?
|
||||
}
|
||||
};
|
||||
match (schema.kind(object), names.len(), items.len()) {
|
||||
(Kind::Singleton, _, _) | (Kind::Object, 1, 1) => {
|
||||
print_json(&Value::Object(items.into_iter().next().unwrap_or_default()))
|
||||
}
|
||||
_ => print_json(&Value::Array(items.into_iter().map(Value::Object).collect())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies what a write needs applying, and says what's left to do.
|
||||
fn finish(client: &Client, mut tracker: Tracker) -> Result<()> {
|
||||
for action in tracker.apply(client)? {
|
||||
eprintln!("sent {}", action.name());
|
||||
}
|
||||
for t in &tracker.restart {
|
||||
eprintln!("note: {t} takes effect when the server restarts");
|
||||
}
|
||||
if tracker.failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Partial(tracker.failures.join("\n")))
|
||||
}
|
||||
}
|
||||
|
||||
fn create(client: &Client, schema: &Schema, object: &str, mut item: Map<String, Value>) -> Result<()> {
|
||||
if schema.kind(object) == Kind::Singleton {
|
||||
return Err(Error::Usage(format!("{object} is a singleton: use update")));
|
||||
}
|
||||
plan::resolve_refs(client, schema, object, &mut item)?;
|
||||
let answer = Registry::set(client, object, json!({ "create": { "new": item } }))?;
|
||||
if let Some(err) = answer.get("notCreated").and_then(|n| n.get("new")) {
|
||||
return Err(Error::Server(format!("{object} not created: {}", set_error(err))));
|
||||
}
|
||||
let id = answer
|
||||
.get("created")
|
||||
.and_then(|c| c.get("new"))
|
||||
.and_then(|c| c.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Server("the server didn't say what it created".into()))?;
|
||||
out!("{id}");
|
||||
let mut tracker = Tracker::default();
|
||||
tracker.record(object, &answer);
|
||||
finish(client, tracker)
|
||||
}
|
||||
|
||||
fn update(
|
||||
client: &Client,
|
||||
schema: &Schema,
|
||||
object: &str,
|
||||
id: &str,
|
||||
mut patch: Map<String, Value>,
|
||||
) -> Result<()> {
|
||||
plan::resolve_refs(client, schema, object, &mut patch)?;
|
||||
let answer = Registry::set(client, object, json!({ "update": { id: patch } }))?;
|
||||
if let Some(err) = answer.get("notUpdated").and_then(|n| n.get(id)) {
|
||||
return Err(Error::Server(format!(
|
||||
"{object} {id} not updated: {}",
|
||||
set_error(err)
|
||||
)));
|
||||
}
|
||||
eprintln!("updated {object} {id}");
|
||||
let mut tracker = Tracker::default();
|
||||
tracker.record(object, &answer);
|
||||
finish(client, tracker)
|
||||
}
|
||||
|
||||
fn delete(client: &Client, schema: &Schema, object: &str, names: &[String]) -> Result<()> {
|
||||
if schema.kind(object) == Kind::Singleton {
|
||||
return Err(Error::Usage(format!(
|
||||
"{object} is a singleton and can't be deleted"
|
||||
)));
|
||||
}
|
||||
let ids = names
|
||||
.iter()
|
||||
.map(|n| plan::find_id(client, schema, object, n))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let answer = Registry::set(client, object, json!({ "destroy": ids }))?;
|
||||
let mut problems = Vec::new();
|
||||
for (id, name) in ids.iter().zip(names) {
|
||||
match answer.get("notDestroyed").and_then(|n| n.get(id)) {
|
||||
Some(err) => problems.push(format!("{object} {name} not deleted: {}", set_error(err))),
|
||||
None => eprintln!("deleted {object} {name}"),
|
||||
}
|
||||
}
|
||||
let mut tracker = Tracker::default();
|
||||
if problems.len() < ids.len() {
|
||||
tracker.record(object, &answer);
|
||||
}
|
||||
finish(client, tracker)?;
|
||||
if problems.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Partial(problems.join("\n")))
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(client: &Client, schema: &Schema, names: &[String], output: Option<&str>) -> Result<()> {
|
||||
let types: Vec<String> = if names.is_empty() {
|
||||
schema
|
||||
.registry_types()
|
||||
.into_iter()
|
||||
.filter(|t| !plan::NOT_SETTINGS.contains(t))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
} else {
|
||||
names.iter().map(|n| schema.resolve(n)).collect::<Result<_>>()?
|
||||
};
|
||||
let (plan, warnings) = plan::snapshot(client, schema, &types)?;
|
||||
for w in warnings {
|
||||
eprintln!("note: {w}");
|
||||
}
|
||||
let mut text = serde_json::to_string_pretty(&plan)?;
|
||||
text.push('\n');
|
||||
match output {
|
||||
Some(path) => std::fs::write(path, text).map_err(|e| Error::Usage(format!("{path}: {e}")))?,
|
||||
None => std::io::stdout().lock().write_all(text.as_bytes())?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply(client: &Client, schema: &Schema, file: &str, options: plan::Options, yes: bool) -> Result<()> {
|
||||
let text = if file == "-" {
|
||||
std::io::read_to_string(std::io::stdin())?
|
||||
} else {
|
||||
std::fs::read_to_string(file).map_err(|e| Error::Usage(format!("{file}: {e}")))?
|
||||
};
|
||||
let value: Value = serde_json::from_str(&text).map_err(|e| Error::Usage(format!("{file}: {e}")))?;
|
||||
let plan = plan::read_plan(schema, value)?;
|
||||
|
||||
if options.prune && !options.dry_run && !yes {
|
||||
if !std::io::stdin().is_terminal() || file == "-" {
|
||||
return Err(Error::Usage(
|
||||
"--prune deletes objects: add --yes, or try --dry-run first".into(),
|
||||
));
|
||||
}
|
||||
let types: Vec<&str> = plan.iter().map(|(t, _)| t.as_str()).collect();
|
||||
eprint!(
|
||||
"--prune deletes every {} the plan leaves out. Go ahead? [y/N] ",
|
||||
types.join(", ")
|
||||
);
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer)?;
|
||||
if !matches!(answer.trim(), "y" | "Y" | "yes") {
|
||||
return Err(Error::Usage("nothing changed".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let dry_run = options.dry_run;
|
||||
let mut applier = plan::Applier::new(client, schema, options);
|
||||
applier.run(&plan)?;
|
||||
let o = &applier.outcome;
|
||||
let verb = if dry_run { "would be " } else { "" };
|
||||
eprintln!(
|
||||
"{} {verb}created, {} {verb}updated, {} {verb}deleted, {} unchanged{}",
|
||||
o.created,
|
||||
o.updated,
|
||||
o.destroyed,
|
||||
o.unchanged,
|
||||
if o.failed > 0 {
|
||||
format!(", {} failed", o.failed)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
let failed = o.failed;
|
||||
let tracker = std::mem::take(&mut applier.tracker);
|
||||
if !dry_run {
|
||||
finish(client, tracker)?;
|
||||
}
|
||||
if failed > 0 {
|
||||
return Err(Error::Partial(format!("{failed} change(s) failed")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+1346
File diff suppressed because it is too large.
Load diff
+265
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Getting saved settings applied on the running server.
|
||||
//!
|
||||
//! A registry write is stored at once, but most settings take effect only
|
||||
//! when the server rebuilds its configuration from the registry, which the
|
||||
//! x:Action reloads do. A current server reloads after every write that needs
|
||||
//! it and says how that went in the set response's `x:settingsReload`; an
|
||||
//! older one doesn't, and needs the reload sent. This follows the console's
|
||||
//! table of which types need which reload.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::client::{Client, set_error};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Action {
|
||||
// In the order they run when more than one is due.
|
||||
LookupStores,
|
||||
TlsCertificates,
|
||||
BlockedIps,
|
||||
Settings,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::LookupStores => "ReloadLookupStores",
|
||||
Action::TlsCertificates => "ReloadTlsCertificates",
|
||||
Action::BlockedIps => "ReloadBlockedIps",
|
||||
Action::Settings => "ReloadSettings",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_word(word: &str) -> Option<Self> {
|
||||
match word {
|
||||
"settings" => Some(Action::Settings),
|
||||
"tls" | "certificates" => Some(Action::TlsCertificates),
|
||||
"lookups" => Some(Action::LookupStores),
|
||||
"blocked-ips" => Some(Action::BlockedIps),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Types the server keeps current without a reload, or that take effect only
|
||||
/// on a restart (the stores), or that aren't settings at all.
|
||||
const NOTHING_TO_APPLY: &[&str] = &[
|
||||
"Directory",
|
||||
"Authentication",
|
||||
"Account",
|
||||
"AccountPassword",
|
||||
"AccountSettings",
|
||||
"Alert",
|
||||
"ApiKey",
|
||||
"AppPassword",
|
||||
"DnsServer",
|
||||
"Domain",
|
||||
"DkimSignature",
|
||||
"Enterprise",
|
||||
"MailingList",
|
||||
"MaskedEmail",
|
||||
"OAuthClient",
|
||||
"PublicKey",
|
||||
"Role",
|
||||
"SpamLlm",
|
||||
"Tenant",
|
||||
"Action",
|
||||
"ArchivedItem",
|
||||
"ArfExternalReport",
|
||||
"Bootstrap",
|
||||
"ClusterNode",
|
||||
"DmarcExternalReport",
|
||||
"DmarcInternalReport",
|
||||
"Log",
|
||||
"Metric",
|
||||
"QueuedMessage",
|
||||
"SpamTrainingSample",
|
||||
"Task",
|
||||
"TlsExternalReport",
|
||||
"TlsInternalReport",
|
||||
"Trace",
|
||||
"BlobStore",
|
||||
"Coordinator",
|
||||
"DataStore",
|
||||
"InMemoryStore",
|
||||
"MetricsStore",
|
||||
"SearchStore",
|
||||
"TracingStore",
|
||||
"Application",
|
||||
];
|
||||
|
||||
/// Types a store needs a restart for, which a reload never applies.
|
||||
pub const RESTART_TYPES: &[&str] = &[
|
||||
"x:BlobStore",
|
||||
"x:Coordinator",
|
||||
"x:DataStore",
|
||||
"x:InMemoryStore",
|
||||
"x:MetricsStore",
|
||||
"x:SearchStore",
|
||||
"x:TracingStore",
|
||||
];
|
||||
|
||||
/// The reload that applies a write to `object`, or None when none is needed.
|
||||
pub fn action_for(object: &str) -> Option<Action> {
|
||||
let name = object.strip_prefix("x:")?;
|
||||
if NOTHING_TO_APPLY.contains(&name) {
|
||||
return None;
|
||||
}
|
||||
Some(match name {
|
||||
"Certificate" => Action::TlsCertificates,
|
||||
"StoreLookup" | "HttpLookup" | "MemoryLookupKey" | "MemoryLookupKeyValue" => Action::LookupStores,
|
||||
"BlockedIp" => Action::BlockedIps,
|
||||
_ => Action::Settings,
|
||||
})
|
||||
}
|
||||
|
||||
/// What a set response says about applying its writes, when it says anything.
|
||||
pub fn server_reload(set_response: &Map<String, Value>) -> Option<(bool, Option<String>)> {
|
||||
let reload = set_response.get("x:settingsReload")?;
|
||||
let applied = reload.get("applied")?.as_bool()?;
|
||||
let description = reload
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(str::to_owned);
|
||||
Some((applied, description))
|
||||
}
|
||||
|
||||
/// Tracks the writes of one run and applies what the server didn't.
|
||||
#[derive(Default)]
|
||||
pub struct Tracker {
|
||||
/// Reloads still to send, for writes the server didn't say it applied.
|
||||
pending: Vec<Action>,
|
||||
/// Reloads the server tried and couldn't apply, with its reason.
|
||||
pub failures: Vec<String>,
|
||||
/// Store types that were written, which need a restart.
|
||||
pub restart: Vec<String>,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
/// Notes a set response for `object` that changed something.
|
||||
pub fn record(&mut self, object: &str, response: &Map<String, Value>) {
|
||||
if RESTART_TYPES.contains(&object) && !self.restart.iter().any(|t| t == object) {
|
||||
self.restart.push(object.to_owned());
|
||||
}
|
||||
match server_reload(response) {
|
||||
// x:AllowedIp's answer covers the blocked-IP reload only; allowed
|
||||
// addresses are rebuilt by a full one.
|
||||
Some((true, _)) if object != "x:AllowedIp" => {}
|
||||
Some((false, description)) => self.failures.push(format!(
|
||||
"{object} was saved but not applied: {}",
|
||||
description.unwrap_or_else(|| "the server gave no reason".into())
|
||||
)),
|
||||
_ => {
|
||||
if let Some(action) = action_for(object)
|
||||
&& !self.pending.contains(&action)
|
||||
{
|
||||
self.pending.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the reloads still due. Returns what was sent.
|
||||
pub fn apply(&mut self, client: &Client) -> Result<Vec<Action>> {
|
||||
let mut actions = std::mem::take(&mut self.pending);
|
||||
actions.sort();
|
||||
if actions.is_empty() {
|
||||
return Ok(actions);
|
||||
}
|
||||
if let Err(e) = send(client, &actions) {
|
||||
self.failures.push(e.to_string());
|
||||
}
|
||||
Ok(actions)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs reload actions on the server, in order.
|
||||
pub fn send(client: &Client, actions: &[Action]) -> Result<()> {
|
||||
let mut create = Map::new();
|
||||
for (i, action) in actions.iter().enumerate() {
|
||||
create.insert(format!("r{i}"), json!({ "@type": action.name() }));
|
||||
}
|
||||
let answer = client.call("x:Action/set", json!({ "create": create }))?;
|
||||
let not_created = answer.get("notCreated").and_then(Value::as_object);
|
||||
let mut problems = Vec::new();
|
||||
for (i, action) in actions.iter().enumerate() {
|
||||
let key = format!("r{i}");
|
||||
let created = answer
|
||||
.get("created")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|c| c.contains_key(&key));
|
||||
if created {
|
||||
continue;
|
||||
}
|
||||
let why = not_created
|
||||
.and_then(|n| n.get(&key))
|
||||
.map(set_error)
|
||||
.unwrap_or_else(|| "the server didn't confirm it".into());
|
||||
problems.push(format!("{} failed: {why}", action.name()));
|
||||
}
|
||||
if problems.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Partial(problems.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_type_gets_the_reload_that_applies_it() {
|
||||
assert_eq!(action_for("x:Http"), Some(Action::Settings));
|
||||
assert_eq!(action_for("x:Certificate"), Some(Action::TlsCertificates));
|
||||
assert_eq!(action_for("x:HttpLookup"), Some(Action::LookupStores));
|
||||
assert_eq!(action_for("x:BlockedIp"), Some(Action::BlockedIps));
|
||||
assert_eq!(action_for("x:Domain"), None);
|
||||
assert_eq!(action_for("x:BlobStore"), None);
|
||||
assert_eq!(action_for("Mailbox"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_applied_needs_nothing_sent() {
|
||||
let mut t = Tracker::default();
|
||||
let applied = json!({"x:settingsReload": {"applied": true}});
|
||||
t.record("x:Http", applied.as_object().unwrap());
|
||||
assert!(t.pending.is_empty() && t.failures.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_says_nothing_gets_the_reload_sent() {
|
||||
let mut t = Tracker::default();
|
||||
t.record("x:Http", &Map::new());
|
||||
t.record("x:MtaRoute", &Map::new());
|
||||
t.record("x:Certificate", &Map::new());
|
||||
assert_eq!(t.pending, vec![Action::Settings, Action::TlsCertificates]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_reload_is_reported_with_its_reason() {
|
||||
let mut t = Tracker::default();
|
||||
let failed = json!({"x:settingsReload": {"applied": false, "description": "bad route"}});
|
||||
t.record("x:MtaRoute", failed.as_object().unwrap());
|
||||
assert_eq!(
|
||||
t.failures,
|
||||
vec!["x:MtaRoute was saved but not applied: bad route"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_need_a_restart() {
|
||||
let mut t = Tracker::default();
|
||||
t.record("x:BlobStore", &Map::new());
|
||||
assert_eq!(t.restart, vec!["x:BlobStore"]);
|
||||
assert!(t.pending.is_empty());
|
||||
}
|
||||
}
|
||||
+494
@@ -0,0 +1,494 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The server's description of its own registry, as `/api/schema` serves it:
|
||||
//! which types exist, whether each is a collection or a singleton, the
|
||||
//! properties of each (or of each variant, for a type with several), and how
|
||||
//! the console lists them.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Any number of objects, each with its own id.
|
||||
Object,
|
||||
/// Exactly one, with the id `singleton`.
|
||||
Singleton,
|
||||
}
|
||||
|
||||
/// A property that holds the id of another registry object, or a set of them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Reference {
|
||||
pub property: String,
|
||||
pub target: String,
|
||||
pub many: bool,
|
||||
}
|
||||
|
||||
/// How a property may be written.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Update {
|
||||
Mutable,
|
||||
/// Set when the object is created, never changed after.
|
||||
Immutable,
|
||||
/// The server's to set; never written.
|
||||
ServerSet,
|
||||
}
|
||||
|
||||
pub struct Schema {
|
||||
root: Value,
|
||||
}
|
||||
|
||||
impl Schema {
|
||||
pub fn new(root: Value) -> Result<Self> {
|
||||
for key in ["objects", "schemas", "fields"] {
|
||||
if !root.get(key).is_some_and(Value::is_object) {
|
||||
return Err(Error::Server(format!("the server's schema has no {key}")));
|
||||
}
|
||||
}
|
||||
Ok(Schema { root })
|
||||
}
|
||||
|
||||
fn section(&self, key: &str) -> &Map<String, Value> {
|
||||
static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
|
||||
self.root
|
||||
.get(key)
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or_else(|| EMPTY.get_or_init(Map::new))
|
||||
}
|
||||
|
||||
/// The registry types, collections and singletons, in name order.
|
||||
pub fn registry_types(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self
|
||||
.section("objects")
|
||||
.iter()
|
||||
.filter(|(name, obj)| {
|
||||
name.starts_with("x:")
|
||||
&& matches!(
|
||||
obj.get("type").and_then(Value::as_str),
|
||||
Some("object" | "singleton")
|
||||
)
|
||||
})
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names
|
||||
}
|
||||
|
||||
/// The registry type a name on the command line means: `Domain`,
|
||||
/// `x:Domain` and `domain` are all `x:Domain`; a console view such as
|
||||
/// `x:Account/User` is the type it shows.
|
||||
pub fn resolve(&self, name: &str) -> Result<String> {
|
||||
let objects = self.section("objects");
|
||||
let wanted = if name.starts_with("x:") {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("x:{name}")
|
||||
};
|
||||
let found = objects
|
||||
.get_key_value(&wanted)
|
||||
.map(|(k, _)| k.clone())
|
||||
.or_else(|| objects.keys().find(|k| k.eq_ignore_ascii_case(&wanted)).cloned())
|
||||
.ok_or_else(|| Error::Usage(format!("no registry type {name}: `inbuxa-cli types` lists them")))?;
|
||||
match objects[&found].get("type").and_then(Value::as_str) {
|
||||
Some("view") => objects[&found]
|
||||
.get("objectName")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| Error::Usage(format!("{name} is a view of no type"))),
|
||||
Some("object" | "singleton") => Ok(found),
|
||||
_ => Err(Error::Usage(format!("{name} isn't a registry type"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self, name: &str) -> Kind {
|
||||
match self
|
||||
.section("objects")
|
||||
.get(name)
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some("singleton") => Kind::Singleton,
|
||||
_ => Kind::Object,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self, name: &str) -> &str {
|
||||
self.section("objects")
|
||||
.get(name)
|
||||
.and_then(|o| o.get("description"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// For a type with variants (`@type`), each one's name, label and the
|
||||
/// schema that holds its properties.
|
||||
pub fn variants(&self, name: &str) -> Vec<(String, String, Option<String>)> {
|
||||
let Some(schema) = self.section("schemas").get(name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if schema.get("type").and_then(Value::as_str) != Some("multiple") {
|
||||
return Vec::new();
|
||||
}
|
||||
schema
|
||||
.get("variants")
|
||||
.and_then(Value::as_array)
|
||||
.map(|vs| {
|
||||
vs.iter()
|
||||
.map(|v| {
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
(
|
||||
s("name").unwrap_or_default(),
|
||||
s("label").unwrap_or_default(),
|
||||
s("schemaName"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The schema name that holds the properties of `object`, which for a
|
||||
/// type with variants depends on its `@type`.
|
||||
fn schema_name(&self, name: &str, object: Option<&Map<String, Value>>) -> Option<String> {
|
||||
let schema = self.section("schemas").get(name)?;
|
||||
match schema.get("type").and_then(Value::as_str) {
|
||||
Some("single") => schema
|
||||
.get("schemaName")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
Some("multiple") => {
|
||||
let variant = object?.get("@type")?.as_str()?;
|
||||
self.variants(name)
|
||||
.into_iter()
|
||||
.find(|(n, _, _)| n == variant)
|
||||
.and_then(|(_, _, s)| s)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The properties `object` can have, by name, with each one's spec.
|
||||
pub fn properties(&self, name: &str, object: Option<&Map<String, Value>>) -> Option<&Map<String, Value>> {
|
||||
let schema_name = self.schema_name(name, object)?;
|
||||
self.properties_of_schema(&schema_name)
|
||||
}
|
||||
|
||||
pub fn properties_of_schema(&self, schema_name: &str) -> Option<&Map<String, Value>> {
|
||||
self.section("fields")
|
||||
.get(schema_name)?
|
||||
.get("properties")?
|
||||
.as_object()
|
||||
}
|
||||
|
||||
pub fn update_rule(spec: &Value) -> Update {
|
||||
match spec.get("update").and_then(Value::as_str) {
|
||||
Some("serverSet") => Update::ServerSet,
|
||||
Some("immutable") => Update::Immutable,
|
||||
_ => Update::Mutable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a property holds a secret anywhere inside it: a password in an
|
||||
/// account's credentials, a key in a DNS provider's settings. The server
|
||||
/// masks these, so they can't be read back or compared.
|
||||
pub fn holds_secret(&self, spec: &Value) -> bool {
|
||||
self.type_holds_secret(spec.get("type").unwrap_or(&Value::Null), 0)
|
||||
}
|
||||
|
||||
fn type_holds_secret(&self, t: &Value, depth: usize) -> bool {
|
||||
if matches!(
|
||||
t.get("format").and_then(Value::as_str),
|
||||
Some("secret" | "secretText")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if depth > 8 {
|
||||
return false;
|
||||
}
|
||||
if let Some(class) = t.get("class") {
|
||||
return self.type_holds_secret(class, depth + 1);
|
||||
}
|
||||
let Some(name) = t.get("objectName").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
// A nested object's own type, never a registry reference.
|
||||
if t.get("type").and_then(Value::as_str) == Some("objectId") {
|
||||
return false;
|
||||
}
|
||||
let mut schemas: Vec<String> = self.schema_name(name, None).into_iter().collect();
|
||||
schemas.extend(self.variants(name).into_iter().filter_map(|(_, _, s)| s));
|
||||
schemas
|
||||
.iter()
|
||||
.filter_map(|s| self.properties_of_schema(s))
|
||||
.any(|props| {
|
||||
props
|
||||
.values()
|
||||
.any(|p| self.type_holds_secret(p.get("type").unwrap_or(&Value::Null), depth + 1))
|
||||
})
|
||||
}
|
||||
|
||||
/// The properties of `object` that refer to other registry objects.
|
||||
pub fn references(&self, name: &str, object: Option<&Map<String, Value>>) -> Vec<Reference> {
|
||||
let Some(props) = self.properties(name, object) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut refs: Vec<Reference> = props
|
||||
.iter()
|
||||
.filter_map(|(prop, spec)| {
|
||||
let t = spec.get("type")?;
|
||||
let (id_type, many) = match t.get("type").and_then(Value::as_str)? {
|
||||
"objectId" => (t, false),
|
||||
"set" | "list" => (t.get("class")?, true),
|
||||
_ => return None,
|
||||
};
|
||||
if id_type.get("type").and_then(Value::as_str) != Some("objectId") {
|
||||
return None;
|
||||
}
|
||||
let target = id_type.get("objectName")?.as_str()?;
|
||||
// A view (x:Account/Group) refers to the type it shows.
|
||||
let target = target.split('/').next()?;
|
||||
target.starts_with("x:").then(|| Reference {
|
||||
property: prop.clone(),
|
||||
target: target.to_owned(),
|
||||
many,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
refs.sort_by(|a, b| a.property.cmp(&b.property));
|
||||
refs
|
||||
}
|
||||
|
||||
/// Every registry type any variant of `name` refers to.
|
||||
pub fn referenced_types(&self, name: &str) -> Vec<String> {
|
||||
let mut schemas: Vec<String> = Vec::new();
|
||||
if let Some(s) = self.schema_name(name, None) {
|
||||
schemas.push(s);
|
||||
}
|
||||
schemas.extend(self.variants(name).into_iter().filter_map(|(_, _, s)| s));
|
||||
let mut targets: Vec<String> = Vec::new();
|
||||
for schema in schemas {
|
||||
let Some(props) = self.properties_of_schema(&schema) else {
|
||||
continue;
|
||||
};
|
||||
for spec in props.values() {
|
||||
let Some(t) = spec.get("type") else { continue };
|
||||
let id_type = match t.get("type").and_then(Value::as_str) {
|
||||
Some("objectId") => Some(t),
|
||||
Some("set" | "list") => t.get("class"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(target) = id_type
|
||||
.filter(|i| i.get("type").and_then(Value::as_str) == Some("objectId"))
|
||||
.and_then(|i| i.get("objectName"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|t| t.split('/').next())
|
||||
.filter(|t| t.starts_with("x:"))
|
||||
&& !targets.iter().any(|t| t == target)
|
||||
{
|
||||
targets.push(target.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
/// The list the console shows for `name`: its own, or the first of its
|
||||
/// views'.
|
||||
fn list(&self, name: &str) -> Option<&Map<String, Value>> {
|
||||
let lists = self.section("lists");
|
||||
lists.get(name).and_then(Value::as_object).or_else(|| {
|
||||
let prefix = format!("{name}/");
|
||||
let mut views: Vec<(&String, &Value)> =
|
||||
lists.iter().filter(|(k, _)| k.starts_with(&prefix)).collect();
|
||||
views.sort_by(|a, b| a.0.cmp(b.0));
|
||||
views.first().and_then(|(_, v)| v.as_object())
|
||||
})
|
||||
}
|
||||
|
||||
/// The property that tells one object of a type from another, the way a
|
||||
/// person would: a domain's name, an account's address. None for a type
|
||||
/// whose objects have nothing of the kind, such as tracers.
|
||||
pub fn key_property(&self, name: &str) -> Option<String> {
|
||||
let list = self.list(name)?;
|
||||
if let Some(label) = list.get("labelProperty").and_then(Value::as_str) {
|
||||
return Some(label.to_owned());
|
||||
}
|
||||
list.get("columns")
|
||||
.and_then(Value::as_array)?
|
||||
.first()?
|
||||
.get("name")?
|
||||
.as_str()
|
||||
.filter(|n| !n.starts_with('@'))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// A property that scopes the key, for a type whose keys repeat from one
|
||||
/// namespace to the next (lookup keys: `li.ru` is both a trusted domain
|
||||
/// and a URL redirector).
|
||||
pub fn key_qualifier(&self, name: &str) -> Option<String> {
|
||||
let key = self.key_property(name)?;
|
||||
let mut schemas: Vec<String> = self.schema_name(name, None).into_iter().collect();
|
||||
schemas.extend(self.variants(name).into_iter().filter_map(|(_, _, s)| s));
|
||||
schemas
|
||||
.iter()
|
||||
.filter_map(|s| self.properties_of_schema(s))
|
||||
.any(|props| props.contains_key("namespace"))
|
||||
.then(|| "namespace".to_owned())
|
||||
.filter(|q| *q != key)
|
||||
}
|
||||
|
||||
/// The columns the console shows for a type, by property name.
|
||||
pub fn columns(&self, name: &str) -> Vec<String> {
|
||||
self.list(name)
|
||||
.and_then(|l| l.get("columns"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|cols| {
|
||||
cols.iter()
|
||||
.filter_map(|c| c.get("name").and_then(Value::as_str).map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
pub fn sample() -> Schema {
|
||||
Schema::new(json!({
|
||||
"objects": {
|
||||
"x:Domain": {"type": "object", "description": "A domain."},
|
||||
"x:Account": {"type": "object"},
|
||||
"x:Account/User": {"type": "view", "objectName": "x:Account"},
|
||||
"x:Http": {"type": "singleton"},
|
||||
"x:Tracer": {"type": "object"},
|
||||
"x:LookupKey": {"type": "object"},
|
||||
"Mailbox": {"type": "object"}
|
||||
},
|
||||
"schemas": {
|
||||
"x:Domain": {"type": "single", "schemaName": "x:Domain"},
|
||||
"x:Http": {"type": "single", "schemaName": "x:Http"},
|
||||
"x:Account": {"type": "multiple", "variants": [
|
||||
{"name": "User", "label": "User account", "schemaName": "x:UserAccount"},
|
||||
{"name": "Group", "label": "Group account", "schemaName": "x:GroupAccount"}
|
||||
]},
|
||||
"x:LookupKey": {"type": "single", "schemaName": "x:LookupKey"},
|
||||
"x:Credential": {"type": "multiple", "variants": [
|
||||
{"name": "Password", "label": "Password", "schemaName": "x:PasswordCredential"}
|
||||
]},
|
||||
"x:Tracer": {"type": "multiple", "variants": [
|
||||
{"name": "Stdout", "label": "Console", "schemaName": "x:TracerStdout"}
|
||||
]}
|
||||
},
|
||||
"fields": {
|
||||
"x:Domain": {"properties": {
|
||||
"name": {"type": {"type": "string"}, "update": "immutable"},
|
||||
"isEnabled": {"type": {"type": "boolean"}, "update": "mutable"},
|
||||
"createdAt": {"type": {"type": "utcDateTime"}, "update": "serverSet"}
|
||||
}},
|
||||
"x:Http": {"properties": {"usePermissiveCors": {"type": {"type": "boolean"}}}},
|
||||
"x:UserAccount": {"properties": {
|
||||
"name": {"type": {"type": "string"}, "update": "mutable"},
|
||||
"domainId": {"type": {"type": "objectId", "objectName": "x:Domain"}},
|
||||
"memberGroupIds": {"type": {"type": "set", "class": {"type": "objectId", "objectName": "x:Account/Group"}}},
|
||||
"secret": {"type": {"type": "string", "format": "secret"}},
|
||||
"credentials": {"type": {"type": "objectList", "objectName": "x:Credential"}},
|
||||
"emailAddress": {"type": {"type": "string"}, "update": "serverSet"}
|
||||
}},
|
||||
"x:GroupAccount": {"properties": {"name": {"type": {"type": "string"}}}},
|
||||
"x:TracerStdout": {"properties": {"level": {"type": {"type": "string"}}}},
|
||||
"x:PasswordCredential": {"properties": {
|
||||
"secret": {"type": {"type": "string", "format": "secret"}},
|
||||
"expiresAt": {"type": {"type": "utcDateTime"}}
|
||||
}},
|
||||
"x:LookupKey": {"properties": {
|
||||
"namespace": {"type": {"type": "string"}},
|
||||
"key": {"type": {"type": "string"}}
|
||||
}}
|
||||
},
|
||||
"lists": {
|
||||
"x:Domain": {"labelProperty": "name", "columns": [{"name": "name"}, {"name": "isEnabled"}]},
|
||||
"x:Account/User": {"columns": [{"name": "emailAddress"}, {"name": "description"}]},
|
||||
"x:Tracer": {"columns": [{"name": "@type"}, {"name": "level"}]},
|
||||
"x:LookupKey": {"labelProperty": "key", "columns": [{"name": "namespace"}, {"name": "key"}]}
|
||||
}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_resolve_with_or_without_the_prefix_and_through_views() {
|
||||
let s = sample();
|
||||
assert_eq!(s.resolve("Domain").unwrap(), "x:Domain");
|
||||
assert_eq!(s.resolve("x:domain").unwrap(), "x:Domain");
|
||||
assert_eq!(s.resolve("Account/User").unwrap(), "x:Account");
|
||||
assert!(s.resolve("Nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_types_leave_out_views_and_mail_types() {
|
||||
assert_eq!(
|
||||
sample().registry_types(),
|
||||
vec!["x:Account", "x:Domain", "x:Http", "x:LookupKey", "x:Tracer"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_come_from_the_label_or_the_first_column() {
|
||||
let s = sample();
|
||||
assert_eq!(s.key_property("x:Domain").as_deref(), Some("name"));
|
||||
assert_eq!(s.key_property("x:Account").as_deref(), Some("emailAddress"));
|
||||
assert_eq!(s.key_property("x:Tracer"), None);
|
||||
assert_eq!(s.key_property("x:Http"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_found_inside_nested_objects() {
|
||||
let s = sample();
|
||||
let mut user = Map::new();
|
||||
user.insert("@type".into(), json!("User"));
|
||||
let props = s.properties("x:Account", Some(&user)).unwrap();
|
||||
assert!(s.holds_secret(&props["credentials"]));
|
||||
assert!(s.holds_secret(&props["secret"]));
|
||||
assert!(!s.holds_secret(&props["domainId"]));
|
||||
assert!(!s.holds_secret(&props["name"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_namespace_scopes_the_key() {
|
||||
let s = sample();
|
||||
assert_eq!(s.key_qualifier("x:LookupKey").as_deref(), Some("namespace"));
|
||||
assert_eq!(s.key_qualifier("x:Domain"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_follow_the_variant() {
|
||||
let s = sample();
|
||||
let mut user = Map::new();
|
||||
user.insert("@type".into(), json!("User"));
|
||||
let refs = s.references("x:Account", Some(&user));
|
||||
assert_eq!(
|
||||
refs,
|
||||
vec![
|
||||
Reference {
|
||||
property: "domainId".into(),
|
||||
target: "x:Domain".into(),
|
||||
many: false
|
||||
},
|
||||
Reference {
|
||||
property: "memberGroupIds".into(),
|
||||
target: "x:Account".into(),
|
||||
many: true
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(s.referenced_types("x:Account"), vec!["x:Domain", "x:Account"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Secrets come from the environment or a prompt, never the command line,
|
||||
//! where they would end up in the shell's history and the process list.
|
||||
|
||||
use std::io::{BufRead, IsTerminal, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The value of `var`, or what the person types at a prompt that doesn't
|
||||
/// echo. Without a terminal to prompt on, says which variable to set.
|
||||
pub fn from_env_or_prompt(var: &str, label: &str) -> Result<String> {
|
||||
if let Ok(value) = std::env::var(var)
|
||||
&& !value.is_empty()
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return Err(Error::Usage(format!(
|
||||
"no {label}: set {var}, or run this in a terminal to be asked for it"
|
||||
)));
|
||||
}
|
||||
prompt(label)
|
||||
}
|
||||
|
||||
fn prompt(label: &str) -> Result<String> {
|
||||
let mut stderr = std::io::stderr();
|
||||
write!(stderr, "{label}: ")?;
|
||||
stderr.flush()?;
|
||||
|
||||
let echo_was_off = set_echo(false);
|
||||
let mut line = String::new();
|
||||
let read = std::io::stdin().lock().read_line(&mut line);
|
||||
if echo_was_off {
|
||||
set_echo(true);
|
||||
}
|
||||
writeln!(stderr)?;
|
||||
read?;
|
||||
|
||||
let value = line.trim_end_matches(['\r', '\n']);
|
||||
if value.is_empty() {
|
||||
return Err(Error::Usage(format!("no {label} given")));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
/// Turns terminal echo on or off with stty, and says whether it worked.
|
||||
fn set_echo(on: bool) -> bool {
|
||||
Command::new("stty")
|
||||
.arg(if on { "echo" } else { "-echo" })
|
||||
.stdin(Stdio::inherit())
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Properties given on the command line as `name=value`.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// `name=value` pairs as a JSON object. A value that reads as JSON (`true`,
|
||||
/// `42`, `null`, `["a"]`, `{"@type": "Stdout"}`) is taken as JSON; anything
|
||||
/// else is a string, so `name=example.com` needs no quoting. To pass a string
|
||||
/// that looks like JSON, quote it: `description='"true"'`.
|
||||
///
|
||||
/// A name with slashes (`limits/maxSize=10`) is a JMAP patch path, which an
|
||||
/// update applies to that one nested property.
|
||||
pub fn parse_pairs(pairs: &[String]) -> Result<Map<String, Value>> {
|
||||
let mut out = Map::new();
|
||||
for pair in pairs {
|
||||
let (name, raw) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| Error::Usage(format!("{pair:?} isn't name=value")))?;
|
||||
if name.is_empty() {
|
||||
return Err(Error::Usage(format!("{pair:?} has no property name")));
|
||||
}
|
||||
let value = serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_owned()));
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A JSON object from a file, or from standard input for `-`.
|
||||
pub fn read_object(path: &str) -> Result<Map<String, Value>> {
|
||||
let text = if path == "-" {
|
||||
std::io::read_to_string(std::io::stdin())?
|
||||
} else {
|
||||
std::fs::read_to_string(path).map_err(|e| Error::Usage(format!("{path}: {e}")))?
|
||||
};
|
||||
match serde_json::from_str::<Value>(&text) {
|
||||
Ok(Value::Object(map)) => Ok(map),
|
||||
Ok(_) => Err(Error::Usage(format!("{path} isn't a JSON object"))),
|
||||
Err(e) => Err(Error::Usage(format!("{path}: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn json_where_it_parses_and_strings_elsewhere() {
|
||||
let m = parse_pairs(&[
|
||||
"name=example.com".into(),
|
||||
"isEnabled=true".into(),
|
||||
"max=10".into(),
|
||||
"tags=[\"a\"]".into(),
|
||||
"description=\"true\"".into(),
|
||||
"empty=".into(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(m["name"], json!("example.com"));
|
||||
assert_eq!(m["isEnabled"], json!(true));
|
||||
assert_eq!(m["max"], json!(10));
|
||||
assert_eq!(m["tags"], json!(["a"]));
|
||||
assert_eq!(m["description"], json!("true"));
|
||||
assert_eq!(m["empty"], json!(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pair_needs_a_name_and_an_equals_sign() {
|
||||
assert!(parse_pairs(&["name".into()]).is_err());
|
||||
assert!(parse_pairs(&["=x".into()]).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user