root: rewrite server in rust (#24320)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt
2026-07-29 22:42:09 +02:00
committed by GitHub
parent 73de109ed2
commit 74bda646af
38 changed files with 1824 additions and 575 deletions

View File

@@ -105,6 +105,12 @@ jobs:
uses: ./.github/actions/setup
with:
dependencies: ${{ matrix.deps }}
- name: Create required files
run: |
mkdir -p web/dist/standalone/loading
for f in web/robots.txt web/security.txt web/dist/standalone/loading/startup.html; do
echo empty > "$f"
done
- name: run job
run: make ci-lint-${{ matrix.job }}
test-gen:
@@ -437,6 +443,12 @@ jobs:
- name: Prepare database
run: |
uv run make migrate
- name: Create required files
run: |
mkdir -p web/dist/standalone/loading
for f in web/robots.txt web/security.txt web/dist/standalone/loading/startup.html; do
echo empty > "$f"
done
- name: run tests
run: |
cargo llvm-cov --no-report nextest --workspace

143
Cargo.lock generated
View File

@@ -26,6 +26,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -235,6 +250,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "async-compression"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -290,6 +317,7 @@ dependencies = [
"color-eyre",
"eyre",
"futures",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-unix-socket",
@@ -299,6 +327,8 @@ dependencies = [
"metrics-exporter-prometheus",
"moka",
"nix 0.31.3",
"pem",
"percent-encoding",
"pyo3",
"pyo3-build-config",
"rand 0.10.2",
@@ -316,10 +346,10 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tower",
"tower-http 0.7.0",
"tracing",
"url",
"uuid",
"which",
]
[[package]]
@@ -332,7 +362,6 @@ dependencies = [
"client-ip",
"durstr",
"eyre",
"forwarded-header-value",
"futures",
"pin-project-lite",
"tokio",
@@ -651,6 +680,27 @@ dependencies = [
"objc2",
]
[[package]]
name = "brotli"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
@@ -799,7 +849,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d2056bf065c8b4bce5a8898d40e175211ff4410add2a84d695845d3937c729"
dependencies = [
"forwarded-header-value",
"http",
]
@@ -870,6 +919,26 @@ dependencies = [
"memchr",
]
[[package]]
name = "compression-codecs"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
"zstd",
"zstd-safe",
]
[[package]]
name = "compression-core"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1347,16 +1416,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "forwarded-header-value"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
dependencies = [
"nonempty",
"thiserror 1.0.69",
]
[[package]]
name = "fs-err"
version = "3.3.0"
@@ -1727,6 +1786,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2426,12 +2491,6 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nonempty"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
[[package]]
name = "notify"
version = "8.2.0"
@@ -4402,13 +4461,22 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"async-compression",
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
@@ -4887,15 +4955,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "which"
version = "8.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c"
dependencies = [
"libc",
]
[[package]]
name = "whoami"
version = "2.1.2"
@@ -5488,3 +5547,31 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -31,7 +31,7 @@ axum-extra = { version = "= 0.12.6", default-features = false, features = [
] }
base64 = "= 0.22.1"
clap = { version = "= 4.6.4", features = ["derive", "env"] }
client-ip = { version = "0.2.1", features = ["forwarded-header"] }
client-ip = { version = "0.2.1" }
chrono = { version = "0.4.45", features = ["serde"] }
color-eyre = "= 0.6.5"
colored = "= 3.1.1"
@@ -43,9 +43,9 @@ console-subscriber = "= 0.5.0"
dotenvy = "= 0.15.7"
durstr = "= 0.5.1"
eyre = "= 0.6.12"
forwarded-header-value = "= 0.1.1"
futures = "= 0.3.33"
glob = "= 0.3.4"
http-body-util = "= 0.1.4"
hyper = { version = "= 1.10.1", features = ["client", "http1", "http2"] }
hyper-rustls = { version = "= 0.27.9", default-features = false, features = [
"aws-lc-rs",
@@ -71,6 +71,8 @@ moka = { version = "= 0.12.15", default-features = false, features = [
] }
nix = { version = "= 0.31.3", features = ["hostname", "signal"] }
notify = "= 8.2.0"
pem = "= 3.0.6"
percent-encoding = "= 2.3.2"
pin-project-lite = "= 0.2.17"
pyo3 = "= 0.29.0"
pyo3-build-config = "= 0.29.0"
@@ -135,7 +137,11 @@ tokio-tungstenite = { version = "= 0.29.0", features = [
] }
tokio-util = { version = "= 0.7.19", features = ["full"] }
tower = "= 0.5.3"
tower-http = { version = "= 0.7.0", features = ["timeout"] }
tower-http = { version = "= 0.7.0", features = [
"compression-full",
"fs",
"timeout",
] }
tracing = "= 0.1.44"
tracing-error = "= 0.2.1"
tracing-subscriber = { version = "= 0.3.23", features = [
@@ -146,7 +152,6 @@ tracing-subscriber = { version = "= 0.3.23", features = [
] }
url = "= 2.5.8"
uuid = { version = "= 1.24.0", features = ["serde", "v4"] }
which = "= 8.0.5"
ak-axum = { package = "authentik-axum", version = "2026.8.0-rc1", path = "./packages/ak-axum" }
ak-client = { package = "authentik-client", version = "2026.8.0-rc1", path = "./packages/client-rust" }
@@ -269,14 +274,9 @@ verbose_file_reads = "warn"
[profile.dev.package.backtrace]
opt-level = 3
[profile.dev]
panic = "abort"
[profile.release]
debug = 2
lto = "fat"
# Because of the async runtime, we want to die straightaway if we panic.
panic = "abort"
strip = true
[package]
@@ -327,6 +327,7 @@ axum.workspace = true
color-eyre.workspace = true
eyre.workspace = true
futures.workspace = true
http-body-util.workspace = true
hyper = { workspace = true, optional = true }
hyper-rustls = { workspace = true, optional = true }
hyper-unix-socket.workspace = true
@@ -336,6 +337,8 @@ metrics-exporter-prometheus.workspace = true
metrics.workspace = true
moka = { workspace = true, optional = true }
nix.workspace = true
pem.workspace = true
percent-encoding.workspace = true
pyo3 = { workspace = true, optional = true }
rand.workspace = true
regex = { workspace = true, optional = true }
@@ -350,11 +353,11 @@ thiserror = { workspace = true, optional = true }
time.workspace = true
tokio-tungstenite.workspace = true
tokio.workspace = true
tower-http.workspace = true
tower.workspace = true
tracing.workspace = true
url.workspace = true
uuid.workspace = true
which.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -117,7 +117,7 @@ run: ## Run the main authentik server and worker processes
$(UV) run ak allinone
run-watch: ## Run the authentik server and worker, with auto reloading
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs,go --no-meta --notify -- $(UV) run ak allinone
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak allinone
core-i18n-extract:
$(UV) run ak makemessages \

View File

@@ -92,6 +92,7 @@ class FileBackend(ManageableBackend):
"nbf": now() - timedelta(seconds=15),
},
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
# Must match src/server/static.rs
algorithm="HS256",
)
url = f"{prefix}/files/{path}?token={token}"

View File

@@ -339,6 +339,9 @@ class LoggingMiddleware:
def log(self, request: HttpRequest, status_code: int, runtime: int, **kwargs):
"""Log request"""
# Those are logged by the server above
if request.path in ("/-/metrics/", "/-/health/live/", "/-/health/ready/"):
return
for header in self.headers_to_log:
header_value = request.headers.get(header)
if not header_value:

View File

@@ -5,18 +5,17 @@ from django.db.utils import OperationalError
from django.dispatch import Signal
from django.http import HttpRequest, HttpResponse
from django.views import View
from django_prometheus.exports import ExportToDjangoView
monitoring_set = Signal()
class MetricsView(View):
"""Wrapper around ExportToDjangoView with authentication, accessed by the authentik router"""
"""View for metrics monitoring_set signal, accessed by the authentik router"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Check for HTTP-Basic auth"""
monitoring_set.send_robust(self)
return ExportToDjangoView(request)
return HttpResponse(status=204)
class LiveView(View):

View File

@@ -9,7 +9,7 @@ class TestRoot(TransactionTestCase):
def test_monitoring(self):
"""Test monitoring with credentials"""
self.assertEqual(self.client.get(reverse("metrics")).status_code, 200)
self.assertEqual(self.client.get(reverse("metrics")).status_code, 204)
def test_monitoring_live(self):
"""Test LiveView"""

View File

@@ -44,10 +44,9 @@ for _authentik_app in get_apps():
namespace=namespace,
)
_urlpatterns += [
urlpatterns = [
path(CONFIG.get("web.path", "/")[1:], include(_urlpatterns)),
path("-/metrics/", MetricsView.as_view(), name="metrics"),
path("-/health/live/", LiveView.as_view(), name="health-live"),
path("-/health/ready/", ReadyView.as_view(), name="health-ready"),
]
urlpatterns = [path(CONFIG.get("web.path", "/")[1:], include(_urlpatterns))]

View File

@@ -15,23 +15,7 @@ function wait_for_db {
function run_authentik {
case "$1" in
server)
shift 1
echo -n server >"$MODE_FILE"
if [[ -x "$(command -v authentik-server)" ]]; then
echo authentik-server "$@"
else
echo go run ./cmd/server "$@"
fi
;;
healthcheck)
if [[ -x "$(command -v authentik)" ]]; then
echo authentik "$@"
else
echo cargo run -- "$@"
fi
;;
allinone | worker)
server | healthcheck | allinone | worker)
if [[ -x "$(command -v authentik)" ]]; then
echo authentik "$@"
else

View File

@@ -46,46 +46,6 @@ COPY ./website /work/website/
RUN pnpm run build && \
pnpm run build:sfe
# Stage: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.27rc2-trixie@sha256:c048f885615f96feb36c94ec97648dd8cb752e0a5158cf9affcb95d326a0214d AS go-builder
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ARG GOOS=$TARGETOS
ARG GOARCH=$TARGETARCH
WORKDIR /go/src/goauthentik.io
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
dpkg --add-architecture arm64 && \
dpkg --add-architecture amd64 && \
apt-get update && \
apt-get install -y --no-install-recommends crossbuild-essential-arm64 gcc-aarch64-linux-gnu crossbuild-essential-amd64 gcc-x86-64-linux-gnu
RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
--mount=type=bind,target=/go/src/goauthentik.io/go.sum,src=./go.sum \
--mount=type=cache,target=/go/pkg/mod \
go mod download
COPY ./cmd /go/src/goauthentik.io/cmd
COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib
COPY ./web/static.go /go/src/goauthentik.io/web/static.go
COPY --from=node-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
COPY --from=node-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt
COPY ./internal /go/src/goauthentik.io/internal
COPY ./go.mod /go/src/goauthentik.io/go.mod
COPY ./go.sum /go/src/goauthentik.io/go.sum
COPY ./packages/client-go /go/src/goauthentik.io/packages/client-go
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ] && [ "$(uname -m)" != "aarch64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
if [ "$TARGETARCH" = "amd64" ] && [ "$(uname -m)" != "x86_64" ]; then export CC=x86_64-linux-gnu-gcc; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik-server ./cmd/server
# Stage: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v8.0.0@sha256:51e70dd6f16cd3e4d845ac02d09940b10772a75b9d741427d235a78570923c1d AS geoip
@@ -159,6 +119,10 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
# See https://github.com/aws/aws-lc-rs/issues/569
ENV AWS_LC_FIPS_SYS_CC=clang
COPY --from=node-builder /work/web/robots.txt /build/web/robots.txt
COPY --from=node-builder /work/web/security.txt /build/web/security.txt
COPY --from=node-builder /work/web/dist/standalone/loading/startup.html /build/web/dist/standalone/loading/startup.html
RUN --mount=type=bind,target=rust-toolchain.toml,src=rust-toolchain.toml \
--mount=type=bind,target=Cargo.toml,src=Cargo.toml \
--mount=type=bind,target=Cargo.lock,src=Cargo.lock \
@@ -268,7 +232,6 @@ COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=rust-builder /bin/authentik /bin/authentik
COPY --from=go-builder /go/authentik-server /bin/authentik-server
COPY ./packages/ /ak-root/packages
RUN ln -s /ak-root/packages /packages
COPY --from=python-deps /ak-root/.venv /ak-root/.venv

View File

@@ -1,6 +1,8 @@
"""Gunicorn config"""
import os
import platform
import signal
from hashlib import sha512
from pathlib import Path
from tempfile import gettempdir
@@ -17,7 +19,6 @@ from authentik.lib.utils.reflection import get_env
from authentik.root.install_id import get_install_id_raw
from authentik.root.setup import setup
from lifecycle.migrate import run_migrations
from lifecycle.wait_for_db import wait_for_db
from lifecycle.worker import DjangoUvicornWorker
if TYPE_CHECKING:
@@ -28,16 +29,12 @@ if TYPE_CHECKING:
setup()
wait_for_db()
_tmp = Path(gettempdir())
worker_class = "lifecycle.worker.DjangoUvicornWorker"
worker_tmp_dir = str(_tmp.joinpath("authentik_gunicorn_tmp"))
os.makedirs(worker_tmp_dir, exist_ok=True)
bind = f"unix://{str(_tmp.joinpath('authentik-core.sock'))}"
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
preload_app = True
@@ -45,6 +42,11 @@ preload_app = True
max_requests = CONFIG.get_int("web.max_requests", 1000)
max_requests_jitter = CONFIG.get_int("web.max_requests_jitter", 50)
# Match the value in packages/ak-common/src/arbiter.rs for graceful shutdown
timeout = 30
graceful_timeout = 30
dirty_graceful_timeout = 30
logconfig_dict = get_logger_config()
workers = CONFIG.get_int("web.workers", 2)
@@ -53,6 +55,21 @@ threads = CONFIG.get_int("web.threads", 4)
control_socket_disable = True
# libpq can try Kerberos/GSS on macOS, which is not fork-safe in our Gunicorn worker model.
# Disable GSS negotiation for local/dev PostgreSQL connections on Darwin.
if platform.system() == "Darwin":
os.environ.setdefault("PGGSSENCMODE", "disable")
# Avoid macOS SystemConfiguration proxy lookups (_scproxy) in forked workers.
# urllib/requests may consult these APIs and can crash in child workers.
os.environ.setdefault("NO_PROXY", "*")
os.environ.setdefault("no_proxy", "*")
def when_ready(server: "Arbiter"): # noqa: UP037
# Notify rust process that we are ready
os.kill(os.getppid(), signal.SIGUSR1)
def post_fork(server: "Arbiter", worker: DjangoUvicornWorker): # noqa: UP037
"""Tell prometheus to use worker number instead of process ID for multiprocess"""
from prometheus_client import values

View File

@@ -16,7 +16,6 @@ axum.workspace = true
client-ip.workspace = true
durstr.workspace = true
eyre.workspace = true
forwarded-header-value.workspace = true
futures.workspace = true
pin-project-lite.workspace = true
tokio-rustls.workspace = true

View File

@@ -47,14 +47,6 @@ async fn extract_client_ip(parts: &mut Parts) -> IpAddr {
return ip;
}
if let Ok(ip) = client_ip::x_real_ip(&parts.headers) {
return ip;
}
if let Ok(ip) = client_ip::rightmost_forwarded(&parts.headers) {
return ip;
}
if let Ok(Extension(proxy_protocol_state)) =
parts.extract::<Extension<ProxyProtocolState>>().await
&& let Some(header) = &proxy_protocol_state.header
@@ -79,9 +71,15 @@ async fn extract_client_ip(parts: &mut Parts) -> IpAddr {
pub async fn client_ip_middleware(request: Request, next: Next) -> Response {
let (mut parts, body) = request.into_parts();
let client_ip = extract_client_ip(&mut parts).await;
Span::current().record("remote", client_ip.to_string());
parts.extensions.insert::<ClientIp>(ClientIp(client_ip));
let client_ip = if let Some(client_ip) = parts.extensions.get::<ClientIp>() {
client_ip
} else {
let client_ip = ClientIp(extract_client_ip(&mut parts).await);
parts.extensions.insert(client_ip);
parts.extensions.get::<ClientIp>().expect("infallible")
};
Span::current().record("remote", client_ip.0.to_string());
let request = Request::from_parts(parts, body);
@@ -111,36 +109,6 @@ mod tests {
assert_eq!(client_ip, Ipv4Addr::new(192, 0, 2, 42),);
}
#[tokio::test]
async fn x_real_ip_trusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("x-real-ip", "192.0.2.42")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let client_ip = extract_client_ip(&mut parts).await;
assert_eq!(client_ip, Ipv4Addr::new(192, 0, 2, 42),);
}
#[tokio::test]
async fn forwarded_header_trusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("forwarded", "for=192.0.2.42")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let client_ip = extract_client_ip(&mut parts).await;
assert_eq!(client_ip, Ipv4Addr::new(192, 0, 2, 42),);
}
#[tokio::test]
async fn from_connect_info() {
let connect_addr: SocketAddr = "192.0.2.42:34932"
@@ -174,24 +142,6 @@ mod tests {
assert_eq!(client_ip, Ipv6Addr::LOCALHOST);
}
#[tokio::test]
async fn priority_order() {
// Test that X-Forwarded-For takes priority over other headers when trusted
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("x-forwarded-for", "192.0.2.1")
.header("x-real-ip", "192.0.2.2")
.header("forwarded", "for=192.0.2.3")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let client_ip = extract_client_ip(&mut parts).await;
assert_eq!(client_ip, Ipv4Addr::new(192, 0, 2, 1),);
}
#[tokio::test]
async fn no_ip_found() {
let (mut parts, _) = Request::builder()

View File

@@ -1,22 +1,13 @@
//! axum extractor and middleware to retrieve the host.
use axum::{
Extension, RequestPartsExt as _,
Extension,
extract::{FromRequestParts, Request},
http::{
header::{FORWARDED, HOST},
request::Parts,
status::StatusCode,
},
http::{header::HOST, request::Parts, status::StatusCode},
middleware::Next,
response::{IntoResponse as _, Response},
};
use forwarded_header_value::ForwardedHeaderValue;
use tracing::{Span, instrument};
use crate::extract::trusted_proxy::TrustedProxy;
const X_FORWARDED_HOST: &str = "X-Forwarded-Host";
/// Request host.
///
/// The [`host_middleware`] must be added to the router before using this extractor,
@@ -24,6 +15,12 @@ const X_FORWARDED_HOST: &str = "X-Forwarded-Host";
#[derive(Clone, Debug)]
pub struct Host(pub String);
impl Host {
fn as_str(&self) -> &str {
&self.0
}
}
impl<S> FromRequestParts<S> for Host
where
S: Send + Sync,
@@ -39,34 +36,7 @@ where
/// Get the host from the request.
#[instrument(skip_all)]
async fn extract_host(parts: &mut Parts) -> Result<String, (StatusCode, &'static str)> {
let is_trusted = parts
.extract::<TrustedProxy>()
.await
.unwrap_or(TrustedProxy(false))
.0;
if is_trusted {
if let Some(host) = parts
.headers
.get(X_FORWARDED_HOST)
.and_then(|host| host.to_str().ok())
{
return Ok(host.to_owned());
}
if let Some(forwarded) = parts.headers.get(FORWARDED)
&& let Ok(forwarded) = forwarded.to_str()
&& let Ok(forwarded) = ForwardedHeaderValue::from_forwarded(forwarded)
{
for stanza in forwarded.iter() {
if let Some(forwarded_host) = &stanza.forwarded_host {
return Ok(forwarded_host.to_owned());
}
}
}
}
async fn extract_host(parts: &Parts) -> Result<String, (StatusCode, &'static str)> {
if let Some(host) = parts.headers.get(HOST).and_then(|host| host.to_str().ok()) {
return Ok(host.to_owned());
}
@@ -84,12 +54,18 @@ async fn extract_host(parts: &mut Parts) -> Result<String, (StatusCode, &'static
pub async fn host_middleware(request: Request, next: Next) -> Response {
let (mut parts, body) = request.into_parts();
let host = match extract_host(&mut parts).await {
Ok(host) => host,
Err(err) => return err.into_response(),
let host = if let Some(host) = parts.extensions.get::<Host>() {
host
} else {
let host = match extract_host(&parts).await {
Ok(host) => Host(host),
Err(err) => return err.into_response(),
};
parts.extensions.insert(host);
parts.extensions.get::<Host>().expect("infallible")
};
Span::current().record("host", host.clone());
parts.extensions.insert::<Host>(Host(host));
Span::current().record("host", host.as_str());
let request = Request::from_parts(parts, body);
@@ -137,103 +113,6 @@ mod tests {
);
}
#[tokio::test]
async fn x_forwarded_host_trusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("x-forwarded-host", "forwarded.example.com")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let result = extract_host(&mut parts).await;
assert!(result.is_ok());
assert_eq!(
result.expect("Host extraction should succeed"),
"forwarded.example.com",
);
}
#[tokio::test]
async fn forwarded_header_trusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("forwarded", "host=forwarded.example.com")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let result = extract_host(&mut parts).await;
assert!(result.is_ok());
assert_eq!(
result.expect("Host extraction should succeed"),
"forwarded.example.com",
);
}
#[tokio::test]
async fn forwarded_host_untrusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("x-forwarded-host", "malicious.example.com")
.extension(TrustedProxy(false))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let result = extract_host(&mut parts).await;
assert!(result.is_ok());
assert_eq!(
result.expect("Host extraction should succeed"),
"example.com",
);
}
#[tokio::test]
async fn forwarded_header_untrusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("forwarded", "host=malicious.example.com")
.extension(TrustedProxy(false))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let result = extract_host(&mut parts).await;
assert!(result.is_ok());
assert_eq!(
result.expect("Host extraction should succeed"),
"example.com",
);
}
#[tokio::test]
async fn priority_order() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("x-forwarded-host", "x-forwarded.example.com")
.header("forwarded", "host=forwarded.example.com")
.header("host", "host-header.example.com")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let result = extract_host(&mut parts).await;
assert!(result.is_ok());
assert_eq!(
result.expect("Host extraction should succeed"),
"x-forwarded.example.com",
);
}
#[tokio::test]
async fn no_host_found() {
let (mut parts, _) = Request::builder()
@@ -247,26 +126,4 @@ mod tests {
assert!(result.is_err());
assert_eq!(result.expect_err("Host extract should fail").0, 400);
}
#[tokio::test]
async fn multiple_forwarded_stanzas() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header(
"forwarded",
"host=first.example.com, host=second.example.com",
)
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let result = extract_host(&mut parts).await;
assert!(result.is_ok());
assert_eq!(
result.expect("Host extraction should succeed"),
"first.example.com",
);
}
}

View File

@@ -3,11 +3,10 @@
use axum::{
Extension, RequestPartsExt as _,
extract::{FromRequestParts, Request},
http::{self, header::FORWARDED, request::Parts},
http::{self, request::Parts},
middleware::Next,
response::Response,
};
use forwarded_header_value::{ForwardedHeaderValue, Protocol};
use tracing::{Span, instrument};
use crate::{
@@ -16,7 +15,6 @@ use crate::{
};
const X_FORWARDED_PROTO: &str = "X-Forwarded-Proto";
const X_FORWARDED_SCHEME: &str = "X-Forwarded-Scheme";
/// Request scheme.
///
@@ -25,6 +23,12 @@ const X_FORWARDED_SCHEME: &str = "X-Forwarded-Scheme";
#[derive(Clone, Debug)]
pub struct Scheme(pub http::uri::Scheme);
impl Scheme {
fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<S> FromRequestParts<S> for Scheme
where
S: Send + Sync,
@@ -55,28 +59,6 @@ async fn extract_scheme(parts: &mut Parts) -> http::uri::Scheme {
return scheme;
}
if let Some(proto) = parts.headers.get(X_FORWARDED_SCHEME)
&& let Ok(proto) = proto.to_str()
&& let Ok(scheme) = proto.to_lowercase().as_str().try_into()
{
return scheme;
}
if let Some(forwarded) = parts.headers.get(FORWARDED)
&& let Ok(forwarded) = forwarded.to_str()
&& let Ok(forwarded) = ForwardedHeaderValue::from_forwarded(forwarded)
{
for stanza in forwarded.iter() {
if let Some(forwarded_proto) = &stanza.forwarded_proto {
let scheme = match forwarded_proto {
Protocol::Http => http::uri::Scheme::HTTP,
Protocol::Https => http::uri::Scheme::HTTPS,
};
return scheme;
}
}
}
if let Ok(Extension(proxy_protocol_state)) =
parts.extract::<Extension<ProxyProtocolState>>().await
&& let Some(header) = &proxy_protocol_state.header
@@ -99,9 +81,15 @@ async fn extract_scheme(parts: &mut Parts) -> http::uri::Scheme {
pub async fn scheme_middleware(request: Request, next: Next) -> Response {
let (mut parts, body) = request.into_parts();
let scheme = extract_scheme(&mut parts).await;
Span::current().record("scheme", scheme.to_string());
parts.extensions.insert::<Scheme>(Scheme(scheme));
let scheme = if let Some(scheme) = parts.extensions.get::<Scheme>() {
scheme
} else {
let scheme = Scheme(extract_scheme(&mut parts).await);
parts.extensions.insert(scheme);
parts.extensions.get::<Scheme>().expect("infallible")
};
Span::current().record("scheme", scheme.as_str());
let request = Request::from_parts(parts, body);
@@ -129,36 +117,6 @@ mod tests {
assert_eq!(scheme, http::uri::Scheme::HTTPS,);
}
#[tokio::test]
async fn x_forwarded_scheme_trusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("x-forwarded-scheme", "https")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let scheme = extract_scheme(&mut parts).await;
assert_eq!(scheme, http::uri::Scheme::HTTPS,);
}
#[tokio::test]
async fn forwarded_header_trusted() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("forwarded", "proto=https")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let scheme = extract_scheme(&mut parts).await;
assert_eq!(scheme, http::uri::Scheme::HTTPS,);
}
#[tokio::test]
async fn x_forwarded_proto_untrusted() {
let (mut parts, _) = Request::builder()
@@ -203,38 +161,6 @@ mod tests {
assert_eq!(scheme, http::uri::Scheme::HTTP,);
}
#[tokio::test]
async fn priority_order() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("x-forwarded-proto", "http")
.header("x-forwarded-scheme", "https")
.header("forwarded", "proto=https")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let scheme = extract_scheme(&mut parts).await;
assert_eq!(scheme, http::uri::Scheme::HTTP,);
}
#[tokio::test]
async fn multiple_forwarded_stanzas() {
let (mut parts, _) = Request::builder()
.uri("http://example.com/path")
.header("forwarded", "proto=http, proto=https")
.extension(TrustedProxy(true))
.body(Body::empty())
.expect("Failed to create request")
.into_parts();
let scheme = extract_scheme(&mut parts).await;
assert_eq!(scheme, http::uri::Scheme::HTTP,);
}
#[tokio::test]
async fn test_scheme_case_insensitive() {
let (mut parts, _) = Request::builder()

View File

@@ -58,10 +58,12 @@ async fn extract_trusted_proxy(parts: &mut Parts) -> bool {
pub async fn trusted_proxy_middleware(request: Request, next: Next) -> Response {
let (mut parts, body) = request.into_parts();
let trusted_proxy = extract_trusted_proxy(&mut parts).await;
parts
.extensions
.insert::<TrustedProxy>(TrustedProxy(trusted_proxy));
if parts.extensions.get::<TrustedProxy>().is_none() {
let trusted_proxy = extract_trusted_proxy(&mut parts).await;
parts
.extensions
.insert::<TrustedProxy>(TrustedProxy(trusted_proxy));
}
let request = Request::from_parts(parts, body);

View File

@@ -1,5 +1,7 @@
//! Utilities for working with the authentik API client.
use std::path::PathBuf;
use ak_client::{apis::configuration::Configuration, models::Pagination};
use eyre::{Result, eyre};
use url::Url;
@@ -60,6 +62,23 @@ pub fn make_config() -> Result<Configuration> {
})
}
pub fn make_config_embedded(socket_path: PathBuf) -> Result<Configuration> {
let base_path = format!("http://localhost{}api/v3", config::get().web.path);
let client = reqwest::ClientBuilder::new()
.unix_socket(socket_path)
.build()?;
let client = reqwest_middleware::ClientBuilder::new(client).build();
Ok(Configuration {
base_path,
client,
bearer_access_token: Some(config::get().secret_key.clone()),
user_agent: Some(user_agent_outpost()),
..Default::default()
})
}
/// Fetch all pages from a paginated API endpoint, returning all results combined.
///
/// - `fetch`: takes a page number and returns a future resolving to a paginated response.

View File

@@ -295,6 +295,9 @@ pub enum Event {
Signal(SignalKind),
/// The configuration has been reloaded from sources.
ConfigChanged,
#[cfg(feature = "core")]
/// The Gunicorn server has been marked as ready.
GunicornIsReady,
}
impl From<SignalKind> for Event {

View File

@@ -1,4 +1,4 @@
use std::{collections::HashMap, net::SocketAddr, num::NonZeroUsize};
use std::{collections::HashMap, net::SocketAddr, num::NonZeroUsize, path::PathBuf};
use ipnet::IpNet;
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
@@ -146,6 +146,10 @@ pub struct Config {
pub worker: WorkerConfig,
pub storage: StorageConfig,
pub outposts: OutpostConfig,
// Outpost specific fields
pub host: Option<String>,
pub host_browser: Option<String>,
@@ -228,3 +232,32 @@ pub struct WorkerConfig {
#[serde(deserialize_with = "deserialize_str_or_num")]
pub processes: NonZeroUsize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
pub backend: String,
pub file: StorageFileConfig,
pub media: Option<StorageOverrideConfig>,
pub reports: Option<StorageOverrideConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageFileConfig {
pub path: PathBuf,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct StorageOverrideConfig {
pub backend: Option<String>,
pub file: Option<StorageFileOverrideConfig>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct StorageFileOverrideConfig {
pub path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutpostConfig {
pub disable_embedded_outpost: bool,
}

View File

@@ -55,7 +55,11 @@ async fn update_connect_opts_on_config_change(arbiter: Arbiter) -> Result<()> {
info!("starting database watcher for config changes");
loop {
tokio::select! {
Ok(Event::ConfigChanged) = events_rx.recv() => {
event = events_rx.recv() => {
if event != Ok(Event::ConfigChanged) {
continue;
}
trace!("config change received, refreshing database connection options");
let db = get();
db.set_connect_options(get_connect_opts().await?);

1
src/brands/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub(crate) mod tls;

133
src/brands/tls.rs Normal file
View File

@@ -0,0 +1,133 @@
use std::{
collections::{HashMap, hash_map::Entry},
sync::Arc,
};
use ak_common::db;
use eyre::{Report, Result};
use rustls::{
RootCertStore,
crypto::CryptoProvider,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
server::ClientHello,
sign::CertifiedKey,
};
#[derive(Debug)]
struct Brand {
domain: String,
default: bool,
web_certificate: Arc<CertifiedKey>,
}
#[derive(Debug)]
pub(crate) struct BrandCertResolver {
brands: Vec<Brand>,
}
impl BrandCertResolver {
pub(crate) fn resolve(&self, client_hello: &ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
let server_name = client_hello.server_name()?;
let mut best = None;
for brand in &self.brands {
if best.is_none() && brand.default {
best = Some(Arc::clone(&brand.web_certificate));
}
if server_name == brand.domain || server_name.ends_with(&format!(".{}", brand.domain)) {
best = Some(Arc::clone(&brand.web_certificate));
}
}
best
}
}
pub(crate) async fn make_cert_managers() -> Result<(BrandCertResolver, RootCertStore)> {
#[derive(sqlx::FromRow)]
struct BrandRow {
brand_uuid: uuid::Uuid,
domain: String,
default: bool,
web_cert_data: Option<String>,
web_cert_key: Option<String>,
client_cert_data: Option<String>,
}
let rows = sqlx::query_as::<_, BrandRow>(
"
SELECT
b.brand_uuid,
b.domain,
b.default,
wc.certificate_data AS web_cert_data,
wc.key_data AS web_cert_key,
cc.certificate_data AS client_cert_data
FROM authentik_brands_brand b
LEFT JOIN authentik_crypto_certificatekeypair wc
ON wc.kp_uuid = b.web_certificate_id
LEFT JOIN authentik_brands_brand_client_certificates bcc
ON bcc.brand_id = b.brand_uuid
LEFT JOIN authentik_crypto_certificatekeypair cc
ON cc.kp_uuid = bcc.certificatekeypair_id
",
)
.fetch_all(db::get())
.await?;
let (brands, roots) = tokio::task::spawn_blocking(|| {
let mut brands = HashMap::new();
let mut roots = RootCertStore::empty();
for row in rows {
let BrandRow {
brand_uuid,
domain,
default,
web_cert_data,
web_cert_key,
client_cert_data,
} = row;
if let (Some(certificate_data), Some(key_data)) = (web_cert_data, web_cert_key)
&& let Entry::Vacant(e) = brands.entry(brand_uuid)
{
let brand = Brand {
domain,
default,
web_certificate: {
let cert_chain =
CertificateDer::pem_reader_iter(certificate_data.as_bytes())
.collect::<Result<Vec<_>, _>>()?;
let key_der = PrivateKeyDer::from_pem_reader(key_data.as_bytes())?;
let provider =
CryptoProvider::get_default().expect("no rustls provider installed");
Arc::new(CertifiedKey::new(
cert_chain,
provider.key_provider.load_private_key(key_der)?,
))
},
};
e.insert(brand);
}
if let Some(certificate_data) = client_cert_data {
let cert_chain = CertificateDer::pem_reader_iter(certificate_data.as_bytes())
.collect::<Result<Vec<_>, _>>()?;
for cert in cert_chain {
roots.add(cert)?;
}
}
}
Ok::<_, Report>((brands, roots))
})
.await??;
Ok((
BrandCertResolver {
brands: brands.into_values().collect(),
},
roots,
))
}

View File

@@ -7,10 +7,12 @@ use argh::FromArgs;
use eyre::{Result, eyre};
use tracing::{error, info, trace};
#[cfg(feature = "core")]
pub(crate) mod brands;
mod healthcheck;
mod metrics;
#[cfg(feature = "proxy")]
mod outpost;
pub(crate) mod outpost;
#[cfg(feature = "core")]
mod server;
#[cfg(feature = "core")]
@@ -97,20 +99,22 @@ fn main() -> Result<()> {
let metrics = metrics::start(&mut tasks)?;
#[cfg(feature = "core")]
if Mode::get() == Mode::AllInOne || Mode::get() == Mode::Worker {
if Mode::is_core() {
db::init(&mut tasks).await?;
}
match cli.command {
#[cfg(feature = "core")]
Command::AllInOne(_) => {
server::start(server::Cli::default(), &mut tasks).await?;
let workers = worker::start(worker::Cli::default(), &mut tasks)?;
metrics.workers.store(Some(workers));
let server = server::start(server::Cli::default(), &mut tasks).await?;
metrics.server.store(Some(server));
}
#[cfg(feature = "core")]
Command::Server(args) => {
server::start(args, &mut tasks).await?;
let server = server::start(args, &mut tasks).await?;
metrics.server.store(Some(server));
}
#[cfg(feature = "core")]
Command::Worker(args) => {

View File

@@ -15,13 +15,24 @@ pub(super) async fn metrics_handler(State(state): State<Arc<Metrics>>) -> Result
#[cfg(feature = "core")]
if Mode::is_core() {
if Mode::get() == Mode::Worker
&& let Some(workers) = state.workers.load_full()
{
workers.notify_metrics().await?;
match Mode::get() {
Mode::Server if let Some(server) = state.server.load_full() => {
server.notify_metrics().await?;
metrics.extend(spawn_blocking(python::get_python_metrics).await??);
}
Mode::Worker if let Some(workers) = state.workers.load_full() => {
workers.notify_metrics().await?;
metrics.extend(spawn_blocking(python::get_python_metrics).await??);
}
Mode::AllInOne
if let Some(server) = state.server.load_full()
&& let Some(workers) = state.workers.load_full() =>
{
tokio::try_join!(server.notify_metrics(), workers.notify_metrics())?;
metrics.extend(spawn_blocking(python::get_python_metrics).await??);
}
_ => {}
}
metrics.extend(spawn_blocking(python::get_python_metrics).await??);
}
Ok(Response::builder()

View File

@@ -2,7 +2,6 @@ use std::{env::temp_dir, os::unix, path::PathBuf, sync::Arc};
use ak_axum::{router::wrap_router, server};
use ak_common::{
Mode,
arbiter::{Arbiter, Tasks},
config,
};
@@ -16,6 +15,8 @@ use tokio::{
};
use tracing::info;
#[cfg(feature = "core")]
use crate::server::Server;
#[cfg(feature = "core")]
use crate::worker::Workers;
@@ -28,6 +29,8 @@ pub(crate) fn socket_path() -> PathBuf {
pub(crate) struct Metrics {
prometheus: PrometheusHandle,
#[cfg(feature = "core")]
pub(crate) server: ArcSwapOption<Server>,
#[cfg(feature = "core")]
pub(crate) workers: ArcSwapOption<Workers>,
}
@@ -40,6 +43,8 @@ impl Metrics {
Ok(Self {
prometheus,
#[cfg(feature = "core")]
server: ArcSwapOption::empty(),
#[cfg(feature = "core")]
workers: ArcSwapOption::empty(),
})
}
@@ -78,27 +83,16 @@ pub(crate) fn start(tasks: &mut Tasks) -> Result<Arc<Metrics>> {
.name(&format!("{}::run_upkeep", module_path!()))
.spawn(run_upkeep(arbiter, Arc::clone(&metrics)))?;
// Serve the metrics endpoint for the worker and the standalone proxy outpost.
// In server and allinone mode, the server handles it.
let serve_metrics = match Mode::get() {
#[cfg(feature = "core")]
Mode::Worker => true,
#[cfg(feature = "proxy")]
Mode::Proxy => true,
_ => false,
};
if serve_metrics {
for addr in config::get().listen.metrics.iter().copied() {
server::start_plain(tasks, "metrics", router.clone(), addr)?;
}
server::start_unix(
tasks,
"metrics",
router,
unix::net::SocketAddr::from_pathname(socket_path())?,
)?;
for addr in config::get().listen.metrics.iter().copied() {
server::start_plain(tasks, "metrics", router.clone(), addr)?;
}
server::start_unix(
tasks,
"metrics",
router,
unix::net::SocketAddr::from_pathname(socket_path())?,
)?;
Ok(metrics)
}

View File

@@ -1,18 +1,19 @@
use std::{fmt::Display, sync::Arc};
use ak_common::{Arbiter, Tasks, VERSION, api, arbiter, authentik_build_hash};
use ak_common::{Arbiter, Tasks, VERSION, api, arbiter, authentik_build_hash, config};
use axum::http::{HeaderValue, header::AUTHORIZATION};
use eyre::{Result, eyre};
use futures::{SinkExt as _, StreamExt as _};
use futures::{Sink, SinkExt as _, Stream, StreamExt as _};
use nix::unistd::gethostname;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use time::UtcDateTime;
use tokio::{
net::UnixStream,
signal::unix::SignalKind,
time::{Duration, interval, sleep},
};
use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest as _};
use tokio_tungstenite::tungstenite::{Error as WsError, Message, client::IntoClientRequest as _};
use tracing::{debug, info, instrument, trace, warn};
use url::Url;
@@ -130,6 +131,9 @@ async fn watch_events_inner<O: Outpost>(
outpost: Arc<O>,
attempt: u32,
) -> Result<()> {
type WsWriter = Box<dyn Sink<Message, Error = WsError> + Unpin + Send>;
type WsReader = Box<dyn Stream<Item = Result<Message, WsError>> + Unpin + Send>;
info!("refreshing outpost forcefully");
if let Err(err) = handle_event(
Arc::clone(&controller),
@@ -145,9 +149,15 @@ async fn watch_events_inner<O: Outpost>(
warn!(?err, "failed to refresh");
}
let server_config = api::ServerConfig::new()?;
let host = if controller.is_embedded() {
Url::parse(&format!("http://localhost{}", config::get().web.path))?
} else {
let server_config = api::ServerConfig::new()?;
server_config.host
};
let ws_url = build_ws_url(
server_config.host,
host,
&controller.outpost.load().pk.to_string(),
&controller.instance_uuid.to_string(),
attempt,
@@ -165,8 +175,30 @@ async fn watch_events_inner<O: Outpost>(
HeaderValue::from_str(&format!("Bearer {token}"))?,
);
let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
let (mut ws_write, mut ws_read) = ws_stream.split();
// Embedded outposts run inside the core server and reach it over its unix socket,
// which only exists when built with the `core` feature.
let embedded_stream = if controller.is_embedded() {
#[cfg(feature = "core")]
{
Some(UnixStream::connect(crate::server::socket_path()).await?)
}
#[cfg(not(feature = "core"))]
{
None::<UnixStream>
}
} else {
None
};
let (mut ws_write, mut ws_read): (WsWriter, WsReader) = if let Some(stream) = embedded_stream {
let (ws_stream, _response) = tokio_tungstenite::client_async(request, stream).await?;
let (write, read) = ws_stream.split();
(Box::new(write), Box::new(read))
} else {
let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
let (write, read) = ws_stream.split();
(Box::new(write), Box::new(read))
};
info!(
outpost = %controller.outpost.load().pk,
@@ -221,8 +253,8 @@ async fn watch_events_inner<O: Outpost>(
ws_write.send(Message::text(serde_json::to_string(&ping)?)).await?;
trace!("sent websocket hello (heartbeat)");
},
Ok(arbiter::Event::Signal(signal)) = events_rx.recv() => {
if signal == SignalKind::user_defined1() {
event = events_rx.recv() => {
if let Ok(arbiter::Event::Signal(signal)) = event && signal == SignalKind::user_defined1() {
info!("refreshing outpost on signal");
if let Err(err) = handle_event(
Arc::clone(&controller),

View File

@@ -4,6 +4,8 @@ use ak_client::{
apis::{configuration::Configuration, outposts_api::outposts_instances_list},
models::Outpost as OutpostModel,
};
#[cfg(feature = "core")]
use ak_common::Mode;
use ak_common::{Tasks, VERSION, api, authentik_build_hash};
use arc_swap::ArcSwap;
use eyre::{Result, eyre};
@@ -59,7 +61,25 @@ impl OutpostController {
#[instrument(skip_all)]
async fn new<O: Outpost>() -> Result<Self> {
let api_config = api::make_config()?;
let (detected_as_embedded, api_config) = {
#[cfg(not(feature = "core"))]
{
(false, api::make_config()?)
}
#[cfg(feature = "core")]
{
if Mode::is_core() {
(
true,
api::make_config_embedded(crate::server::socket_path())?,
)
} else {
(false, api::make_config()?)
}
}
};
let outpost = Self::get_outpost(&api_config).await?;
let instance_uuid = Uuid::new_v4();
@@ -88,6 +108,13 @@ impl OutpostController {
m_connection,
};
if detected_as_embedded && !controller.is_embedded() {
return Err(eyre!(
"We think we are running as embedded, but the outpost returned by the API is not \
the embedded outpost."
));
}
info!(embedded = controller.is_embedded(), "outpost mode");
debug!(?reload_offset, "HA Reload offset");
@@ -111,13 +138,13 @@ impl OutpostController {
}
#[instrument(skip_all)]
pub(crate) async fn start<O: Outpost + 'static>(_cli: O::Cli, tasks: &mut Tasks) -> Result<()> {
pub(crate) async fn start<O: Outpost + 'static>(_cli: O::Cli, tasks: &mut Tasks) -> Result<Arc<O>> {
let controller = Arc::new(OutpostController::new::<O>().await?);
let outpost = Arc::new(O::new(Arc::clone(&controller)).await?);
event::start(tasks, Arc::clone(&controller), Arc::clone(&outpost))?;
outpost.start(tasks)?;
Arc::clone(&outpost).start(tasks)?;
controller.m_info.set(1_u8);
Ok(())
Ok(outpost)
}

View File

@@ -6,7 +6,7 @@ use ak_client::{
};
use ak_common::{config, tls::store::Certificate};
use arc_swap::ArcSwapOption;
use axum::{Router, routing::any};
use axum::{Router, http::Uri, routing::any};
use eyre::{Result, eyre};
use jsonwebtoken::jwk::JwkSet;
use moka::future::Cache;
@@ -18,14 +18,15 @@ use crate::outpost::proxy::{
claims::Claims,
cookie::SessionCookie,
endpoint::OidcEndpoint,
oauth,
session::{SessionStore, filesystem::FsSessionStore},
upstream,
};
pub(super) mod handlers;
pub(crate) mod handlers;
#[derive(Debug)]
pub(super) struct Application {
pub(crate) struct Application {
pub(super) host: String,
pub(super) provider: ProxyOutpostConfig,
pub(super) router: Router<Arc<Self>>,
@@ -183,6 +184,22 @@ impl Application {
})
}
/// Whether the embedded outpost should handle this request rather than
/// letting it fall through to the core backend. Proxy-mode apps handle
/// everything; forward-auth apps only handle their own outpost endpoints
/// and OAuth callback/logout signatures.
pub(super) fn should_handle_url(&self, uri: &Uri) -> bool {
if self.provider.mode == Some(ProxyMode::Proxy) {
return true;
}
if uri.path().starts_with("/outpost.goauthentik.io") {
return true;
}
let query = uri.query();
oauth::has_signature(query, oauth::CALLBACK_SIGNATURE)
|| oauth::has_signature(query, oauth::LOGOUT_SIGNATURE)
}
/// Default session lifetime: the access token validity plus one second
/// (so the session never outlives indefinitely), or zero if unset.
pub(super) fn session_max_age(&self) -> Duration {

View File

@@ -14,7 +14,7 @@ use tracing::{debug, instrument, trace, warn};
use crate::outpost::proxy::{ProxyOutpost, application};
#[instrument(skip_all)]
pub(super) async fn handle_ping(
pub(crate) async fn handle_ping(
method: Method,
Host(host): Host,
State(outpost): State<Arc<ProxyOutpost>>,

View File

@@ -10,7 +10,7 @@ use ak_common::{
};
use arc_swap::ArcSwap;
use argh::FromArgs;
use axum::Router;
use axum::{Extension, Router, extract::Request, http::Uri, response::Response};
use axum_server::tls_rustls::RustlsConfig;
use eyre::Result;
use rustls::{
@@ -23,7 +23,7 @@ use tracing::{debug, error, info, instrument, warn};
use crate::outpost::{Outpost, OutpostController, proxy::application::Application};
mod allowlist;
mod application;
pub(crate) mod application;
mod auth;
mod backchannel;
mod claims;
@@ -31,7 +31,7 @@ mod cookie;
mod endpoint;
mod error_page;
mod events;
mod handlers;
pub(crate) mod handlers;
mod headers;
mod oauth;
mod oauth_state;
@@ -75,27 +75,27 @@ impl Outpost for ProxyOutpost {
fn start(self: Arc<Self>, tasks: &mut Tasks) -> Result<()> {
let router = build_router(Arc::clone(&self));
for addr in config::get().listen.http.iter().copied() {
ak_axum::server::start_plain(tasks, "proxy-outpost", router.clone(), addr)?;
}
for addr in config::get().listen.https.iter().copied() {
let resolver = Arc::clone(&self);
let server_config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver);
let rustls_config = RustlsConfig::from_config(Arc::new(server_config));
ak_axum::server::start_tls(
tasks,
"proxy-outpost",
router.clone(),
addr,
rustls_config,
)?;
}
// Non-embedded outposts use the filesystem session store; sweep expired files.
// In non-embedded mode, we need to start http(s) listeners and filesystem cleanup.
if !self.controller.is_embedded() {
for addr in config::get().listen.http.iter().copied() {
ak_axum::server::start_plain(tasks, "proxy-outpost", router.clone(), addr)?;
}
for addr in config::get().listen.https.iter().copied() {
let resolver = Arc::clone(&self);
let server_config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver);
let rustls_config = RustlsConfig::from_config(Arc::new(server_config));
ak_axum::server::start_tls(
tasks,
"proxy-outpost",
router.clone(),
addr,
rustls_config,
)?;
}
let arbiter = tasks.arbiter();
tasks
.build_task()
@@ -182,13 +182,10 @@ impl Outpost for ProxyOutpost {
impl ResolvesServerCert for ProxyOutpost {
fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
if let Some(server_name) = client_hello.server_name()
&& let Some(app) = self.apps.load().get(server_name)
&& let Some(cert) = &app.cert
{
return Some(Arc::clone(&cert.certified_key));
}
Some(Arc::clone(&self.default_cert))
Some(
self.resolve_cert(&client_hello)
.unwrap_or_else(|| Arc::clone(&self.default_cert)),
)
}
fn only_raw_public_keys(&self) -> bool {
@@ -197,8 +194,18 @@ impl ResolvesServerCert for ProxyOutpost {
}
impl ProxyOutpost {
pub(crate) fn resolve_cert(&self, client_hello: &ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
if let Some(server_name) = client_hello.server_name()
&& let Some(app) = self.apps.load().get(server_name)
&& let Some(cert) = &app.cert
{
return Some(Arc::clone(&cert.certified_key));
}
None
}
#[instrument(skip(self))]
fn lookup_app(&self, host: &str) -> Option<Arc<Application>> {
pub(crate) fn lookup_app(&self, host: &str) -> Option<Arc<Application>> {
let apps = self.apps.load();
if apps.is_empty() {
@@ -245,6 +252,24 @@ impl ProxyOutpost {
longest_match
}
/// The application that should serve this request from the embedded
/// outpost, if any. `None` means the request belongs to the core backend.
pub(crate) fn app_for_request(&self, host: &str, uri: &Uri) -> Option<Arc<Application>> {
let app = self.lookup_app(host)?;
app.should_handle_url(uri).then_some(app)
}
}
async fn embedded_handle(
Extension(app): Extension<Arc<Application>>,
request: Request,
) -> ak_axum::error::Result<Response> {
application::handlers::handle(app, request).await
}
pub(crate) fn embedded_router() -> Router {
wrap_router(Router::new().fallback(embedded_handle), true)
}
fn build_router(outpost: Arc<ProxyOutpost>) -> Router {

View File

@@ -215,12 +215,12 @@ pub(crate) async fn cleanup_loop(arbiter: Arbiter) -> Result<()> {
let mut ticker = interval(CLEANUP_INTERVAL);
loop {
tokio::select! {
() = arbiter.shutdown() => break,
_ = ticker.tick() => {
if let Err(err) = store.cleanup().await {
warn!(?err, "session cleanup failed");
}
}
() = arbiter.shutdown() => break,
}
}
Ok(())

503
src/server/core.rs Normal file
View File

@@ -0,0 +1,503 @@
use std::sync::{Arc, LazyLock, atomic::Ordering};
use ak_axum::{
accept::tls::TlsState,
error::Result,
extract::{client_ip::ClientIp, host::Host, scheme::Scheme, trusted_proxy::TrustedProxy},
router::wrap_router,
};
use ak_common::{config, db};
use axum::{
Extension, Router,
body::Body,
extract::{OriginalUri, Request, State},
http::{
HeaderName, HeaderValue, StatusCode, Uri,
header::{ACCEPT, CONTENT_TYPE, HOST, LOCATION, RETRY_AFTER},
},
response::Response,
routing::any,
};
use http_body_util::BodyExt as _;
use serde_json::json;
use tracing::{instrument, warn};
use crate::server::{
GUNICORN_READY, Server,
core::websockets::{handle_websocket_upgrade, is_websocket_upgrade},
};
static STARTUP_RESPONSE_JSON: LazyLock<Response<String>> = LazyLock::new(|| {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(RETRY_AFTER, "5")
.header(CONTENT_TYPE, "application/json")
.body(
json!({
"error": "authentik starting",
})
.to_string(),
)
.expect("infallible")
});
static STARTUP_RESPONSE_HTML: LazyLock<Response<String>> = LazyLock::new(|| {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(CONTENT_TYPE, "text/html")
.body(include_str!("../../web/dist/standalone/loading/startup.html").to_owned())
.expect("infallible")
});
static STARTUP_RESPONSE_PLAIN: LazyLock<Response<String>> = LazyLock::new(|| {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(CONTENT_TYPE, "text/plain")
.body("authentik starting".to_owned())
.expect("infallible")
});
const X_FORWARDED_CLIENT_CERT: HeaderName = HeaderName::from_static("x-forwarded-client-cert");
const X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
const X_FORWARDED_PROTO: HeaderName = HeaderName::from_static("x-forwarded-proto");
const FORWARD_ALWAYS_REMOVED_HEADERS: [HeaderName; 7] = [
HeaderName::from_static("forwarded"),
HeaderName::from_static("host"),
X_FORWARDED_FOR,
HeaderName::from_static("x-forwarded-host"),
X_FORWARDED_PROTO,
HeaderName::from_static("x-forwarded-scheme"),
HeaderName::from_static("x-real-ip"),
];
const FORWARD_REMOVED_HEADERS_IF_UNTRUSTED: [HeaderName; 3] = [
HeaderName::from_static("ssl-client-cert"), // nginx-ingress
HeaderName::from_static("x-forwarded-tls-client-cert"), // traefik
X_FORWARDED_CLIENT_CERT, // envoy
];
fn startup_response(accept_header: &str) -> Response {
let response = if accept_header.contains("application/json") {
STARTUP_RESPONSE_JSON.clone()
} else if accept_header.contains("text/html") {
STARTUP_RESPONSE_HTML.clone()
} else {
STARTUP_RESPONSE_PLAIN.clone()
};
let (parts, body) = response.into_parts();
Response::from_parts(parts, body.into())
}
#[expect(
clippy::too_many_arguments,
reason = "We need all that data to forward the request properly"
)]
async fn forward_request(
ClientIp(client_ip): ClientIp,
Host(host): Host,
Scheme(scheme): Scheme,
State(server): State<Arc<Server>>,
TrustedProxy(trusted_proxy): TrustedProxy,
tls_state: Option<Extension<TlsState>>,
OriginalUri(uri): OriginalUri,
mut request: Request,
) -> Result<Response> {
let accept_header = request
.headers()
.get(ACCEPT)
.map(|v| v.to_str().unwrap_or_default().to_owned())
.unwrap_or_default();
if !GUNICORN_READY.load(Ordering::Relaxed) {
return Ok(startup_response(&accept_header));
}
let uri = Uri::builder()
.scheme("http")
.authority("localhost:8000")
.path_and_query(uri.path_and_query().map(|x| x.as_str()).unwrap_or_default())
.build()?;
*request.uri_mut() = uri;
for header_name in FORWARD_ALWAYS_REMOVED_HEADERS {
request.headers_mut().remove(header_name);
}
if !trusted_proxy {
for header_name in FORWARD_REMOVED_HEADERS_IF_UNTRUSTED {
request.headers_mut().remove(header_name);
}
}
request.headers_mut().insert(
X_FORWARDED_FOR,
HeaderValue::from_str(&client_ip.to_string())?,
);
request
.headers_mut()
.insert(HOST, HeaderValue::from_str(&host)?);
request
.headers_mut()
.insert(X_FORWARDED_PROTO, HeaderValue::from_str(scheme.as_ref())?);
if is_websocket_upgrade(request.headers()) {
return handle_websocket_upgrade(request, server).await;
}
if let Some(tls_state) = tls_state
&& let Some(peer_certificates) = &tls_state.peer_certificates
{
let xfcc = peer_certificates
.iter()
.map(|cert| {
let pem_encoded = pem::encode(&pem::Pem::new("CERTIFICATE", cert.as_ref()));
let url_encoded: String =
url::form_urlencoded::byte_serialize(pem_encoded.as_bytes()).collect();
format!("Cert={url_encoded}")
})
.collect::<Vec<_>>()
.join(",");
request
.headers_mut()
.insert("X_FORWARDED_CLIENT_CERT", HeaderValue::from_str(&xfcc)?);
}
match server.client.request(request).await {
Ok(res) => {
let (parts, body) = res.into_parts();
Ok(Response::from_parts(
parts,
Body::from_stream(body.into_data_stream()),
))
}
Err(_) => Ok(startup_response(&accept_header)),
}
}
#[instrument(skip_all)]
async fn health_ready(State(server): State<Arc<Server>>) -> Result<StatusCode> {
if !server.is_alive().await {
warn!("server detected as not alive");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
if let Err(err) = sqlx::query("SELECT 1").execute(db::get()).await {
warn!(?err, "failed to check db health");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
match server.health_ready().await {
Ok(true) => {}
Ok(false) => {
warn!("server responded not ready");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
Err(err) => {
warn!(?err, "failed to check server health readiness");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
}
if let Some(workers) = server.workers.load_full() {
if !workers.are_alive().await {
warn!("workers detected as not alive");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
match workers.health_ready().await {
Ok(true) => {}
Ok(false) => {
warn!("workers responded not ready");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
Err(err) => {
warn!(?err, "failed to check workers health readiness");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
}
}
Ok(StatusCode::OK)
}
#[instrument(skip_all)]
async fn health_live(State(server): State<Arc<Server>>) -> Result<StatusCode> {
if !server.is_alive().await {
warn!("server detected as not alive");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
if !server.health_live().await? {
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
match server.health_live().await {
Ok(true) => {}
Ok(false) => {
warn!("server responded not live");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
Err(err) => {
warn!(?err, "failed to check server health liveness");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
}
if let Some(workers) = server.workers.load_full() {
if !workers.are_alive().await {
warn!("workers detected as not alive");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
match workers.health_live().await {
Ok(true) => {}
Ok(false) => {
warn!("workers responded not live");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
Err(err) => {
warn!(?err, "failed to check workers health liveness");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
}
}
Ok(StatusCode::OK)
}
pub(super) fn build_router(server: &Arc<Server>) -> eyre::Result<Router> {
// Router for monitoring endpoints, under /-/
let monitoring_router = wrap_router(
Router::new()
.route("/health/ready/", any(health_ready))
.route("/health/live/", any(health_live))
.fallback(any(StatusCode::NOT_FOUND))
.with_state(Arc::clone(server)),
true,
);
// Static files
let static_router = wrap_router(super::r#static::build_router(), true);
// Router for endpoints handled in Python
let gunicorn_router = wrap_router(
Router::new()
.fallback(forward_request)
.with_state(Arc::clone(server)),
// Enable tracing but only in debug.
config::get().debug,
);
let router = Router::new()
.nest("/-/", monitoring_router.clone())
.merge(static_router)
.merge(gunicorn_router);
let web_path = &config::get().web.path;
let router = if web_path == "/" {
router
} else {
let redirect_response = (
StatusCode::FOUND,
[(LOCATION, HeaderValue::from_str(web_path)?)],
);
let redirect_router = wrap_router(Router::new().route("/", any(redirect_response)), true);
Router::new()
.merge(redirect_router)
.nest(web_path, router)
.nest("/-/", monitoring_router)
};
Ok(router)
}
mod websockets {
use std::sync::Arc;
use ak_axum::error::{AppError, Result};
use axum::{
body::Body,
extract::Request,
http::{
HeaderMap, HeaderValue, StatusCode,
header::{
CONNECTION, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_VERSION, UPGRADE,
},
},
response::{IntoResponse as _, Response},
};
use futures::{SinkExt as _, StreamExt as _};
use hyper_util::rt::TokioIo;
use tokio::{net::UnixStream, sync::mpsc};
use tokio_tungstenite::{
WebSocketStream, client_async,
tungstenite::{Message, handshake::derive_accept_key, protocol::Role},
};
use tracing::{debug, trace, warn};
use crate::server::Server;
pub(super) fn is_websocket_upgrade(headers: &HeaderMap<HeaderValue>) -> bool {
let has_upgrade = headers
.get(UPGRADE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("websocket"));
let has_connection = headers
.get(CONNECTION)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| {
v.split(',')
.any(|part| part.trim().eq_ignore_ascii_case("upgrade"))
});
let has_websocket_key = headers.contains_key(SEC_WEBSOCKET_KEY);
let has_websocket_version = headers.contains_key(SEC_WEBSOCKET_VERSION);
has_upgrade && has_connection && has_websocket_key && has_websocket_version
}
pub(super) async fn handle_websocket_upgrade(
request: Request,
server: Arc<Server>,
) -> Result<Response> {
let Some(ws_key) = request
.headers()
.get(SEC_WEBSOCKET_KEY)
.and_then(|key| key.to_str().ok())
else {
return Ok((StatusCode::BAD_REQUEST, "").into_response());
};
let ws_accept = derive_accept_key(ws_key.as_bytes());
let path_q = request
.uri()
.path_and_query()
.map(|x| x.as_str())
.unwrap_or_default();
let uri = format!("ws://localhost:8000{path_q}");
let mut ws_request =
tokio_tungstenite::tungstenite::handshake::client::Request::builder().uri(uri);
for (k, v) in request.headers() {
ws_request = ws_request.header(k.as_str(), v);
}
let ws_request = ws_request.body(())?;
let response = Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.header(UPGRADE, "websocket")
.header(CONNECTION, "upgrade")
.header(SEC_WEBSOCKET_ACCEPT, ws_accept)
.body(Body::empty())?;
tokio::spawn(async move {
if let Err(err) = handle_websocket_connection(request, server, ws_request).await {
warn!("WebSocket connection error: {}", err.0);
}
});
Ok(response)
}
async fn handle_websocket_connection(
request: Request,
server: Arc<Server>,
ws_request: tokio_tungstenite::tungstenite::handshake::client::Request,
) -> Result<()> {
let upgraded = hyper::upgrade::on(request).await?;
let io = TokioIo::new(upgraded);
let client_ws = WebSocketStream::from_raw_socket(io, Role::Server, None).await;
let upstream_ws = {
let stream = UnixStream::connect(&server.socket_path).await?;
let (ws_stream, _) = client_async(ws_request, stream).await?;
ws_stream
};
let (mut client_sender, mut client_receiver) = client_ws.split();
let (mut upstream_sender, mut upstream_receiver) = upstream_ws.split();
let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
let close_tx_upstream = close_tx.clone();
let client_to_upstream = tokio::spawn(async move {
let mut client_closed = false;
while let Some(msg) = client_receiver.next().await {
let msg = msg?;
match msg {
Message::Close(_) => {
if !client_closed {
upstream_sender.send(Message::Close(None)).await?;
let _ = close_tx.send(()).await;
client_closed = true;
break;
}
}
msg @ (Message::Binary(_)
| Message::Text(_)
| Message::Ping(_)
| Message::Pong(_)) => {
if !client_closed {
upstream_sender.send(msg).await?;
}
}
Message::Frame(_) => {}
}
}
if !client_closed {
upstream_sender.send(Message::Close(None)).await?;
let _ = close_tx.send(()).await;
}
Ok::<_, AppError>(())
});
let upstream_to_client = tokio::spawn(async move {
let mut upstream_closed = false;
while let Some(msg) = upstream_receiver.next().await {
let msg = msg?;
match msg {
Message::Close(_) => {
if !upstream_closed {
client_sender.send(Message::Close(None)).await?;
let _ = close_tx_upstream.send(()).await;
upstream_closed = true;
break;
}
}
msg @ (Message::Binary(_)
| Message::Text(_)
| Message::Ping(_)
| Message::Pong(_)) => {
if !upstream_closed {
client_sender.send(msg).await?;
}
}
Message::Frame(_) => {}
}
}
if !upstream_closed {
client_sender.send(Message::Close(None)).await?;
let _ = close_tx_upstream.send(()).await;
}
Ok::<_, AppError>(())
});
tokio::select! {
_ = close_rx.recv() => {
trace!("WebSocket connection closed gracefully");
},
res = client_to_upstream => {
if let Err(err) = res {
debug!("Client to upstream task failed: {:?}", err);
}
}
res = upstream_to_client => {
if let Err(err) = res {
debug!("Upstream to client task failed: {:?}", err);
}
}
}
Ok(())
}
}

View File

@@ -1,18 +1,57 @@
use std::{env::temp_dir, path::PathBuf, process::Stdio, sync::Arc};
use std::{
env::temp_dir,
os::unix,
path::PathBuf,
process::Stdio,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use ak_common::{Arbiter, Tasks, config};
use ak_axum::extract::{
host::{Host, host_middleware},
trusted_proxy::trusted_proxy_middleware,
};
use ak_common::{Arbiter, Event, Tasks, config, tls::self_signed};
use arc_swap::ArcSwapOption;
use argh::FromArgs;
use axum::{
Router,
body::Body,
extract::{Request, State},
http::{StatusCode, header::HOST},
middleware::from_fn,
response::Response,
routing::any,
};
use eyre::{Result, eyre};
use hyper_unix_socket::UnixSocketConnector;
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use nix::{
sys::signal::{Signal, kill},
unistd::Pid,
};
use rustls::sign::CertifiedKey;
use tokio::{
net::UnixStream,
process::{Child, Command},
signal::unix::SignalKind,
sync::Mutex,
time::{Duration, sleep, timeout},
time::{Duration, Instant, interval},
};
use tracing::{info, warn};
use tower::ServiceExt as _;
use tracing::{info, instrument, trace, warn};
use crate::{
brands::tls::BrandCertResolver,
outpost::{self, proxy::ProxyOutpost},
worker::Workers,
};
mod core;
mod r#static;
mod tls;
#[derive(Debug, Default, FromArgs, PartialEq, Eq)]
/// Run the authentik server.
@@ -23,59 +62,83 @@ use tracing::{info, warn};
)]
pub(crate) struct Cli {}
pub(super) static GUNICORN_READY: AtomicBool = AtomicBool::new(false);
pub(crate) fn socket_path() -> PathBuf {
temp_dir().join("authentik.sock")
}
#[derive(Debug)]
pub(crate) struct Server {
server: Mutex<Child>,
gunicorn: Mutex<Child>,
socket_path: PathBuf,
pub(crate) client: Client<UnixSocketConnector<PathBuf>, Body>,
pub(crate) workers: ArcSwapOption<Workers>,
proxy_outpost: Arc<ArcSwapOption<ProxyOutpost>>,
brand_cert_resolver: ArcSwapOption<BrandCertResolver>,
fallback_cert: Arc<CertifiedKey>,
}
impl Server {
async fn new() -> Result<Self> {
fn new(socket_path: PathBuf) -> Result<Self> {
info!("starting server");
let server = if config::get().debug && which::which("authentik-server").is_err() {
let build_status = Command::new("go")
.args(["build", "-o", "server", "./cmd/server"])
.stdin(Stdio::null())
.status()
.await?;
if !build_status.success() {
return Err(eyre!("golang server failed to compile"));
}
Command::new("./server")
.kill_on_drop(true)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?
} else {
Command::new("authentik-server")
.kill_on_drop(true)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?
};
let gunicorn = Command::new("gunicorn")
.args([
"--bind",
&format!("unix://{}", socket_path.display()),
"-c",
"./lifecycle/gunicorn.conf.py",
"authentik.root.asgi:application",
])
.kill_on_drop(true)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?;
let client = Client::builder(TokioExecutor::new())
.pool_idle_timeout(Duration::from_mins(1))
.set_host(false)
.build(UnixSocketConnector::new(socket_path.clone()));
Ok(Self {
server: Mutex::new(server),
gunicorn: Mutex::new(gunicorn),
client,
socket_path,
workers: ArcSwapOption::empty(),
proxy_outpost: Arc::new(ArcSwapOption::empty()),
brand_cert_resolver: ArcSwapOption::empty(),
fallback_cert: Arc::new(self_signed::generate_certifiedkey()?),
})
}
async fn shutdown(&self) -> Result<()> {
info!("shutting down server");
let mut server = self.server.lock().await;
if let Some(id) = server.id() {
kill(Pid::from_raw(id.cast_signed()), Signal::SIGINT)?;
async fn shutdown(&self, signal: Signal) -> Result<()> {
trace!(
signal = signal.as_str(),
"sending shutdown signal to server"
);
let mut gunicorn = self.gunicorn.lock().await;
if let Some(id) = gunicorn.id() {
kill(Pid::from_raw(id.cast_signed()), signal)?;
}
timeout(Duration::from_secs(1), server.wait()).await??;
gunicorn.wait().await?;
drop(gunicorn);
Ok(())
}
async fn graceful_shutdown(&self) -> Result<()> {
info!("gracefully shutting down server");
self.shutdown(Signal::SIGTERM).await
}
async fn fast_shutdown(&self) -> Result<()> {
info!("gracefully shutting down server");
self.shutdown(Signal::SIGINT).await
}
async fn is_alive(&self) -> bool {
let try_wait = self.server.lock().await.try_wait();
let try_wait = self.gunicorn.lock().await.try_wait();
match try_wait {
Ok(Some(code)) => {
warn!(?code, "server has exited");
@@ -91,34 +154,219 @@ impl Server {
}
}
}
async fn is_socket_ready(&self) -> bool {
let result = UnixStream::connect(&self.socket_path).await;
trace!(?result, "checking if server socket is ready");
result.is_ok()
}
#[instrument(skip(self))]
pub(crate) async fn health_live(&self) -> Result<bool> {
trace!("sending health live request to server");
let req = Request::builder()
.method("GET")
.uri("http://localhost:8000/-/health/live/")
.header(HOST, "localhost")
.body(Body::empty())?;
Ok(self
.client
.request(req)
.await
.inspect_err(|err| warn!(?err, "failed to send health live request to server"))?
.status()
.is_success())
}
#[instrument(skip(self))]
pub(crate) async fn health_ready(&self) -> Result<bool> {
trace!("sending health ready request to server");
let req = Request::builder()
.method("GET")
.uri("http://localhost:8000/-/health/ready/")
.header(HOST, "localhost")
.body(Body::empty())?;
Ok(self
.client
.request(req)
.await
.inspect_err(|err| warn!(?err, "failed to send health ready request to server"))?
.status()
.is_success())
}
#[instrument(skip(self))]
pub(crate) async fn notify_metrics(&self) -> Result<()> {
trace!("sending metrics request to server");
let req = Request::builder()
.method("GET")
.uri("http://localhost:8000/-/metrics/")
.header(HOST, "localhost")
.body(Body::empty())?;
self.client
.request(req)
.await
.inspect_err(|err| warn!(?err, "failed to send metrics request to server"))?;
Ok(())
}
}
async fn watch_server(arbiter: Arbiter, server: Arc<Server>) -> Result<()> {
info!("starting server watcher");
let mut events_rx = arbiter.events_subscribe();
let mut check_interval = interval(Duration::from_secs(5));
let mut start_interval = interval(Duration::from_secs(1));
loop {
tokio::select! {
() = sleep(Duration::from_secs(5)) => {
if !server.is_alive().await {
return Err(eyre!("server has exited unexpectedly"));
event = events_rx.recv() => {
if let Ok(Event::Signal(signal)) = event
&& signal == SignalKind::user_defined1()
&& !GUNICORN_READY.load(Ordering::Relaxed)
{
info!("server notified us ready, marked ready for operation");
GUNICORN_READY.store(true, Ordering::Relaxed);
arbiter.send_event(Event::GunicornIsReady)?;
}
},
_ = start_interval.tick(), if !GUNICORN_READY.load(Ordering::Relaxed) => {
if server.is_socket_ready().await {
info!("server socket is accepting connections, marked ready for operation");
GUNICORN_READY.store(true, Ordering::Relaxed);
arbiter.send_event(Event::GunicornIsReady)?;
}
},
_ = check_interval.tick() => {
if !server.is_alive().await {
return Err(eyre!("the server has exited unexpectedly"));
}
},
() = arbiter.fast_shutdown() => {
server.fast_shutdown().await?;
return Ok(());
}
() = arbiter.shutdown() => {
server.shutdown().await?;
() = arbiter.graceful_shutdown() => {
server.graceful_shutdown().await?;
return Ok(());
}
}
}
}
async fn route_core_and_outpost(
State((core_router, proxy_router, proxy_outpost)): State<(
Router,
Router,
Arc<ArcSwapOption<ProxyOutpost>>,
)>,
Host(host): Host,
mut request: Request,
) -> Response {
let start = Instant::now();
// The embedded outpost is only populated once gunicorn is ready, so this is
// `None` during startup and every request naturally falls through to core.
let app = proxy_outpost
.load_full()
.and_then(|outpost| outpost.app_for_request(&host, request.uri()));
let (router, dest) = match app {
Some(app) => {
// The proxy router reads the resolved application from the request
// extensions, so we don't look it up a second time.
request.extensions_mut().insert(app);
(proxy_router, "embedded_outpost")
}
None => (core_router, "core"),
};
let response = router.oneshot(request).await.expect("infallible");
metrics::histogram!("authentik_main_request_duration", "dest" => dest)
.record(start.elapsed().as_secs_f64());
response
}
fn build_router(server: &Arc<Server>) -> Result<Router> {
let core_router = core::build_router(server)?;
let proxy_router = outpost::proxy::embedded_router();
metrics::describe_histogram!(
"authentik_main_request_duration",
metrics::Unit::Seconds,
"API request latencies in seconds"
);
let router = if config::get().outposts.disable_embedded_outpost {
Router::new()
} else {
Router::new().route("/outpost.goauthentik.io/ping", any(StatusCode::NO_CONTENT))
};
Ok(router
.fallback(any(route_core_and_outpost))
.with_state((core_router, proxy_router, Arc::clone(&server.proxy_outpost)))
.layer(from_fn(host_middleware))
.layer(from_fn(trusted_proxy_middleware)))
}
pub(crate) async fn start(_cli: Cli, tasks: &mut Tasks) -> Result<Arc<Server>> {
let arbiter = tasks.arbiter();
let mut events_rx = arbiter.events_subscribe();
let server = Arc::new(Server::new().await?);
let server = Arc::new(Server::new(temp_dir().join("authentik-gunicorn.sock"))?);
tasks
.build_task()
.name(&format!("{}::watch_server", module_path!()))
.spawn(watch_server(arbiter.clone(), Arc::clone(&server)))?;
let router = build_router(&server)?;
for addr in config::get().listen.http.iter().copied() {
ak_axum::server::start_plain(tasks, "server", router.clone(), addr)?;
}
let tls_config = tls::make_initial_tls_config(Arc::clone(&server));
for addr in config::get().listen.https.iter().copied() {
ak_axum::server::start_tls(tasks, "server", router.clone(), addr, tls_config.clone())?;
}
tasks
.build_task()
.name(&format!("{}::tls::watch_tls_config", module_path!()))
.spawn(tls::watch_tls_config(
arbiter.clone(),
tls_config,
Arc::clone(&server),
))?;
ak_axum::server::start_unix(
tasks,
"server",
router,
unix::net::SocketAddr::from_pathname(socket_path())?,
)?;
if !config::get().outposts.disable_embedded_outpost {
info!("waiting for gunicorn to be ready before starting embedded outpost");
loop {
tokio::select! {
event = events_rx.recv() => {
if event == Ok(Event::GunicornIsReady) {
break;
}
},
() = arbiter.shutdown() => {
warn!("we were told to shutdown before starting the embedded outpost");
return Ok(server);
},
}
}
info!("starting embedded outpost");
server.proxy_outpost.store(Some(
outpost::start::<ProxyOutpost>(outpost::proxy::Cli::default(), tasks).await?,
));
}
Ok(server)
}

250
src/server/static.rs Normal file
View File

@@ -0,0 +1,250 @@
use std::fmt::Write as _;
use aws_lc_rs::digest;
use axum::{
Router,
extract::{Query, Request, State},
http::{
HeaderValue, StatusCode,
header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY},
},
middleware::{self, Next},
response::{IntoResponse as _, Response},
routing::any,
};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
use percent_encoding::percent_decode_str;
use serde::Deserialize;
use time::OffsetDateTime;
use tower_http::{
compression::{CompressionLayer, predicate::SizeAbove},
services::fs::ServeDir,
};
use crate::config;
#[derive(Debug, Deserialize)]
struct StorageClaims {
exp: Option<i64>,
nbf: Option<i64>,
path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StorageTokenQuery {
token: Option<String>,
}
fn is_storage_token_valid(usage: &str, secret_key: &str, request: &Request) -> bool {
// Use typed query parsing so `token` is percent-decoded before JWT parsing.
let token_string = match Query::<StorageTokenQuery>::try_from_uri(request.uri()) {
Ok(query) => match query.0.token {
Some(token) if !token.is_empty() => token,
_ => return false,
},
Err(_) => return false,
};
let Ok(token_header) = decode_header(&token_string) else {
return false;
};
// Must match what we use in authentik/admin/files/backends/file.py
if token_header.alg != Algorithm::HS256 {
return false;
}
// Derive a per-usage key so media and reports tokens are not interchangeable.
let key = format!("{secret_key}:{usage}");
let key_digest = digest::digest(&digest::SHA256, key.as_bytes());
let key_hex_digest = key_digest
.as_ref()
.iter()
.fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
});
let mut validation = Validation::new(token_header.alg);
validation.validate_exp = false;
validation.validate_nbf = false;
validation.validate_aud = false;
validation.required_spec_claims.clear();
let claims = match decode::<StorageClaims>(
&token_string,
&DecodingKey::from_secret(key_hex_digest.as_bytes()),
&validation,
) {
Ok(token) => token.claims,
Err(_) => return false,
};
let now = OffsetDateTime::now_utc().unix_timestamp();
if claims.exp.unwrap_or(0) < now {
return false;
}
if claims.nbf.unwrap_or(now + 1) > now {
return false;
}
let Some(claim_path) = claims.path else {
return false;
};
// Decode path before comparison so encoded URL segments cannot bypass path binding.
let Ok(request_path) = percent_decode_str(request.uri().path()).decode_utf8() else {
return false;
};
let request_path = request_path.trim_start_matches('/');
let expected_path = format!("{usage}/{request_path}");
if claim_path != expected_path {
return false;
}
true
}
#[derive(Clone)]
struct StorageMiddlewareConfig {
usage: &'static str,
set_csp_header: bool,
}
async fn storage_middleware(
State(config): State<StorageMiddlewareConfig>,
request: Request,
next: Next,
) -> Response {
if !is_storage_token_valid(config.usage, &config::get().secret_key, &request) {
return (StatusCode::NOT_FOUND, "404 page not found\n").into_response();
}
let mut response = next.run(request).await;
if config.set_csp_header {
// Since media is user-controlled, better be safe
response.headers_mut().insert(
CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; sandbox"),
);
}
response
}
async fn static_header_middleware(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("public, no-transform"),
);
response.headers_mut().insert(
"X-authentik-version",
HeaderValue::from_static(env!("CARGO_PKG_VERSION")),
);
response
}
pub(crate) fn build_router() -> Router {
let config = config::get();
let mut router = Router::new().layer(middleware::from_fn(static_header_middleware));
let dist_fs = ServeDir::new("./web/dist/").append_index_html_on_directories(false);
let static_fs = ServeDir::new("./web/authentik/").append_index_html_on_directories(false);
router = router.nest_service("/static/dist/", dist_fs.clone());
router = router.nest_service("/static/authentik/", static_fs);
router = router.nest_service("/if/flow/{flow_slug}/assets/", dist_fs.clone());
router = router.nest_service("/if/admin/assets/", dist_fs.clone());
router = router.nest_service("/if/user/assets/", dist_fs.clone());
router = router.nest_service("/if/rac/{app_slug}/assets/", dist_fs);
let default_backend = &config.storage.backend;
let media_backend = config
.storage
.media
.clone()
.unwrap_or_default()
.backend
.unwrap_or_else(|| default_backend.clone());
let reports_backend = config
.storage
.reports
.clone()
.unwrap_or_default()
.backend
.unwrap_or_else(|| default_backend.clone());
let default_path = &config.storage.file.path;
if media_backend == "file" {
let media_path = config
.storage
.media
.clone()
.unwrap_or_default()
.file
.unwrap_or_default()
.path
.unwrap_or_else(|| default_path.clone())
.join("media");
let media_fs = ServeDir::new(media_path).append_index_html_on_directories(false);
let media_router =
Router::new()
.fallback_service(media_fs)
.layer(middleware::from_fn_with_state(
StorageMiddlewareConfig {
usage: "media",
set_csp_header: true,
},
storage_middleware,
));
router = router.nest("/files/media/", media_router);
}
if reports_backend == "file" {
let reports_path = config
.storage
.reports
.clone()
.unwrap_or_default()
.file
.unwrap_or_default()
.path
.unwrap_or_else(|| default_path.clone())
.join("reports");
let reports_fs = ServeDir::new(reports_path).append_index_html_on_directories(false);
let reports_router =
Router::new()
.fallback_service(reports_fs)
.layer(middleware::from_fn_with_state(
StorageMiddlewareConfig {
usage: "reports",
set_csp_header: false,
},
storage_middleware,
));
router = router.nest("/files/reports/", reports_router);
}
router = router.route(
"/robots.txt",
any(async || include_str!("../../web/robots.txt")),
);
router = router.route(
"/.well-known/security.txt",
any(async || include_str!("../../web/security.txt")),
);
router = router.layer(middleware::from_fn(static_header_middleware));
router = router.layer(CompressionLayer::new().compress_when(SizeAbove::new(32)));
router
}

111
src/server/tls.rs Normal file
View File

@@ -0,0 +1,111 @@
use std::{sync::Arc, time::Duration};
use ak_common::{Arbiter, Event};
use axum_server::tls_rustls::RustlsConfig;
use eyre::Result;
use rustls::{
ServerConfig,
server::{ClientHello, ResolvesServerCert, WebPkiClientVerifier},
sign::CertifiedKey,
};
use tracing::{debug, info, warn};
use crate::{brands, server::Server};
pub(super) fn make_initial_tls_config(server: Arc<Server>) -> RustlsConfig {
RustlsConfig::from_config(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(server),
))
}
impl ResolvesServerCert for Server {
fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
if client_hello.server_name().is_none() {
Some(Arc::clone(&self.fallback_cert))
} else if let Some(proxy_outpost) = self.proxy_outpost.load_full()
&& let Some(cert) = proxy_outpost.resolve_cert(&client_hello)
{
Some(cert)
} else if let Some(cert) = self.resolve_cert(&client_hello) {
Some(cert)
} else {
Some(Arc::clone(&self.fallback_cert))
}
}
fn only_raw_public_keys(&self) -> bool {
false
}
}
impl Server {
fn resolve_cert(&self, client_hello: &ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
if let Some(brand_cert_resolver) = self.brand_cert_resolver.load_full() {
brand_cert_resolver.resolve(client_hello)
} else {
None
}
}
}
async fn update_tls_config(config: &RustlsConfig, server: &Arc<Server>) -> Result<()> {
let (brand_core_resolver, roots) = brands::tls::make_cert_managers().await?;
server
.brand_cert_resolver
.store(Some(Arc::new(brand_core_resolver)));
let server_config = if roots.is_empty() {
ServerConfig::builder().with_no_client_auth()
} else {
let client_cert_verifier = WebPkiClientVerifier::builder(Arc::new(roots))
.allow_unauthenticated()
.build()?;
ServerConfig::builder().with_client_cert_verifier(client_cert_verifier)
};
let resolver: Arc<dyn ResolvesServerCert> = Arc::<Server>::clone(server);
let server_config = server_config.with_cert_resolver(resolver);
config.reload_from_config(Arc::new(server_config));
Ok(())
}
pub(super) async fn watch_tls_config(
arbiter: Arbiter,
config: RustlsConfig,
server: Arc<Server>,
) -> Result<()> {
let mut events_rx = arbiter.events_subscribe();
info!("waiting for gunicorn to be ready before starting tls watcher");
loop {
tokio::select! {
event = events_rx.recv() => {
if event == Ok(Event::GunicornIsReady) {
break;
}
},
() = arbiter.shutdown() => {
warn!("we were told to shutdown before starting the tls watcher");
return Ok(());
},
}
}
info!("starting tls watcher");
loop {
if let Err(err) = update_tls_config(&config, &server).await {
warn!(?err, "error while reloading tls config");
} else {
debug!("reloaded tls config");
}
tokio::select! {
() = tokio::time::sleep(Duration::from_mins(1)) => {},
() = arbiter.shutdown() => return Ok(()),
}
}
}

View File

@@ -2,35 +2,62 @@ use std::sync::Arc;
use ak_axum::{error::Result, router::wrap_router};
use ak_common::db;
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::any};
use tracing::instrument;
use axum::{Router, extract::State, http::StatusCode, routing::any};
use tracing::{instrument, warn};
use super::Workers;
#[instrument(skip_all)]
async fn health_ready(State(workers): State<Arc<Workers>>) -> Result<StatusCode> {
if !workers.are_alive().await || sqlx::query("SELECT 1").execute(db::get()).await.is_err() {
Ok(StatusCode::SERVICE_UNAVAILABLE)
} else if workers.health_ready().await? {
Ok(StatusCode::OK)
} else {
Ok(StatusCode::SERVICE_UNAVAILABLE)
if !workers.are_alive().await {
warn!("workers detected as not alive");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
if let Err(err) = sqlx::query("SELECT 1").execute(db::get()).await {
warn!(?err, "failed to check db health");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
match workers.health_ready().await {
Ok(true) => {}
Ok(false) => {
warn!("workers responded not ready");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
Err(err) => {
warn!(?err, "failed to check workers health readiness");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
}
Ok(StatusCode::OK)
}
#[instrument(skip_all)]
async fn health_live(State(workers): State<Arc<Workers>>) -> Result<StatusCode> {
if !workers.are_alive().await || sqlx::query("SELECT 1").execute(db::get()).await.is_err() {
Ok(StatusCode::SERVICE_UNAVAILABLE)
} else if workers.health_live().await? {
Ok(StatusCode::OK)
} else {
Ok(StatusCode::SERVICE_UNAVAILABLE)
if !workers.are_alive().await {
warn!("workers detected as not alive");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
match workers.health_live().await {
Ok(true) => {}
Ok(false) => {
warn!("workers responded not live");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
Err(err) => {
warn!(?err, "failed to check workers health liveness");
return Ok(StatusCode::SERVICE_UNAVAILABLE);
}
}
Ok(StatusCode::OK)
}
async fn fallback() -> impl IntoResponse {
StatusCode::OK
async fn fallback() -> StatusCode {
StatusCode::NOT_FOUND
}
pub(super) fn build_router(workers: Arc<Workers>) -> Router {

View File

@@ -53,6 +53,7 @@ pub(crate) struct Cli {}
const INITIAL_WORKER_ID: usize = 1000;
static INITIAL_WORKER_READY: AtomicBool = AtomicBool::new(false);
#[derive(Debug)]
pub(crate) struct Worker {
worker_id: usize,
worker: Child,
@@ -142,7 +143,7 @@ impl Worker {
.method("GET")
.uri("http://localhost:8000/-/health/live/")
.header(HOST, "localhost")
.body(Body::from(""))?;
.body(Body::empty())?;
Ok(self
.client
.request(req)
@@ -159,7 +160,7 @@ impl Worker {
.method("GET")
.uri("http://localhost:8000/-/health/ready/")
.header(HOST, "localhost")
.body(Body::from(""))?;
.body(Body::empty())?;
Ok(self
.client
.request(req)
@@ -176,7 +177,7 @@ impl Worker {
.method("GET")
.uri("http://localhost:8000/-/metrics/")
.header(HOST, "localhost")
.body(Body::from(""))?;
.body(Body::empty())?;
self.client
.request(req)
.await
@@ -194,6 +195,7 @@ impl Drop for Worker {
}
}
#[derive(Debug)]
pub(crate) struct Workers(Mutex<Vec<Worker>>);
impl Workers {
@@ -238,7 +240,7 @@ impl Workers {
}
#[instrument(skip_all)]
async fn are_alive(&self) -> bool {
pub(crate) async fn are_alive(&self) -> bool {
for worker in self.0.lock().await.iter_mut() {
if !worker.is_alive() {
return false;
@@ -255,7 +257,7 @@ impl Workers {
}
#[instrument(skip_all)]
async fn health_live(&self) -> Result<bool> {
pub(crate) async fn health_live(&self) -> Result<bool> {
for worker in self.0.lock().await.iter() {
if !worker.health_live().await? {
return Ok(false);
@@ -265,7 +267,7 @@ impl Workers {
}
#[instrument(skip_all)]
async fn health_ready(&self) -> Result<bool> {
pub(crate) async fn health_ready(&self) -> Result<bool> {
for worker in self.0.lock().await.iter() {
if !worker.health_ready().await? {
return Ok(false);
@@ -291,8 +293,11 @@ async fn watch_workers(arbiter: Arbiter, workers: Arc<Workers>) -> Result<()> {
loop {
tokio::select! {
Ok(Event::Signal(signal)) = events_rx.recv() => {
if signal == SignalKind::user_defined2() && !INITIAL_WORKER_READY.load(Ordering::Relaxed) {
event = events_rx.recv() => {
if let Ok(Event::Signal(signal)) = event
&& signal == SignalKind::user_defined2()
&& !INITIAL_WORKER_READY.load(Ordering::Relaxed)
{
info!("worker notified us ready, marked ready for operation");
INITIAL_WORKER_READY.store(true, Ordering::Relaxed);
workers.start_other_workers().await?;