use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
crypto::ring::default_provider,
server::{ClientHello, ResolvesServerCert},
sign::CertifiedKey,
},
server::TlsStream,
};
use crate::{
cert::{self, CertError},
node::Node,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
#[non_exhaustive]
pub enum ServeTarget {
Accept,
Proxy {
to: String,
},
Text {
body: String,
},
TcpForward {
to: String,
},
Path {
handlers: alloc::collections::BTreeMap<String, ServeTarget>,
},
Redirect {
to: String,
status: u16,
},
}
impl ServeTarget {
pub fn terminates_tls(&self) -> bool {
match self {
ServeTarget::Accept
| ServeTarget::Proxy { .. }
| ServeTarget::Text { .. }
| ServeTarget::Path { .. }
| ServeTarget::Redirect { .. } => true,
ServeTarget::TcpForward { .. } => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ServeState {
pub name: String,
pub ports: alloc::collections::BTreeMap<u16, ServeTarget>,
}
impl ServeState {
pub fn validate(&self) -> Result<(), CertError> {
let any_tls = self.ports.values().any(ServeTarget::terminates_tls);
if any_tls && !cert::is_tailnet_name(&self.name) {
return Err(CertError::NotTailnetName(self.name.clone()));
}
for (port, target) in &self.ports {
if *port == 0 {
return Err(CertError::Acme("serve port must be non-zero".into()));
}
validate_target(target, 0)?;
}
Ok(())
}
}
const MAX_PATH_NESTING_DEPTH: usize = 1;
fn validate_target(target: &ServeTarget, depth: usize) -> Result<(), CertError> {
match target {
ServeTarget::Proxy { to } | ServeTarget::TcpForward { to } if to.trim().is_empty() => Err(
CertError::Acme("serve proxy/forward target must not be empty".into()),
),
ServeTarget::Redirect { to, status } => {
if to.trim().is_empty() {
return Err(CertError::Acme(
"serve redirect target must not be empty".into(),
));
}
if to.contains(['\r', '\n']) {
return Err(CertError::Acme(
"serve redirect target must not contain CR/LF".into(),
));
}
if !(300..=399).contains(status) {
return Err(CertError::Acme(
"serve redirect status must be in 300..=399".into(),
));
}
Ok(())
}
ServeTarget::Path { handlers } => {
if depth >= MAX_PATH_NESTING_DEPTH {
return Err(CertError::Acme(
"serve path handlers must not nest more than one level".into(),
));
}
if handlers.is_empty() {
return Err(CertError::Acme(
"serve path handlers must not be empty".into(),
));
}
for nested in handlers.values() {
validate_target(nested, depth + 1)?;
}
Ok(())
}
_ => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServeConfig {
pub name: String,
pub port: u16,
pub target: ServeTarget,
}
impl ServeConfig {
pub fn validate(&self) -> Result<(), CertError> {
if !cert::is_tailnet_name(&self.name) {
return Err(CertError::NotTailnetName(self.name.clone()));
}
if self.port == 0 {
return Err(CertError::Acme("serve port must be non-zero".into()));
}
validate_target(&self.target, 0)
}
}
#[derive(Debug)]
struct SingleCert(Arc<CertifiedKey>);
impl ResolvesServerCert for SingleCert {
fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
Some(self.0.clone())
}
}
pub fn tls_acceptor(cert: CertifiedKey) -> Result<TlsAcceptor, CertError> {
let config = ServerConfig::builder_with_provider(Arc::new(default_provider()))
.with_safe_default_protocol_versions()
.map_err(CertError::Rustls)?
.with_no_client_auth()
.with_cert_resolver(Arc::new(SingleCert(Arc::new(cert))));
Ok(TlsAcceptor::from(Arc::new(config)))
}
pub async fn accept_tls<Io>(acceptor: &TlsAcceptor, io: Io) -> Result<TlsStream<Io>, CertError>
where
Io: AsyncRead + AsyncWrite + Unpin,
{
acceptor.accept(io).await.map_err(CertError::Io)
}
pub async fn listen_tls(cfg: &ServeConfig) -> Result<TlsAcceptor, CertError> {
cfg.validate()?;
let cert = cert::get_certificate(&cfg.name).await?;
tls_acceptor(cert)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FunnelOptions {
pub funnel_only: bool,
}
#[derive(Debug)]
pub enum FunnelError {
NotAllowed,
PortNotAllowed(u16),
Cert(CertError),
Unsupported {
detail: String,
},
}
impl core::fmt::Display for FunnelError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
FunnelError::NotAllowed => write!(
f,
"Funnel not available: node lacks the \"https\" and/or \"funnel\" attributes"
),
FunnelError::PortNotAllowed(port) => {
write!(f, "port {port} is not allowed for funnel")
}
FunnelError::Cert(e) => write!(f, "Funnel certificate error: {e}"),
FunnelError::Unsupported { detail } => {
write!(f, "Funnel ingress is unsupported in this fork: {detail}")
}
}
}
}
impl std::error::Error for FunnelError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FunnelError::Cert(e) => Some(e),
FunnelError::NotAllowed
| FunnelError::PortNotAllowed(_)
| FunnelError::Unsupported { .. } => None,
}
}
}
impl From<CertError> for FunnelError {
fn from(e: CertError) -> Self {
FunnelError::Cert(e)
}
}
pub const MISSING_FUNNEL_RELAY: &str = "the Tailscale-operated public ingress relay + the public DNS \
<node>.<tailnet>.ts.net:443 -> relay mapping that POST public client bytes to this node's peerAPI \
/v0/ingress; these are Tailscale infrastructure (provisioned automatically against real Tailscale \
SaaS with a Funnel-enabled ACL) and a self-hosted control plane provides no such relay";
pub fn funnel_access(node: &Node, port: u16) -> Result<(), FunnelError> {
if !node.can_funnel() {
return Err(FunnelError::NotAllowed);
}
if !node.check_funnel_port(port) {
return Err(FunnelError::PortNotAllowed(port));
}
Ok(())
}
pub async fn listen_funnel(
node: &Node,
cfg: &ServeConfig,
_opts: FunnelOptions,
) -> Result<TlsAcceptor, FunnelError> {
cfg.validate()?;
funnel_access(node, cfg.port)?;
let cert = cert::get_certificate(&cfg.name).await?;
Ok(tls_acceptor(cert)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(name: &str, port: u16) -> ServeConfig {
ServeConfig {
name: name.into(),
port,
target: ServeTarget::Accept,
}
}
#[test]
fn validate_accepts_tailnet_name() {
assert!(cfg("host.tail1.ts.net", 443).validate().is_ok());
}
#[test]
fn validate_rejects_offtailnet_name() {
let err = cfg("example.com", 443).validate().unwrap_err();
assert!(matches!(err, CertError::NotTailnetName(_)));
}
#[test]
fn validate_rejects_zero_port() {
assert!(cfg("host.tail1.ts.net", 0).validate().is_err());
}
#[test]
fn validate_rejects_empty_proxy_target() {
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Proxy { to: " ".into() },
};
assert!(c.validate().is_err());
}
#[test]
fn serve_config_roundtrips_json() {
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 8443,
target: ServeTarget::Proxy {
to: "127.0.0.1:8080".into(),
},
};
let json = serde_json::to_string(&c).unwrap();
let back: ServeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn serve_target_path_redirect_roundtrips_json() {
let mut handlers = alloc::collections::BTreeMap::new();
handlers.insert(
"/".to_string(),
ServeTarget::Redirect {
to: "https://host.tail1.ts.net/app".into(),
status: 308,
},
);
handlers.insert(
"/api".to_string(),
ServeTarget::Proxy {
to: "127.0.0.1:8080".into(),
},
);
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Path { handlers },
};
let json = serde_json::to_string(&c).unwrap();
let back: ServeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
assert!(c.validate().is_ok());
}
#[test]
fn validate_rejects_bad_redirect_status() {
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Redirect {
to: "/elsewhere".into(),
status: 200,
},
};
assert!(c.validate().is_err());
}
#[test]
fn validate_rejects_empty_redirect_target() {
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Redirect {
to: " ".into(),
status: 302,
},
};
assert!(c.validate().is_err());
}
#[test]
fn validate_rejects_redirect_with_crlf() {
for bad in [
"https://host.tail1.ts.net/\r\nSet-Cookie: evil=1",
"https://host.tail1.ts.net/\rX",
"https://host.tail1.ts.net/\nX",
] {
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Redirect {
to: bad.into(),
status: 302,
},
};
assert!(
c.validate().is_err(),
"ServeConfig must reject CR/LF redirect target: {bad:?}"
);
let mut ports = alloc::collections::BTreeMap::new();
ports.insert(
443u16,
ServeTarget::Redirect {
to: bad.into(),
status: 302,
},
);
let st = ServeState {
name: "host.tail1.ts.net".into(),
ports,
};
assert!(
st.validate().is_err(),
"ServeState must reject CR/LF redirect target: {bad:?}"
);
}
let ok = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Redirect {
to: "https://host.tail1.ts.net/app".into(),
status: 308,
},
};
assert!(ok.validate().is_ok());
}
#[test]
fn validate_rejects_empty_path_handlers() {
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Path {
handlers: alloc::collections::BTreeMap::new(),
},
};
assert!(c.validate().is_err());
}
#[test]
fn validate_rejects_nested_path() {
let mut inner = alloc::collections::BTreeMap::new();
inner.insert("/deep".to_string(), ServeTarget::Accept);
let mut handlers = alloc::collections::BTreeMap::new();
handlers.insert("/".to_string(), ServeTarget::Path { handlers: inner });
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Path { handlers },
};
assert!(c.validate().is_err());
}
#[test]
fn validate_recurses_into_nested_path_target() {
let mut handlers = alloc::collections::BTreeMap::new();
handlers.insert("/".to_string(), ServeTarget::Proxy { to: " ".into() });
let c = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Path { handlers },
};
assert!(c.validate().is_err());
}
#[test]
fn serve_state_validate_accepts_path_and_redirect() {
let mut handlers = alloc::collections::BTreeMap::new();
handlers.insert(
"/api".to_string(),
ServeTarget::Proxy {
to: "127.0.0.1:8080".into(),
},
);
let mut ports = alloc::collections::BTreeMap::new();
ports.insert(443u16, ServeTarget::Path { handlers });
ports.insert(
8443u16,
ServeTarget::Redirect {
to: "/api".into(),
status: 307,
},
);
let st = ServeState {
name: "host.tail1.ts.net".into(),
ports,
};
assert!(st.validate().is_ok());
}
#[tokio::test]
async fn listen_tls_is_fail_closed() {
let err = match listen_tls(&cfg("host.tail1.ts.net", 443)).await {
Ok(_) => panic!("must not build an acceptor without a real cert"),
Err(e) => e,
};
assert!(matches!(err, CertError::Unimplemented { .. }));
}
#[test]
fn tls_acceptor_builds_from_certified_key() {
let cert = rcgen::generate_simple_self_signed(vec!["host.tail1.ts.net".into()]).unwrap();
let cert_pem = cert.cert.pem();
let key_pem = cert.key_pair.serialize_pem();
let ck = cert::certified_key_from_pem(cert_pem.as_bytes(), key_pem.as_bytes()).unwrap();
assert!(tls_acceptor(ck).is_ok());
}
use crate::node::{Node, NodeCapMap, StableId, TailnetAddress};
fn funnel_node(caps: &[&str]) -> Node {
let mut cap_map = NodeCapMap::new();
for c in caps {
cap_map.insert((*c).to_string(), vec![]);
}
Node {
id: 1,
stable_id: StableId("n1".to_string()),
hostname: "host".to_string(),
user_id: 0,
tailnet: Some("tail1.ts.net".to_string()),
tags: vec![],
tailnet_address: TailnetAddress {
ipv4: "100.64.0.1/32".parse().unwrap(),
ipv6: "fd7a::1/128".parse().unwrap(),
},
node_key: [0u8; 32].into(),
node_key_expiry: None,
machine_key: None,
disco_key: None,
accepted_routes: vec![],
underlay_addresses: vec![],
derp_region: None,
cap: Default::default(),
cap_map,
peerapi_port: None,
peerapi_dns_proxy: false,
is_wireguard_only: false,
exit_node_dns_resolvers: vec![],
peer_relay: false,
service_vips: Default::default(),
key_signature: vec![],
}
}
const FUNNEL_PORTS_443_8443: &str =
"https://tailscale.com/cap/funnel-ports?ports=443,8443,10000-10010";
#[test]
fn funnel_access_denies_without_both_attrs() {
assert!(matches!(
funnel_access(&funnel_node(&[]), 443),
Err(FunnelError::NotAllowed)
));
assert!(matches!(
funnel_access(&funnel_node(&["https", FUNNEL_PORTS_443_8443]), 443),
Err(FunnelError::NotAllowed)
));
assert!(matches!(
funnel_access(&funnel_node(&["funnel", FUNNEL_PORTS_443_8443]), 443),
Err(FunnelError::NotAllowed)
));
}
#[test]
fn funnel_access_denies_disallowed_port() {
let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
assert!(matches!(
funnel_access(&node, 22),
Err(FunnelError::PortNotAllowed(22))
));
}
#[test]
fn funnel_access_allows_listed_single_and_range_ports() {
let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
assert!(funnel_access(&node, 443).is_ok());
assert!(funnel_access(&node, 8443).is_ok());
assert!(funnel_access(&node, 10000).is_ok());
assert!(funnel_access(&node, 10005).is_ok());
assert!(funnel_access(&node, 10010).is_ok());
assert!(funnel_access(&node, 9999).is_err());
assert!(funnel_access(&node, 10011).is_err());
}
#[test]
fn check_funnel_port_denies_without_ports_cap() {
let node = funnel_node(&["https", "funnel"]);
assert!(node.can_funnel());
assert!(!node.check_funnel_port(443));
}
#[test]
fn check_funnel_port_denies_empty_ports_query() {
let node = funnel_node(&[
"https",
"funnel",
"https://tailscale.com/cap/funnel-ports?ports=",
]);
assert!(!node.check_funnel_port(443));
}
#[test]
fn check_funnel_port_rejects_wrong_url_with_ports_query() {
let node = funnel_node(&[
"https",
"funnel",
"https://tailscale.com/cap/funnel-ports-evil?ports=443",
]);
assert!(!node.check_funnel_port(443));
}
#[tokio::test]
async fn listen_funnel_is_fail_closed_unsupported_when_allowed() {
let node = funnel_node(&["https", "funnel", FUNNEL_PORTS_443_8443]);
let cfg = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Accept,
};
let err = match listen_funnel(&node, &cfg, FunnelOptions::default()).await {
Ok(_) => panic!("must not build a Funnel acceptor without relay + real cert"),
Err(e) => e,
};
assert!(matches!(
err,
FunnelError::Unsupported { .. } | FunnelError::Cert(_)
));
}
#[tokio::test]
async fn listen_funnel_denies_before_cert_when_not_allowed() {
let node = funnel_node(&[]);
let cfg = ServeConfig {
name: "host.tail1.ts.net".into(),
port: 443,
target: ServeTarget::Accept,
};
let err = match listen_funnel(&node, &cfg, FunnelOptions::default()).await {
Ok(_) => panic!("must deny a node that cannot funnel"),
Err(e) => e,
};
assert!(matches!(err, FunnelError::NotAllowed));
}
}