Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
653 changes: 615 additions & 38 deletions Cargo.lock

Large diffs are not rendered by default.

22 changes: 17 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,37 @@ unused = { level = "allow", priority = -1 }

[dependencies]
# Async
async-trait = "0.1.91"
async-trait = "0.1.92"
tokio = { version = "1.53.1", features = ["full"] }

# Arc
arc-swap = "1.9.2"

# Certs
# used Ring as backend for default
rcgen = { version = "0.14.8", features = ["default"] }
# match the backed from rcgen
instant-acme = { version = "0.8.5", features = ["rcgen"] }
instant-acme = { version = "0.8.5", default-features = false, features = [
"ring",
"rcgen",
"hyper-rustls",
] }
rustls = "0.23.43"


# TOML
toml = "1.1.3"
toml = "1.1.4"

# Json
serde = { version = "1.0.229", features = ["derive"] }

# Others
thiserror = "2.0.19"
thiserror = "2.0.20"
chrono = { version = "0.4.45", features = ["serde"] }

# HTTP
http = "1.4.2"
http = "1.5.0"
pingora = { version = "0.8.1", features = ["proxy", "boringssl"] }

# HTTP Client
reqwest = "0.13.4"
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ services:
proxy:
image: maxvanderschee/reverse-proxy:latest # or ghcr.io/mvdschee/reverse-proxy:latest
restart: unless-stopped
# host ports 80/443 mapped to container ports 8080/8443
# see ## Under the hood for explanation
ports:
- "80:8080"
- "443:8443"
Expand Down Expand Up @@ -100,7 +102,7 @@ The full schema lives in [`example/example.toml`](example/example.toml). The fie
| `acme.email` | yes | Contact email for Let's Encrypt (used once ACME lands; required today even if every route is `none`). |
| `routes[].host` | yes | The `Host` header to match (e.g. `app.example.com`). |
| `routes[].upstream` | yes | `host:port` to forward to. Use `host.docker.internal:<port>` to reach the host machine from Docker. |
| `routes[].cert_type` | no | `self_signed` (default works on boot), `acme` (WIP), or `none` (HTTP only). Defaults to `acme`. |
| `routes[].cert_type` | no | `self_signed` (default works on boot), `acme` (WIP), or `none` (HTTP only). Defaults to `none`. |

## How it works

Expand Down Expand Up @@ -164,5 +166,12 @@ I'll be upfront on every public project about what was done with AI. For this on

- Research and tradeoff discussions
- Cleanup of the README and other prose
- Talking through code-level solutions
- Discussing code-level solutions
- Generating the Docker image scaffolding from a spec

# TODO:

- finish create_acme_dns_challenge
- create a dns entry checker for the background task
- generate a staging certificate with instance_acme
- wire up the full flow in CertBackgroundRenewal
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ services:
context: .
dockerfile: .docker/Dockerfile
ports:
# nonroot user can't bind to ports < 1024, so map to 8080/8443
- "80:8080"
- "443:8443"
# Lets upstream = "host.docker.internal:<port>" reach apps running
Expand Down
6 changes: 3 additions & 3 deletions example/example.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[acme]
email = "max@example.com"
email = "max@example.com" # use a real email this will fail the even on local testing

[[routes]]
host = "git.example.com"
upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
cert_type = "self_signed" # default is "acme"
cert_type = "self_signed" # default is "none"

[[routes]]
host = "test.example.com"
upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
cert_type = "none" # default is "acme"
cert_type = "none" # default is "none"
2 changes: 1 addition & 1 deletion makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
dev:
CONFIG_PATH=./example/example.toml watchexec -q -c -w src --exts rs --restart "cargo run"
CONFIG_PATH=./example/local.toml watchexec -q -c -w src --exts rs --restart "cargo run"

scan:
foxguard --config .foxguard.yml
Expand Down
4 changes: 2 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const HTTP_PORT_DEFAULT: u16 = 80;
const HTTPS_PORT_DEFAULT: u16 = 443;
const INPUT_ADDRESS: &str = "0.0.0.0";

// in seconds
const CERT_BACKGROUND_TASK_INTERVAL: u64 = 120;
/// in seconds
const CERT_BACKGROUND_TASK_INTERVAL: u64 = 3600; // 1 hour

#[derive(Debug, Clone)]
pub struct Config {
Expand Down
200 changes: 156 additions & 44 deletions src/core/handlers/certs.rs
Original file line number Diff line number Diff line change
@@ -1,73 +1,185 @@
use crate::{
Error, Result,
core::{
handlers::filesystem::{safe_path, write_file},
handlers::filesystem::{check_file_exists, read_file, safe_path, write_file},
models::{
certs::{CertificateConfig, CertificateType},
certs::{
CertDir, CertPath, CertificateConfig, CertificateType, Email, KeyPath, TlsMaterial,
TlsStore,
},
routes::Host,
tasks::TaskInterval,
},
},
info,
error, info,
services::certs::{
acme::{create_account, create_order},
self_signed::create_self_signed_certificate_files,
},
warn,
};
use arc_swap::ArcSwap;
use async_trait::async_trait;
use instant_acme::{Identifier, NewOrder, OrderStatus};
use pingora::{server::ShutdownWatch, services::background::BackgroundService, tls};
use rcgen::{CertifiedKey, generate_simple_self_signed};
use std::time::Duration;
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::time;

pub fn generate_certs(certificate_configs: Vec<CertificateConfig>) -> Result<()> {
for config in &certificate_configs {
// self signed certificates are good until the year 4096
// this will be replace every restart so it's safe to keep using the default setting
pub fn create_self_signed_certs(certificate_configs: &Vec<CertificateConfig>) -> Result<()> {
for config in certificate_configs {
match config.cert_type {
CertificateType::SelfSigned => {
info!("generating self-signed certificate for {}", config.host);
// self signed certificates are good until the year 4096
// this will be replace every restart so it's safe to keep using the default setting
// for selfsigned we will create the certs here right away
create_self_signed_certificate_files(config);
},
_ => {},
}
}

let subject_alt_names = vec![config.host.to_string()];
let pem_filename = format!("{}.pem", config.host);
let key_filename = format!("{}.key", config.host);
Ok(())
}

let key_path = safe_path(&config.cert_dir, &key_filename)?;
let pem_path = safe_path(&config.cert_dir, &pem_filename)?;
pub fn load_tls_store(certificate_configs: &Vec<CertificateConfig>) -> Result<TlsStore> {
let mut tls_certs = HashMap::new();

let CertifiedKey {
cert,
signing_key,
} = generate_simple_self_signed(subject_alt_names)
.map_err(|e| Error::Certificate(e.to_string()))?;
for config in certificate_configs {
if config.cert_type != CertificateType::None {
let (key_path, cert_path) = certificate_paths(&config.host, &config.cert_dir)?;

let pem_serialized = cert.pem();
let key_serialized = signing_key.serialize_pem();
let has_tls_files = check_file_exists(&key_path) && check_file_exists(&cert_path);

write_file(pem_path, pem_serialized.as_bytes())?;
write_file(key_path, key_serialized.as_bytes())?;
},
CertificateType::Acme => {
info!("generating acme certificate for {}", config.host);
},
CertificateType::None => {},
// We only show a warning so its easier to debug once its running,
// but we are not stopping any traffic.
if !has_tls_files {
warn!("Certificate files not found for host '{}' but is expected", &config.host);
continue;
}

let cert_bytes = read_file(&cert_path)?;
let cert = tls::x509::X509::from_pem(&cert_bytes)
.map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?;

let key_bytes = read_file(&key_path)?;
let key = tls::pkey::PKey::private_key_from_pem(&key_bytes)
.map_err(|e| Error::Certificate(format!("Failed to parse private key: {}", e)))?;

tls_certs.insert(
config.host.clone(),
TlsMaterial {
cert,
key,
},
);
}
}

Ok(())
let tls_store: TlsStore = Arc::new(ArcSwap::from_pointee(tls_certs));

Ok(tls_store)
}

pub async fn background_certs_task(
certificates: Vec<CertificateConfig>,
task_interval: TaskInterval,
) -> Result<()> {
// only acme certificates need to be renewed
let certificates = certificates
.into_iter()
.filter(|cert| cert.cert_type == CertificateType::Acme)
.collect::<Vec<CertificateConfig>>();
pub fn certificate_paths(host: &Host, cert_dir: &CertDir) -> Result<(KeyPath, CertPath)> {
let cert_filename = format!("{}.pem", host);
let key_filename = format!("{}.key", host);

loop {
info!("certificates: {}", certificates.len());
let key_path = safe_path(cert_dir, &key_filename)?;
let cert_path = safe_path(cert_dir, &cert_filename)?;

// for certificate in &certificates {
// }
Ok((key_path, cert_path))
}

time::sleep(Duration::from_secs(*task_interval)).await;
pub struct CertBackgroundRenewal {
pub certificate_configs: Vec<CertificateConfig>,
pub task_interval: TaskInterval,
pub tls_store: TlsStore,
pub email: Email,
}

impl CertBackgroundRenewal {
pub fn new(
certificate_configs: Vec<CertificateConfig>,
task_interval: TaskInterval,
tls_store: TlsStore,
email: Email,
) -> Self {
Self {
certificate_configs,
task_interval,
tls_store,
email,
}
}
}

Ok(())
#[async_trait]
impl BackgroundService for CertBackgroundRenewal {
// start should never return this will stop the background task,
// this means we have to be a little more verbose with our error handeling.
// TLDR; just continue on any error :D, problem for the next loop :')
async fn start(&self, mut shutdown: ShutdownWatch) {
// TODO what to do when creating an account fails (acme endpoints is 500 etc..)
let account_result = create_account(&self.email)
.await
.map_err(|e| Error::Certificate(format!("Failed with create_account: {}", e)));

let configs = self
.certificate_configs
.clone()
.into_iter()
.filter(|c| c.cert_type == CertificateType::Acme);

loop {
let (account, credentials) = match account_result {
Ok(ref pair) => pair,
Err(ref err) => {
error!("{err:?}");
continue;
},
};

for config in configs.clone() {
let order_result = create_order(&account, &config.host).await;

let mut order = match order_result {
Ok(order) => order,
Err(err) => {
error!("{err:?}");
continue;
},
};

let state = order.state();
info!("order state: {:#?}", state);

if !matches!(state.status, OrderStatus::Pending) {
warn!("Skipping non-Pending order: {:?}", state.status);
continue;
}

//
// if so verify the dns records with cloudflare
//
// if its set allow the order to be proccessed
//
// write to the file system
//
// swap the file content in the store with the new values if any
//
//
// note: we write to the file system so we can pick the files up and load them in the store when we restart or bootup
// this so we don't have to deal here with loading if the files are there (so we only have to check here if the order is invalid or valid and swap when its time)
// so on boot we load all the tls certs from self-signed / acme and check in this flow it its valid or not and fix it with a swap.
}

info!("background thing");

tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(*self.task_interval)) => {}
_ = shutdown.changed() => break,
}
}
}
}
Loading