mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
root: rewrite server in rust (#24320)
Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
committed by
GitHub
parent
73de109ed2
commit
74bda646af
12
.github/workflows/ci-main.yml
vendored
12
.github/workflows/ci-main.yml
vendored
@@ -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
143
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
23
Cargo.toml
23
Cargo.toml
@@ -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
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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 \
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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))]
|
||||
|
||||
18
lifecycle/ak
18
lifecycle/ak
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
1
src/brands/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod tls;
|
||||
133
src/brands/tls.rs
Normal file
133
src/brands/tls.rs
Normal 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,
|
||||
))
|
||||
}
|
||||
12
src/main.rs
12
src/main.rs
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
503
src/server/core.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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
250
src/server/static.rs
Normal 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
111
src/server/tls.rs
Normal 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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user