Initial commit

This commit is contained in:
Dreaded_X 2025-04-18 02:10:03 +02:00
commit 85f35c7daa
Signed by: Dreaded_X
GPG Key ID: 5A0CBFE3C3377FAA
14 changed files with 2488 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

77
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,77 @@
fail_fast: true
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args:
- --allow-multiple-documents
- id: check-toml
- id: check-added-large-files
- id: check-merge-conflict
- repo: https://github.com/crate-ci/typos
rev: v1.31.1
hooks:
- id: typos
args: ["--force-exclude"]
- repo: local
hooks:
- id: fmt
name: fmt
description: Format files with cargo fmt.
entry: cargo +nightly fmt
language: system
types: [rust]
args: ["--", "--check"]
# For some reason some formatting is different depending on how you invoke?
pass_filenames: false
- id: clippy
name: clippy
description: Lint rust sources
entry: cargo clippy
language: system
args: ["--", "-D", "warnings"]
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false
- id: audit
name: audit
description: Audit packages
entry: cargo audit
args: ["--deny", "warnings"]
language: system
pass_filenames: false
verbose: true
always_run: true
- id: udeps
name: unused
description: Check for unused crates
entry: cargo udeps
args: ["--workspace"]
language: system
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false
- id: test
name: test
description: Rust test
entry: cargo test
language: system
args: ["--workspace"]
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false
- repo: https://github.com/hadolint/hadolint
rev: v2.12.0
hooks:
- id: hadolint

2
.rustfmt.toml Normal file
View File

@ -0,0 +1,2 @@
imports_granularity = "Module"
group_imports = "StdExternalCrate"

2119
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "authelia-controller"
edition = "2024"
default-run = "authelia-controller"
[dependencies]
color-eyre = "0.6.3"
dotenvy = "0.15.7"
futures-util = "0.3.31"
k8s-openapi = { version = "0.24.0", features = ["v1_31"] }
kube = { version = "0.99.0", features = ["derive", "runtime"] }
schemars = "0.8.22"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_yaml = "0.9.34"
thiserror = "2.0.12"
tokio = { version = "1.44.2", features = ["full"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }

11
src/bin/crdgen.rs Normal file
View File

@ -0,0 +1,11 @@
use kube::CustomResourceExt;
fn main() {
let resources =
[
serde_yaml::to_string(&authelia_controller::resources::AccessControlRule::crd())
.unwrap(),
]
.join("---\n");
print!("{resources}")
}

33
src/context.rs Normal file
View File

@ -0,0 +1,33 @@
use kube::runtime::events::{Recorder, Reporter};
#[derive(Clone)]
pub struct Context {
pub client: kube::Client,
pub controller_name: String,
pub namespace: String,
pub deployment_name: String,
pub secret_name: String,
pub recorder: Recorder,
}
impl Context {
pub fn new(
client: kube::Client,
controller_name: &str,
namespace: impl Into<String>,
deployment_name: impl Into<String>,
secret_name: impl Into<String>,
) -> Self {
let reporter: Reporter = controller_name.into();
let recorder = Recorder::new(client.clone(), reporter);
Self {
client,
controller_name: controller_name.into(),
namespace: namespace.into(),
deployment_name: deployment_name.into(),
secret_name: secret_name.into(),
recorder,
}
}
}

2
src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod context;
pub mod resources;

70
src/main.rs Normal file
View File

@ -0,0 +1,70 @@
use std::sync::Arc;
use std::time::Duration;
use authelia_controller::context::Context;
use authelia_controller::resources::AccessControlRule;
use dotenvy::dotenv;
use futures_util::{StreamExt as _, TryStreamExt as _};
use kube::runtime::reflector::{self};
use kube::runtime::{WatchStreamExt, watcher};
use kube::{Api, Client};
use tracing::{error, info};
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;
use tracing_subscriber::{EnvFilter, Registry};
#[tokio::main]
async fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
dotenv().ok();
let env_filter = EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?;
if std::env::var("CARGO").is_ok() {
let logger = tracing_subscriber::fmt::layer().compact();
Registry::default().with(logger).with(env_filter).init();
} else {
let logger = tracing_subscriber::fmt::layer().json();
Registry::default().with(logger).with(env_filter).init();
}
let namespace = std::env::var("AUTHELIA_NAMESPACE").unwrap_or("authelia".into());
let deployment = std::env::var("AUTHELIA_DEPLOYMENT").unwrap_or("authelia".into());
let secret = std::env::var("AUTHELIA_SECRET").unwrap_or("authelia-acl".into());
info!("Starting");
let client = Client::try_default().await?;
let access_control_rules = Api::<AccessControlRule>::all(client.clone());
let (reader, writer) = reflector::store();
let wc = watcher::Config::default().any_semantic();
let mut stream = watcher(access_control_rules, wc)
.default_backoff()
.reflect(writer)
.applied_objects()
.boxed();
let context = Arc::new(Context::new(
client,
"authelia.huizinga.dev",
namespace,
deployment,
secret,
));
let interval = 15;
tokio::spawn(async move {
reader.wait_until_ready().await.unwrap();
loop {
if let Err(err) = AccessControlRule::update_acl(reader.state(), context.clone()).await {
error!("Failed to update: {err}");
}
tokio::time::sleep(Duration::from_secs(interval)).await;
}
});
while stream.try_next().await?.is_some() {}
Ok(())
}

View File

@ -0,0 +1,128 @@
use std::collections::BTreeMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::Secret;
use kube::api::{ObjectMeta, Patch, PatchParams};
use kube::{Api, CustomResource, ResourceExt};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tracing::{debug, trace};
use crate::context::Context;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema, Hash)]
#[serde(rename_all = "snake_case")]
enum AccessPolicy {
Deny,
Bypass,
OneFactor,
TwoFactor,
}
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema, Hash)]
#[kube(
kind = "AccessControlRule",
group = "authelia.huizinga.dev",
version = "v1"
)]
#[kube(
shortname = "acl",
doc = "Custom resource for managing authelia access rules"
)]
#[serde(rename_all = "camelCase")]
pub struct AccessControlRuleSpec {
domain: String,
policy: AccessPolicy,
}
#[derive(Serialize, Deserialize, Clone, Debug, Hash)]
struct AccessControl {
rules: Vec<AccessControlRuleSpec>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Hash)]
struct TopLevel {
access_control: AccessControl,
}
impl AccessControlRule {
pub async fn update_acl(
mut rules: Vec<Arc<AccessControlRule>>,
ctx: Arc<Context>,
) -> Result<(), kube::Error> {
debug!("Updating acl");
rules.sort_by_cached_key(|rule| rule.name_any());
let rules = rules
.iter()
.inspect(|rule| trace!(name = rule.name_any(), "Rule found"))
.map(|rule| rule.spec.clone())
.collect();
let top = TopLevel {
access_control: AccessControl { rules },
};
let contents = BTreeMap::from([(
"configuration.acl.yaml".into(),
serde_yaml::to_string(&top).expect("serializer should not fail"),
)]);
let secret = Secret {
metadata: ObjectMeta {
..Default::default()
},
string_data: Some(contents),
..Default::default()
};
debug!(
name = ctx.secret_name,
namespace = ctx.namespace,
"Applying secret"
);
let secrets = Api::<Secret>::namespaced(ctx.client.clone(), &ctx.namespace);
secrets
.patch(
&ctx.secret_name,
&PatchParams::apply(&ctx.controller_name),
&Patch::Apply(&secret),
)
.await?;
let mut hasher = DefaultHasher::new();
top.hash(&mut hasher);
let hash = hasher.finish();
let patch = serde_json::json!({
"spec": {
"template": {
"metadata": {
"annotations": {
"authelia.huizinga.dev/aclHash": hash.to_string()
}
}
}
}
});
debug!(
name = ctx.deployment_name,
namespace = ctx.namespace,
hash,
"Updating deployment hash"
);
let deployments = Api::<Deployment>::namespaced(ctx.client.clone(), &ctx.namespace);
deployments
.patch(
&ctx.deployment_name,
&PatchParams::default(),
&Patch::Strategic(&patch),
)
.await?;
Ok(())
}
}

3
src/resources/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod access_control_rule;
pub use self::access_control_rule::AccessControlRule;

7
yaml/rule-test-1.yaml Normal file
View File

@ -0,0 +1,7 @@
apiVersion: authelia.huizinga.dev/v1
kind: AccessControlRule
metadata:
name: test-1
spec:
domain: "test-1.domain"
policy: one_factor

7
yaml/rule-test-2.yaml Normal file
View File

@ -0,0 +1,7 @@
apiVersion: authelia.huizinga.dev/v1
kind: AccessControlRule
metadata:
name: test-2
spec:
domain: "test-2.domain"
policy: one_factor

7
yaml/rule-test-3.yaml Normal file
View File

@ -0,0 +1,7 @@
apiVersion: authelia.huizinga.dev/v1
kind: AccessControlRule
metadata:
name: test-3
spec:
domain: "test-3.domain"
policy: one_factor