Author SHA1 Message Date
jcoffey-dev c90064f9d8 Merge pull request 'ci: publish the image on tags, port the weekly release' (#9) from ci/publish-and-release into main
publish / version (push) Failing after 14s
publish / publish (push) Skipped
publish / release (push) Skipped
ci / build (push) Successful in 7m5s
2026-09-22 17:03:48 +00:00
jcoffey-dev 8846a280f1 ci: publish the image on tags, port the weekly release
ci / build (pull_request) Successful in 6m49s
publish.yml replaces .github/workflows/publish.yml: on a v* tag it checks the
tag equals v<brand_version!> and is on main, builds the linux/amd64+arm64
image in one buildx run (the Dockerfile already cross-compiles, so only its
final stage goes through QEMU), pushes :<version> and :latest to the
registry, links the package, and creates the tag's release if it has none.

weekly-release.yml ports .github/workflows/release.yml: bump brand_version!
through the contents API, then create the release and so the tag, which
starts publish.yml. It only dry-runs until RELEASE_LIVE=1 and a
RELEASE_TOKEN secret exist.
2026-09-22 09:56:32 -07:00
jcoffey-dev e3717f7990 Merge pull request 'Point links at git.coffeylabs.org after the move from GitHub' (#8) from fix/links-after-move into main
ci / build (push) Successful in 5m9s
2026-09-22 16:17:16 +00:00
jcoffey-dev ef068abbb1 Merge pull request 'ci: raise the Cargo target-dir limit to 60 GB' (#7) from ci/cargo-cache-limit into main
ci / build (push) Canceled after 8m36s
2026-09-22 16:08:41 +00:00
jcoffey-dev 7d2c2d2322 Point links at git.coffeylabs.org after the move from GitHub
ci / build (pull_request) Successful in 6m51s
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:08:33 -07:00
jcoffey-dev b37d252660 ci: raise the Cargo target-dir limit to 60 GB
ci / build (pull_request) Successful in 22m47s
The dev and test profiles together already take ~22 GB after one cold build,
so the 25 GB limit would have wiped a warm cache within a build or two.
2026-09-22 08:45:15 -07:00
jcoffey-dev 521b8449bf Merge pull request 'ci: persistent Cargo cache, run on either runner' (#6) from ci/cargo-cache into main
ci / build (push) Successful in 6m57s
2026-09-22 15:37:48 +00:00
jcoffey-dev 1ee2e2a6a3 ci: persistent Cargo cache, run on either runner
ci / build (pull_request) Successful in 38m1s
The build now mounts the named volume inbuxa-server-cargo at /cache and keeps
CARGO_HOME and CARGO_TARGET_DIR there, so a push reuses the compiled
dependency tree (RocksDB included) instead of rebuilding it from scratch.
Both runners allow that one volume; each host keeps its own copy.

With the cache in place the job moves to runs-on: light, so it can run on
host2 as well. Cargo's parallelism now follows the job's CPU cap rather than
the host's core count, and the target dir is dropped past 25 GB.
2026-09-22 07:59:35 -07:00
jcoffey-dev 825f49671e Merge branch 'ci/gitea-actions' into 'main'
ci / build (push) Successful in 37m28s
ci: add Gitea Actions workflow

See merge request inbuxa/inbuxa-server!5
2026-09-22 00:01:05 -07:00
jcoffey-dev 29bcfecb80 ci: add Gitea Actions workflow ported from .gitlab-ci.yml
ci / build (pull_request) Successful in 24m51s
2026-09-21 22:45:05 -07:00
17 changed files with 363 additions and 316 deletions
+62
View File
@@ -0,0 +1,62 @@
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub.
#
# Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
# instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in.
#
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
# Either runner (host1 or host2): the build needs no docker socket.
runs-on: light
container:
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
# A named volume per host that outlives the job: Cargo's registry/git
# cache and the target dir. Without it every run recompiled RocksDB and
# the rest of the dependency tree from scratch. Each runner allows this
# one volume in its valid_volumes; each host keeps its own copy.
volumes:
- inbuxa-server-cargo:/cache
env:
CARGO_HOME: /cache/cargo-home
CARGO_TARGET_DIR: /cache/target
# Dependencies are reused whole; incremental data for the workspace
# crates would only bloat a shared target dir.
CARGO_INCREMENTAL: "0"
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
# Cargo sizes its parallelism from the host's core count, not the job's
# CPU cap (2 on host2, 4 on host1); a C++ build of RocksDB at 8-way
# parallelism inside 6 GB gets OOM-killed. Match jobs to the cap.
- run: |
jobs=$(awk '$1 != "max" { printf "%d", $1 / $2 }' /sys/fs/cgroup/cpu.max 2>/dev/null)
echo "CARGO_BUILD_JOBS=${jobs:-$(nproc)}" >> "$GITHUB_ENV"
echo "cargo jobs: ${jobs:-$(nproc)}; cache: $(du -sh /cache 2>/dev/null | cut -f1)"
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
- run: cargo build -p inbuxa --locked
# --no-run: the workflow compiled every test target without running them,
# which catches a test that no longer builds without paying for the suite.
- run: cargo test --workspace --locked --no-run
# Keep the cache from growing without bound: past 60 GB the target dir
# is dropped and the next build starts cold. The download cache stays.
# Two builds (dev + test profiles) already fill ~22 GB, so the limit
# has to sit well above that or it would wipe a warm cache every run.
- if: always()
run: |
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
echo "target dir: ${used:-0} GB"
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
+138
View File
@@ -0,0 +1,138 @@
# Publish the container image, ported from .github/workflows/publish.yml when
# the project moved to the self-hosted Gitea (2026-09-22). Starts on a v* tag,
# whether a person pushed it or weekly-release.yml created it through the
# releases API.
#
# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in
# one buildx run on host1 instead of one native runner per architecture: the
# Dockerfile's builder stage runs on the build platform and cross-compiles
# with an aarch64 linker, so only the small final stage (apt, setcap) goes
# through QEMU for arm64. No digest-joining job is needed.
#
# Two guards before anything is pushed:
# * the tag must be v<brand_version!>. The version is a string in
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
# with it, so a tag beside an unbumped macro would publish an image that
# reports a different version from its tag.
# * the tag must be on main, so an image never describes code that was never
# reviewed onto the default branch.
#
# :latest moves with every published tag: tags are cut by the weekly release
# (or by hand for a real release); there are no prerelease tags here.
#
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
# own token is refused by the container registry.
name: publish
on:
push:
tags: ['v*']
jobs:
version:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
outputs:
version: ${{ steps.v.outputs.version }}
steps:
# Full history: the ancestry check cannot be answered from a shallow
# clone. The checkout also fetches every branch as origin/*.
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- id: v
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
if [ "$TAG" != "v$V" ]; then
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
echo "Refusing to publish an image that would report the wrong version." >&2
exit 1
fi
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|| { echo "$TAG is not on main" >&2; exit 1; }
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V"
publish:
needs: [version]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
test -n "$REGISTRY" && test -n "$VERSION"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
# Attestations off, as before: they add manifests of their own to the
# index, and the index should hold the two images and nothing else.
- run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--provenance=false --sbom=false \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
--push .
docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on
# the repository's Packages tab. Idempotent.
- run: |
apk add --no-cache -q curl
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|| echo "package already linked (or link refused); not fatal"
- if: always()
run: docker logout "$REGISTRY" || true
# The weekly release creates its Release (and so the tag) first; a tag
# pushed by hand has none. Either way the tag ends up with exactly one
# Release, created after the image exists so its pull instructions work.
release:
needs: [version, publish]
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- shell: bash
env:
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
REGISTRY: ${{ vars.REGISTRY }}
run: |
python3 - <<'PY'
import json, os, urllib.request, urllib.error
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
h = {"Authorization": f"token {os.environ['TOKEN']}", "Content-Type": "application/json"}
tag, version = os.environ["TAG"], os.environ["VERSION"]
try:
urllib.request.urlopen(urllib.request.Request(f"{api}/releases/tags/{tag}", headers=h))
print(f"{tag} already has a release"); raise SystemExit
except urllib.error.HTTPError as e:
if e.code != 404: raise
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
body = f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`."
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
print(f"created release {r['tag_name']}")
PY
+135
View File
@@ -0,0 +1,135 @@
# Weekly release, ported from .github/workflows/release.yml when the project
# moved to the self-hosted Gitea (2026-09-22): cut a release once a week, but
# only if there is something in it. A release with nothing in it moves
# :latest to an identical build, spends a version number, and notifies
# everybody about nothing.
#
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
# second release on one day. It lives in crates/types/src/branding.rs
# (brand_version!), deliberately not in Cargo.toml so upstream's version bumps
# merge without conflicts. The bump is committed to main and the tag names that
# commit, so the tree a tag points at reports the version the tag claims --
# publish.yml refuses a tag that doesn't.
#
# Mondays 10:07 UTC, last of the three INBUXA releases: Admin and the webmail
# release ahead of the server they talk to. Run it by hand with
# workflow_dispatch; dry_run defaults to true.
#
# NOT LIVE YET: this only ever dry-runs unless the Actions variable
# RELEASE_LIVE is '1' (repo or org). Going live also needs a repo secret
# RELEASE_TOKEN (jcoffey-dev, write:repository, allowed to push to main):
# * a tag Gitea creates for the job's own token raises no event, and the
# tag must start publish.yml;
# * the bump is committed through the contents API. Gitea has no "only if
# the branch is still at X" guard, so the job checks main's head right
# before writing and refuses if it moved since the commit it counted from;
# run it again. (The API does refuse if the file itself changed, via its
# blob sha.)
name: weekly-release
on:
schedule:
- cron: '7 10 * * 1'
workflow_dispatch:
inputs:
dry_run:
description: Show the decision and stop
type: boolean
default: true
# One at a time: two overlapping runs would race to write the same version and
# create the same tag.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
weekly-release:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
env:
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
# with dry_run unticked.
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
RELEASE_LIVE: ${{ vars.RELEASE_LIVE }}
REPO: ${{ github.repository }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- shell: bash
run: |
python3 - <<'PY'
import base64, datetime, json, os, re, subprocess, sys, urllib.request
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
def call(method, path, token, body=None):
req = urllib.request.Request(api + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"token {token}", "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
return json.load(r)
def git(*a):
return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout.strip()
def has_tag(t):
# show-ref matches an exact ref; rev-parse --verify on this git
# can read some tag names as describe output and "find" a tag
# that isn't there.
return subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{t}"]).returncode == 0
sha = git("rev-parse", "HEAD")
# The newest published release, or empty on a project that has never
# had one -- in which case everything counts as new. A release can
# outlive its tag; falling back to the whole history over-counts,
# which cuts a release that was due anyway.
rels = call("GET", "/releases?draft=false&pre-release=false&limit=1", os.environ["READ_TOKEN"])
previous = rels[0]["tag_name"] if rels else ""
rng = f"{previous}..HEAD" if previous and has_tag(previous) else "HEAD"
count = int(git("rev-list", "--count", rng))
if count == 0:
print(f"Nothing to release: no commits since {previous}."); sys.exit(0)
d = datetime.datetime.now(datetime.timezone.utc)
today = f"{d.year}.{d.month}.{d.day}"
version, n = today, 2
while has_tag(f"v{version}"):
version, n = f"{today}.{n}", n + 1
tag = f"v{version}"
print(f"Releasing {tag} -- {count} commit(s) since {previous or 'the beginning'}, from {sha}.")
if os.environ["DRY_RUN"] == "1":
print(f"Dry run (RELEASE_LIVE='{os.environ.get('RELEASE_LIVE', '')}'): stopping here."); sys.exit(0)
token = os.environ.get("RELEASE_TOKEN", "")
if not token:
print("RELEASE_TOKEN secret is not set on this repository", file=sys.stderr); sys.exit(1)
# Scoped to the macro body rather than replacing the first quoted
# string in the file, and asserted to have matched exactly once:
# branding.rs holds other string literals.
path = "crates/types/src/branding.rs"
src = open(path, encoding="utf-8").read()
out, hits = re.subn(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")',
lambda m: m.group(1) + version + m.group(2), src, count=1)
assert hits == 1, f"brand_version! not found in {path}"
head = call("GET", "/branches/main", token)["commit"]["id"]
if head != sha:
print(f"main moved from {sha} to {head} since this run counted; run it again.", file=sys.stderr); sys.exit(1)
blob = call("GET", f"/contents/{path}?ref={sha}", token)["sha"]
bump = call("PUT", f"/contents/{path}", token, {
"branch": "main", "message": f"Version {version}", "sha": blob,
"content": base64.b64encode(out.encode()).decode()})["commit"]["sha"]
print(f"committed the bump as {bump}")
# Notes bounded to what is new: one line per change on main's
# first-parent history. Creating the release creates the tag, which
# is an ordinary push, so publish.yml builds and pushes the image.
notes = git("log", "--first-parent", "--format=- %s", rng)
rel = call("POST", "/releases", token, {
"tag_name": tag, "target_commitish": bump, "name": f"INBUXA {version}",
"body": f"{count} commit(s) since {previous or 'the beginning'}.\n\n{notes}"})
print(f"created release {rel['tag_name']}")
PY
+1
View File
@@ -10,6 +10,7 @@ run.sh
!.gitattributes
!.github
!.gitlab-ci.yml
!.gitea
CLAUDE.md
# The cutover rehearsal writes its fixture and state here.
+2 -2
View File
@@ -35,8 +35,8 @@ to Stalwart Labs with credit to you, and you'll be told that has happened.
This repository is the mail server. The web front ends have their own:
- [inbuxa-admin](https://github.com/inbuxa/inbuxa-admin)
- [ihasmail-inbuxa](https://github.com/inbuxa/ihasmail-inbuxa)
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
Upstream's own security documents are kept in `.github-upstream/` for
reference. They describe Stalwart Labs' process, not this project's.
+6 -25
View File
@@ -47,9 +47,6 @@ pub struct Network {
#[derive(Clone)]
pub struct NetworkInfo {
pub pacc: Pacc,
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
/// served while legacy protocols are off (legacy-protocols LP-7).
pub pacc_jmap_only: Pacc,
pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>,
}
@@ -323,26 +320,11 @@ impl Network {
}
}
let split = |pacc: &Configuration| {
serde_json::to_string(pacc)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| Pacc {
prefix: prefix.to_string(),
suffix: suffix.to_string(),
})
.unwrap()
};
// inbuxa: legacy-protocols LP-7
let pacc_jmap_only = {
let mut pacc = pacc.clone();
pacc.protocols.imap = None;
pacc.protocols.pop3 = None;
pacc.protocols.smtp = None;
pacc.protocols.managesieve = None;
split(&pacc)
};
let pacc = split(&pacc);
let (prefix, suffix) = serde_json::to_string(&pacc)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
.unwrap();
let mut network = Network {
node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(),
@@ -357,8 +339,7 @@ impl Network {
info: NetworkInfo {
mxs: system.mail_exchangers.into_iter().collect(),
services: system.services,
pacc,
pacc_jmap_only,
pacc: Pacc { prefix, suffix },
},
};
@@ -2,11 +2,9 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use crate::{Server, manager::application::Resource};
use quick_xml::Reader;
use quick_xml::XmlVersion;
use quick_xml::events::Event;
@@ -57,12 +55,7 @@ impl Server {
let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [143, 993]),
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
@@ -2,11 +2,9 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use crate::{Server, manager::application::Resource};
use registry::schema::enums::ServiceProtocol;
use std::fmt::Write;
use utils::url_params::UrlParams;
@@ -30,9 +28,6 @@ impl Server {
("%EMAILADDRESS%", default_host.as_str())
};
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
// Build XML response
let mut config = String::with_capacity(1024);
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
@@ -45,9 +40,6 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>"
);
for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, tag, ports) = match protocol {
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
+9 -44
View File
@@ -2,15 +2,9 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{
Server,
config::network::Pacc,
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
};
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose};
use dns_update::{
@@ -39,8 +33,6 @@ impl Server {
let mut records = Vec::new();
let network = &self.core.network;
let default_host = network.server_name.as_str();
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
let domain_name = domain.name.as_str();
let domain_name_suffix = format!(".{domain_name}");
@@ -201,25 +193,6 @@ impl Server {
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
};
// inbuxa: legacy-protocols LP-7. While they are off, every
// name says "not offered" -- target "." (RFC 6186 section
// 3.4) -- rather than vanishing, so a client that looks
// is told, and an old record left in the zone is replaced.
if legacy_off && is_legacy_service(protocol) {
for (service_name, _) in services {
records.push(NamedDnsRecord {
name: format!("_{service_name}._tcp.{domain_name}."),
record: DnsRecord::SRV(SRVRecord {
target: ".".to_string(),
priority: 0,
weight: 0,
port: 0,
}),
});
}
continue;
}
for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
if is_tls == 1 || service.cleartext {
records.push(NamedDnsRecord {
@@ -304,14 +277,6 @@ impl Server {
for (protocol, service) in &network.info.services {
let hostname = service.hostname.as_deref().unwrap_or(default_host);
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
// the switch has closed. Submission's port stays open
// (the SMTP lock), so its record stays.
if legacy_off
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
{
continue;
}
let port = match protocol {
ServiceProtocol::Imap => 993,
ServiceProtocol::Pop3 => 995,
@@ -417,12 +382,6 @@ impl Server {
}
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// inbuxa: legacy-protocols LP-7
let pacc = if self.legacy_protocols_off().await? {
&self.core.network.info.pacc_jmap_only
} else {
&self.core.network.info.pacc
};
self.get_directory_for_domain(domain_name)
.await
.caused_by(trc::location!())
@@ -431,9 +390,15 @@ impl Server {
.and_then(|directory| {
directory
.oidc_discovery_document()
.map(|doc| pacc.build(&doc.url))
.map(|doc| self.core.network.info.pacc.build(&doc.url))
})
.unwrap_or_else(|| {
self.core
.network
.info
.pacc
.build(&self.core.network.http.url_https)
})
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
})
}
}
-83
View File
@@ -21,10 +21,6 @@
//! a legacy protocol is refused before any password is looked at, so a
//! listener that exists by mistake still lets nobody in.
//!
//! And nothing advertises what is closed (LP-7): client configuration and
//! the suggested DNS records leave the legacy services out, or mark them as
//! not offered, while the switch is off.
//!
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy
//! (LP-20). The server stops answering; what still routes the port is the
//! operator's to reconcile.
@@ -35,7 +31,6 @@ use inbuxa_features::security::{
listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener},
};
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId};
use store::registry::bootstrap::Bootstrap;
@@ -107,37 +102,6 @@ impl Server {
protocol_policy::set(&self.core.storage.data, &policy).await?;
// LP-8. Raised here rather than by the JMAP method, so whatever turns
// the switch is reported. A /set that changed nothing -- the switch
// already where it was asked to be, nothing to close or reopen -- is
// not a change.
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
(&change.closed, "closed")
} else {
(&change.reopened, "reopened")
};
trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "server",
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(),
Details = direction,
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
// Only when a listener could not be put back (LP-5).
Reason = (!change.failed.is_empty()).then(|| listener_names(
change
.failed
.iter()
.map(|(l, why)| format!("{}: {why}", l.id))
)),
);
}
Ok(change)
}
@@ -251,12 +215,6 @@ impl Server {
}
}
/// Names for an event field: the listeners a change closed, reopened or
/// failed to reopen (LP-8).
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
trc::Value::Array(names.map(Into::into).collect())
}
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegacyProtocol {
@@ -344,27 +302,6 @@ impl Server {
}
}
/// The services mail apps sign in to, which the switch turns off: nothing may
/// offer them while it is (LP-7). SMTP here is submission -- mail apps
/// sending -- since inbound mail is never a configured service.
pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
matches!(
protocol,
ServiceProtocol::Imap
| ServiceProtocol::Pop3
| ServiceProtocol::Smtp
| ServiceProtocol::Managesieve
)
}
impl Server {
/// Whether the server-wide switch is off, for the answers that must stop
/// offering legacy services (LP-7). Read per answer, as sign-in reads it.
pub async fn legacy_protocols_off(&self) -> trc::Result<bool> {
Ok(self.protocol_policy().await?.legacy_protocols.is_disabled())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -412,26 +349,6 @@ mod tests {
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
}
#[test]
fn only_the_services_mail_apps_sign_in_to_are_legacy() {
for protocol in [
ServiceProtocol::Imap,
ServiceProtocol::Pop3,
ServiceProtocol::Smtp,
ServiceProtocol::Managesieve,
] {
assert!(is_legacy_service(&protocol), "{protocol:?}");
}
for protocol in [
ServiceProtocol::Jmap,
ServiceProtocol::Caldav,
ServiceProtocol::Carddav,
ServiceProtocol::Webdav,
] {
assert!(!is_legacy_service(&protocol), "{protocol:?}");
}
}
#[test]
fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
+1 -6
View File
@@ -2,8 +2,6 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::blob::UploadResponse;
@@ -188,10 +186,7 @@ impl ToRequestError for trc::Error {
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
RequestError::forbidden()
}
// inbuxa: legacy-protocols LP-8 is an event, never an error
trc::SecurityEvent::IpBlockExpired
| trc::SecurityEvent::IpAllowExpired
| trc::SecurityEvent::LegacyProtocolsChanged => {
trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => {
RequestError::internal_server_error()
}
},
+2 -5
View File
@@ -9,9 +9,8 @@
// This file is auto-generated. Do not edit directly.
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is
// security.legacy-protocols-changed (LP-8)
pub const TOTAL_EVENT_COUNT: usize = 644;
// auth.legacy-protocol-refused (legacy-protocols LP-6)
pub const TOTAL_EVENT_COUNT: usize = 643;
pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -656,8 +655,6 @@ pub enum SecurityEvent {
IpAllowExpired = 594,
IpUnauthorized = 279,
Unauthorized = 552,
// inbuxa: legacy-protocols LP-8
LegacyProtocolsChanged = 643,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+2 -28
View File
@@ -446,8 +446,6 @@ impl EventType {
b"security.ip-allow-expired" => EventType::Security(SecurityEvent::IpAllowExpired),
b"security.ip-unauthorized" => EventType::Security(SecurityEvent::IpUnauthorized),
b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized),
// inbuxa: legacy-protocols LP-8
b"security.legacy-protocols-changed" => EventType::Security(SecurityEvent::LegacyProtocolsChanged),
b"server.startup" => EventType::Server(ServerEvent::Startup),
b"server.shutdown" => EventType::Server(ServerEvent::Shutdown),
b"server.startup-error" => EventType::Server(ServerEvent::StartupError),
@@ -1213,10 +1211,6 @@ impl EventType {
EventType::Security(SecurityEvent::IpAllowExpired) => "security.ip-allow-expired",
EventType::Security(SecurityEvent::IpUnauthorized) => "security.ip-unauthorized",
EventType::Security(SecurityEvent::Unauthorized) => "security.unauthorized",
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"security.legacy-protocols-changed"
}
EventType::Server(ServerEvent::Startup) => "server.startup",
EventType::Server(ServerEvent::Shutdown) => "server.shutdown",
EventType::Server(ServerEvent::StartupError) => "server.startup-error",
@@ -1889,8 +1883,6 @@ impl EventType {
EventType::Security(SecurityEvent::IpAllowExpired) => 594,
EventType::Security(SecurityEvent::IpUnauthorized) => 279,
EventType::Security(SecurityEvent::Unauthorized) => 552,
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => 643,
EventType::Server(ServerEvent::Startup) => 393,
EventType::Server(ServerEvent::Shutdown) => 392,
EventType::Server(ServerEvent::StartupError) => 394,
@@ -2579,8 +2571,6 @@ impl EventType {
594 => Some(EventType::Security(SecurityEvent::IpAllowExpired)),
279 => Some(EventType::Security(SecurityEvent::IpUnauthorized)),
552 => Some(EventType::Security(SecurityEvent::Unauthorized)),
// inbuxa: legacy-protocols LP-8
643 => Some(EventType::Security(SecurityEvent::LegacyProtocolsChanged)),
393 => Some(EventType::Server(ServerEvent::Startup)),
392 => Some(EventType::Server(ServerEvent::Shutdown)),
394 => Some(EventType::Server(ServerEvent::StartupError)),
@@ -3000,8 +2990,6 @@ impl EventType {
EventType::Security(SecurityEvent::IpAllowExpired) => Level::Info,
EventType::Security(SecurityEvent::IpUnauthorized) => Level::Info,
EventType::Security(SecurityEvent::Unauthorized) => Level::Info,
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => Level::Info,
EventType::Server(ServerEvent::Startup) => Level::Info,
EventType::Server(ServerEvent::Shutdown) => Level::Info,
EventType::Server(ServerEvent::Licensing) => Level::Info,
@@ -3210,9 +3198,7 @@ impl EventType {
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration",
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => {
"Legacy mail protocol sign-in refused"
}
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "Legacy mail protocol sign-in refused",
EventType::Auth(AuthEvent::Error) => "Authentication error",
EventType::Auth(AuthEvent::Warning) => "Authentication warning",
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
@@ -3725,10 +3711,6 @@ impl EventType {
EventType::Security(SecurityEvent::IpAllowExpired) => "IP allow expired",
EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address",
EventType::Security(SecurityEvent::Unauthorized) => "Unauthorized access",
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"Legacy mail protocols switch changed"
}
EventType::Server(ServerEvent::Startup) => "Starting INBUXA Server",
EventType::Server(ServerEvent::Shutdown) => "Shutting down INBUXA Server",
EventType::Server(ServerEvent::StartupError) => "Server startup error",
@@ -3982,9 +3964,7 @@ impl EventType {
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => {
"This server allows only INBUXA webmail and JMAP apps"
}
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "This server allows only INBUXA webmail and JMAP apps",
EventType::Auth(AuthEvent::Error) => "Authentication error",
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
@@ -4122,10 +4102,6 @@ impl EventType {
EventType::Security(SecurityEvent::IpAllowExpired) => "Insufficient permissions",
EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address",
EventType::Security(SecurityEvent::Unauthorized) => "Insufficient permissions",
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
"Legacy mail protocols switch changed"
}
EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP error",
EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error",
EventType::Smtp(SmtpEvent::Error) => "SMTP error",
@@ -4687,8 +4663,6 @@ impl EventType {
EventType::Security(SecurityEvent::IpAllowExpired),
EventType::Security(SecurityEvent::IpUnauthorized),
EventType::Security(SecurityEvent::Unauthorized),
// inbuxa: legacy-protocols LP-8
EventType::Security(SecurityEvent::LegacyProtocolsChanged),
EventType::Server(ServerEvent::Startup),
EventType::Server(ServerEvent::Shutdown),
EventType::Server(ServerEvent::StartupError),
+1 -1
View File
@@ -21,6 +21,6 @@ echo >&2
echo "A server started with no configuration comes up in bootstrap mode;" >&2
echo "INBUXA Admin's setup wizard completes first boot over JMAP." >&2
echo >&2
echo "Releases: https://github.com/inbuxa/inbuxa-server/releases" >&2
echo "Releases: https://git.coffeylabs.org/inbuxa/inbuxa-server/releases" >&2
echo "Docs: https://docs.inbuxa.org/install/fresh/" >&2
exit 1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
rLRbZKj15KvcPMVmnisfCDsXXZEKks6BXpMkMpS0mlI
q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY
+1 -104
View File
@@ -17,11 +17,7 @@ submission -- locked open -- is refused with the spec's words, with the right
password and with a wrong one, and refusals never add up to a disconnect
(LP-11). And that a normal IMAP sign-in works with the switch on, before and
after. And that while it is off, no listener the switch would close can be
created, or made by an update (LP-4, test 4), and nothing advertises what is
closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission,
and the suggested zone marks their SRV names not offered (LP-7, test 5).
Every change of the switch, and every refused sign-in, is an event in the
server's log (LP-8, test 14; LP-6).
created, or made by an update (LP-4, test 4).
Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1.
@@ -193,47 +189,6 @@ def smtp_auths(port, user, passwords):
return replies
def advertised(admin, admin_pw):
"""What each client-configuration answer and the suggested zone offer."""
with urllib.request.urlopen(f"{HTTP}/mail/[email protected]",
timeout=30) as resp:
autoconfig = resp.read().decode()
body = ('<?xml version="1.0" encoding="utf-8"?><Autodiscover xmlns="http://schemas.'
'microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request>'
'<EMailAddress>[email protected]</EMailAddress><AcceptableResponseSchema>http://'
'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a'
'</AcceptableResponseSchema></Request></Autodiscover>').encode()
req = urllib.request.Request(f"{HTTP}/autodiscover/autodiscover.xml", data=body, method="POST")
req.add_header("Content-Type", "text/xml")
with urllib.request.urlopen(req, timeout=30) as resp:
autodiscover = resp.read().decode()
with urllib.request.urlopen(f"{HTTP}/.well-known/user-agent-configuration.json",
timeout=30) as resp:
pacc = json.load(resp).get("protocols", {})
got = one(admin, admin_pw, "x:Domain/get", {"ids": None, "properties": ["name", "dnsZoneFile"]})
zone = next((d.get("dnsZoneFile") or "" for d in got[1].get("list", [])
if d.get("name") == "legacy.test"), "")
srv = {}
for line in zone.splitlines():
fields = line.split()
if "SRV" in fields and fields[0].startswith("_"):
srv[fields[0].split(".")[0] + "." + fields[0].split(".")[1]] = fields[-1]
return {
"autoconfig": {t for t in ("imap", "pop3", "smtp") if f'type="{t}"' in autoconfig},
"autodiscover": {t for t in ("IMAP", "POP3", "SMTP") if f"<Type>{t}</Type>" in autodiscover},
"pacc": {t for t in ("imap", "pop3", "smtp", "managesieve") if t in pacc},
"jmap": "jmap" in pacc,
"srv": srv,
}
def events(name):
"""The server's log lines for one event, from its stdout tracer. The log
is the container's, so it starts afresh at every restart."""
out = docker("logs", NAME, check_rc=False)
return [l for l in (out.stdout + out.stderr).splitlines() if f"({name})" in l]
def settle(port, want, tries=30):
"""Wait for a port to reach the wanted state, so the check is not a race."""
for _ in range(tries):
@@ -277,15 +232,6 @@ def main():
stop()
start()
# A tracer to stdout, so the events can be read back from the container's
# log. It takes effect from the next start.
res = one(admin, admin_pw, "x:Tracer/set", {"create": {"t": {
"@type": "Stdout", "level": "info", "buffered": False, "ansi": False}}})
if not (res[1].get("created") or {}).get("t"):
sys.exit("tracer create failed: " + json.dumps(res))
stop()
start()
sess = session(admin, admin_pw)
account = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
policy_get = {"accountId": account, "ids": None}
@@ -297,15 +243,6 @@ def main():
check(accepts(PORTS["submissions"]), "submission accepts before the switch")
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
# What is advertised with the switch on -- the control for LP-7.
before = advertised(admin, admin_pw)
print(" advertised before:", {k: sorted(v) if isinstance(v, set) else v
for k, v in before.items() if k != "srv"})
check(before["autoconfig"] and before["autodiscover"],
"autoconfig and autodiscover offer mail apps a server with the switch on")
check(before["srv"].get("_imaps._tcp", ".") != ".",
"the suggested zone offers IMAP with the switch on")
# A normal sign-in works with the switch on -- the control for LP-6.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works with the switch on")
@@ -358,19 +295,6 @@ def main():
if not all(r == SMTP_REFUSAL for r in replies):
print(" replies:", replies)
# Nothing advertises what is closed (LP-7, test 5).
during = advertised(admin, admin_pw)
check(not during["autoconfig"], "autoconfig offers no IMAP, POP3 or submission (LP-7)")
check(not during["autodiscover"], "autodiscover offers no IMAP, POP3 or submission (LP-7)")
check(not during["pacc"] and during["jmap"], "PACC offers JMAP and nothing legacy (LP-7)")
names = ("_imap._tcp", "_imaps._tcp", "_pop3._tcp", "_pop3s._tcp",
"_submission._tcp", "_submissions._tcp")
offered = {n: t for n, t in during["srv"].items() if n in names and t != "."}
check(not offered and "_imaps._tcp" in during["srv"],
"the suggested zone marks the legacy SRV names not offered, target . (LP-7)")
if offered or "_imaps._tcp" not in during["srv"]:
print(" srv:", during["srv"])
# No listener the switch would close can be added while it is off (LP-4,
# test 4), and the refusal names the policy.
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": {
@@ -398,25 +322,6 @@ def main():
"turning it into an IMAP listener is refused (LP-4)")
one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [extra]})
# The change was reported (LP-8, test 14), with who made it and what closed.
changed = events("security.legacy-protocols-changed")
check(len(changed) == 1 and 'value = "disabled"' in changed[0]
and 'policy = "server"' in changed[0] and 'details = "closed"' in changed[0]
and '"imaps"' in changed[0] and "accountId = " in changed[0],
"turning it off is one event: scope, new value, who, listeners closed (LP-8)")
if len(changed) != 1:
print(" events:", changed)
# Asking for what already holds is not a change.
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", policy_set({"legacyProtocols": "disabled"}))
check(len(events("security.legacy-protocols-changed")) == 1,
"setting it off again when it is off raises no event (LP-8)")
# Every refused sign-in is an event too, and none is a failed sign-in.
refused = events("auth.legacy-protocol-refused")
check(len(refused) == 7 and all('source = "submission"' in l for l in refused),
"each refused sign-in is an auth.legacy-protocol-refused event (LP-6)")
check(not events("auth.failed") and not events("auth.too-many-attempts"),
"and none is logged as a failed sign-in (LP-11)")
# A restart must not reopen them: the objects are gone, not just the sockets.
stop()
start()
@@ -435,14 +340,6 @@ def main():
policy = got[1]["list"][0]
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
changed = events("security.legacy-protocols-changed")
check(len(changed) == 1 and 'value = "enabled"' in changed[0]
and 'details = "reopened"' in changed[0] and '"imaps"' in changed[0],
"turning it back on is one event, naming the listeners reopened (LP-8)")
after = advertised(admin, admin_pw)
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
"autoconfig and the suggested zone offer them again once back on")
# And sign-in works again, with no restart.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),