Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support suffix for DNS server #547

Merged
merged 3 commits into from
Jan 11, 2024
Merged
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
9 changes: 5 additions & 4 deletions docs/pages/deploy-to-prod.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ Obtaining a certificate involves proving to a third party that you control the d
Plane implements the [ACME DNS-01](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) challenge type to obtain a TLS
certificate for each proxy. Each proxy generates its own private key, which never leaves that proxy.

For this to work, the `NS` record for the `_acme-challenge.<cluster name>` subdomain needs to point to one or more Plane DNS servers.
These are basic DNS servers that exist only to serve the ACME DNS-01 challenge.

The DNS servers need to be able to accept incoming connections from the public internet on port 53 (both UDP and TCP).
For this to work, the `CNAME` record for the `_acme-challenge.<cluster name>` subdomain needs to point to a domain like
`<cluster name>.my-dns-acme-server.com`. The `A` record of `<cluster name>.my-dns-acme-server.com` needs to contain the public
IP of a DNS server that is running the Plane ACME DNS-01 receiver, with port 53 (TCP and UDP) open to the public internet.

These are basic DNS servers that exist only to serve the ACME DNS-01 challenge, which is required for proxies to update their
certificates.
4 changes: 3 additions & 1 deletion plane/plane-tests/tests/common/test_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ impl TestEnvironment {
let port = listener.local_addr().unwrap().port();
let name = AcmeDnsServerName::new_random();
let handle = tokio::spawn(async move {
run_dns_with_listener(name, client, listener).await.unwrap();
run_dns_with_listener(name, client, listener, None)
.await
.unwrap();
});

DnsServer {
Expand Down
58 changes: 57 additions & 1 deletion plane/src/admin.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::{
client::{PlaneClient, PlaneClientError},
names::{BackendName, DroneName},
names::{BackendName, DroneName, Name, ProxyName},
protocol::{CertManagerRequest, CertManagerResponse, MessageFromProxy, MessageToProxy},
types::{BackendStatus, ClusterName, ConnectRequest, ExecutorConfig, KeyConfig, SpawnConfig},
PLANE_GIT_HASH, PLANE_VERSION,
};
Expand Down Expand Up @@ -94,6 +95,10 @@
#[clap(long)]
drone: DroneName,
},
PutDummyDns {
#[clap(long)]
cluster: ClusterName,
},
Status,
}

Expand Down Expand Up @@ -222,6 +227,57 @@
client_hash
);
}
AdminCommand::PutDummyDns { cluster } => {
let connection = client.proxy_connection(&cluster);
let proxy_name = ProxyName::new_random();
let mut conn = connection.connect(&proxy_name).await.unwrap();

Check warning on line 233 in plane/src/admin.rs

View workflow job for this annotation

GitHub Actions / clippy

used `unwrap()` on a `Result` value

warning: used `unwrap()` on a `Result` value --> plane/src/admin.rs:233:28 | 233 | let mut conn = connection.connect(&proxy_name).await.unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is an `Err`, it will panic = help: consider using `expect()` to provide a better panic message = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used note: the lint level is defined here --> plane/src/lib.rs:1:9 | 1 | #![warn(clippy::unwrap_used)] | ^^^^^^^^^^^^^^^^^^^

conn.send(MessageFromProxy::CertManagerRequest(
CertManagerRequest::CertLeaseRequest,
))
.await
.unwrap();

Check warning on line 239 in plane/src/admin.rs

View workflow job for this annotation

GitHub Actions / clippy

used `unwrap()` on a `Result` value

warning: used `unwrap()` on a `Result` value --> plane/src/admin.rs:235:13 | 235 | / conn.send(MessageFromProxy::CertManagerRequest( 236 | | CertManagerRequest::CertLeaseRequest, 237 | | )) 238 | | .await 239 | | .unwrap(); | |_____________________^ | = note: if this value is an `Err`, it will panic = help: consider using `expect()` to provide a better panic message = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used

let response = conn.recv().await.unwrap();

Check warning on line 241 in plane/src/admin.rs

View workflow job for this annotation

GitHub Actions / clippy

used `unwrap()` on an `Option` value

warning: used `unwrap()` on an `Option` value --> plane/src/admin.rs:241:28 | 241 | let response = conn.recv().await.unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is `None`, it will panic = help: consider using `expect()` to provide a better panic message = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used

match response {
MessageToProxy::CertManagerResponse(CertManagerResponse::CertLeaseResponse {
accepted,
}) => {
if accepted {
tracing::info!("Leased dummy DNS.");
} else {
tracing::error!("Failed to lease dummy DNS.");
}
}
_ => panic!("Unexpected response"),
}

let message = format!("Dummy message from {}", proxy_name.to_string());

Check warning on line 256 in plane/src/admin.rs

View workflow job for this annotation

GitHub Actions / clippy

`to_string` applied to a type that implements `Display` in `format!` args

warning: `to_string` applied to a type that implements `Display` in `format!` args --> plane/src/admin.rs:256:70 | 256 | let message = format!("Dummy message from {}", proxy_name.to_string()); | ^^^^^^^^^^^^ help: remove this | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#to_string_in_format_args = note: `#[warn(clippy::to_string_in_format_args)]` on by default

conn.send(MessageFromProxy::CertManagerRequest(
CertManagerRequest::SetTxtRecord {
txt_value: message.clone(),
},
))
.await
.unwrap();

Check warning on line 264 in plane/src/admin.rs

View workflow job for this annotation

GitHub Actions / clippy

used `unwrap()` on a `Result` value

warning: used `unwrap()` on a `Result` value --> plane/src/admin.rs:258:13 | 258 | / conn.send(MessageFromProxy::CertManagerRequest( 259 | | CertManagerRequest::SetTxtRecord { 260 | | txt_value: message.clone(), 261 | | }, 262 | | )) 263 | | .await 264 | | .unwrap(); | |_____________________^ | = note: if this value is an `Err`, it will panic = help: consider using `expect()` to provide a better panic message = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used

let response = conn.recv().await.unwrap();

Check warning on line 266 in plane/src/admin.rs

View workflow job for this annotation

GitHub Actions / clippy

used `unwrap()` on an `Option` value

warning: used `unwrap()` on an `Option` value --> plane/src/admin.rs:266:28 | 266 | let response = conn.recv().await.unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is `None`, it will panic = help: consider using `expect()` to provide a better panic message = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used

match response {
MessageToProxy::CertManagerResponse(
CertManagerResponse::SetTxtRecordResponse { accepted },
) => {
if accepted {
tracing::info!(?message, "Sent dummy DNS message.");
} else {
tracing::error!(?message, "Failed to set DNS message.");
}
}
_ => panic!("Unexpected response"),
}
}
};

Ok(())
Expand Down
31 changes: 20 additions & 11 deletions plane/src/dns/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use self::error::Result;
use self::{error::Result, name_to_cluster::NameToCluster};
use crate::{
client::PlaneClient,
dns::error::OrDnsError,
Expand Down Expand Up @@ -28,17 +28,23 @@ use trust_dns_server::{
};

mod error;
mod name_to_cluster;

const TCP_TIMEOUT_SECONDS: u64 = 10;

struct AcmeDnsServer {
loop_handle: Option<JoinHandle<()>>,
send: Sender<MessageFromDns>,
request_map: Arc<DashMap<ClusterName, Sender<Option<String>>>>,
name_to_cluster: NameToCluster,
}

impl AcmeDnsServer {
fn new(name: AcmeDnsServerName, mut client: TypedSocketConnector<MessageFromDns>) -> Self {
fn new(
name: AcmeDnsServerName,
mut client: TypedSocketConnector<MessageFromDns>,
zone: Option<String>,
) -> Self {
let (send, mut recv) = broadcast::channel::<MessageFromDns>(1);
let request_map: Arc<DashMap<ClusterName, Sender<Option<String>>>> = Arc::default();

Expand Down Expand Up @@ -90,6 +96,7 @@ impl AcmeDnsServer {
loop_handle: Some(loop_handle),
send,
request_map,
name_to_cluster: NameToCluster::new(zone),
}
}

Expand All @@ -115,19 +122,19 @@ impl AcmeDnsServer {
match request.query().query_type() {
RecordType::TXT => {
tracing::info!(?request, ?name, "TXT query.");
let name = name.strip_suffix('.').unwrap_or(&name);
let Some(name) = name.strip_prefix("_acme-challenge.") else {
tracing::warn!(?request, ?name, "TXT query on non _acme-challenge domain.");

let Some(cluster) = self.name_to_cluster.cluster_name(&name) else {
tracing::warn!(
?request,
?name,
"TXT query for record that does not match configured zone."
);
return Err(error::DnsError {
code: ResponseCode::FormErr,
message: format!("Invalid TXT query: {}", name),
});
};

let cluster: ClusterName =
name.parse().or_dns_error(ResponseCode::ServFail, || {
format!("No TXT record found for {}", name)
})?;
let result = self
.request(cluster)
.await
Expand Down Expand Up @@ -203,8 +210,9 @@ pub async fn run_dns_with_listener(
name: AcmeDnsServerName,
client: PlaneClient,
listener: TcpListener,
zone: Option<String>,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let mut fut = ServerFuture::new(AcmeDnsServer::new(name, client.dns_connection()));
let mut fut = ServerFuture::new(AcmeDnsServer::new(name, client.dns_connection(), zone));

let addr = listener.local_addr()?;

Expand Down Expand Up @@ -233,10 +241,11 @@ pub async fn run_dns(
name: AcmeDnsServerName,
client: PlaneClient,
port: u16,
zone: Option<String>,
) -> anyhow::Result<()> {
let ip_port_pair = (Ipv4Addr::UNSPECIFIED, port);
let listener = TcpListener::bind(ip_port_pair).await?;
run_dns_with_listener(name, client, listener)
run_dns_with_listener(name, client, listener, zone)
.await
.map_err(|err| anyhow!("Error running DNS server {:?}", err))
}
94 changes: 94 additions & 0 deletions plane/src/dns/name_to_cluster.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use std::str::FromStr;

use crate::types::ClusterName;

/// Maps a requested domain name to a cluster name.
///
/// This can be configured to operate in two ways.
///
/// If the `cname_zone` is `None`, the server assumes that it is the authoritative
/// DNS server for the cluster. It expects requests of the form
/// _acme-challenge.<cluster name>, and will strip the _acme-challenge prefix.
///
/// If the `cname_zone` is `Some(_)`, the server will expect requests of the form
/// <cluster name>.<cname_zone>, and will strip the <cname_zone> suffix.
///
/// Note that the _acme-challenge. prefix is not expected in the case where
/// `cname_zone` is `Some(_)`. The _acme-challenge record should be pointed
/// directly to the <cluster name>.<cname_zone> record via a CNAME record.
///
/// In production, the `cname_zone` should always be set (and the CLI enforces
/// this), but for testing it is useful to be able to run without a `cnbame_zone`
pub struct NameToCluster {
cname_zone: Option<String>,
}

impl NameToCluster {
pub fn new(cname_zone: Option<String>) -> Self {
Self { cname_zone }
}

pub fn cluster_name(&self, name: &str) -> Option<ClusterName> {
let name = name.strip_suffix('.').unwrap_or(name);

match &self.cname_zone {
Some(cname_zone) => {
let name = name.strip_suffix(cname_zone)?;
let name = name.strip_suffix('.').unwrap_or(name);
ClusterName::from_str(name).ok()
}
None => {
let name = name.strip_prefix("_acme-challenge.")?;
ClusterName::from_str(name).ok()
}
}
}
}

#[cfg(test)]
mod test {
use crate::types::ClusterName;
use std::str::FromStr;

#[test]
fn test_no_cname_zone() {
let name_to_cluster = super::NameToCluster::new(None);

assert_eq!(
name_to_cluster.cluster_name("foo.bar.baz"),
None,
"No cluster name should be returned for a domain without a _acme-challenge prefix."
);

assert_eq!(
name_to_cluster.cluster_name("_acme-challenge.foo.bar.baz"),
Some(ClusterName::from_str("foo.bar.baz").unwrap())
);

assert_eq!(
name_to_cluster.cluster_name("_acme-challenge.foo.bar.baz."),
Some(ClusterName::from_str("foo.bar.baz").unwrap())
);
}

#[test]
fn test_cname_zone() {
let name_to_cluster = super::NameToCluster::new(Some("example.com".to_string()));

assert_eq!(
name_to_cluster.cluster_name("foo.bar.baz"),
None,
"No match for a domain that lacks the cname zone."
);

assert_eq!(
name_to_cluster.cluster_name("foo.bar.baz.example.com"),
Some(ClusterName::from_str("foo.bar.baz").unwrap())
);

assert_eq!(
name_to_cluster.cluster_name("foo.bar.baz.example.com."),
Some(ClusterName::from_str("foo.bar.baz").unwrap())
);
}
}
13 changes: 12 additions & 1 deletion plane/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ enum Command {
#[clap(long)]
controller_url: Url,

/// Suffix to strip from requests before looking up TXT records.
/// E.g. if the zone is "example.com", a TXT record lookup
/// for foo.bar.baz.example.com
/// will return the TXT records for the cluster "foo.bar.baz".
///
/// The DNS record for _acme-challenge.foo.bar.baz in this case
/// should have a CNAME record pointing to foo.bar.baz.example.com.
#[clap(long)]
zone: String,

#[clap(long, default_value = "53")]
port: u16,
},
Expand Down Expand Up @@ -232,11 +242,12 @@ async fn run(opts: Opts) -> Result<()> {
name,
controller_url,
port,
zone,
} => {
let name = name.or_random();
tracing::info!("Starting DNS server");
let client = PlaneClient::new(controller_url);
run_dns(name, client, port).await?;
run_dns(name, client, port, Some(zone)).await?;
}
Command::Admin(admin_opts) => {
plane::admin::run_admin_command(admin_opts).await;
Expand Down
Loading