Files
wireguard-admin/src/wireguard/document.rs
T

631 lines
24 KiB
Rust

//! Lossless byte-span representation and redacted public inspection.
//!
//! The supported input encoding is UTF-8. Input that is not valid UTF-8 is
//! rejected, without including any source bytes in the error. The document
//! retains the exact input bytes, including mixed line endings and a missing
//! final newline. Public views expose only section/key spans, peer public keys,
//! and parsed AllowedIPs; no arbitrary directive values are exposed because
//! they may contain secrets.
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use ipnet::IpNet;
use thiserror::Error;
use crate::domain::PublicKey;
use super::metadata::{self, MetadataAttachment, MetadataIssue};
/// A half-open byte range into the original UTF-8 input.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SourceSpan {
start: usize,
end: usize,
}
impl SourceSpan {
/// Construct a span from byte offsets. Returns `None` when the end precedes the start.
pub const fn from_offsets(start: usize, end: usize) -> Option<Self> {
if start <= end {
Some(Self { start, end })
} else {
None
}
}
pub const fn start(self) -> usize {
self.start
}
pub const fn end(self) -> usize {
self.end
}
pub const fn len(self) -> usize {
self.end - self.start
}
pub const fn is_empty(self) -> bool {
self.start == self.end
}
}
/// The section type recognized from a bracketed section header.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SectionKind {
Interface,
Peer,
Other,
}
/// A recognized section and its original byte boundaries.
#[derive(Clone, Eq, PartialEq)]
pub struct DocumentSection {
pub kind: SectionKind,
/// Header name after trimming surrounding ASCII whitespace.
pub name: String,
/// Includes the header and section body, ending immediately before the next header.
pub span: SourceSpan,
/// Header line only, excluding its original line ending.
pub header_span: SourceSpan,
pub line: usize,
}
impl fmt::Debug for DocumentSection {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DocumentSection")
.field("kind", &self.kind)
.field("span", &self.span)
.field("header_span", &self.header_span)
.field("line", &self.line)
.finish()
}
}
/// Spans of a recognized key/value directive; values remain private to the document.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DirectiveSpan {
pub line: usize,
pub whole: SourceSpan,
pub key: SourceSpan,
pub value: SourceSpan,
}
/// A public, secret-safe inspection of one `[Peer]` section.
///
/// Conversion to the narrow `domain::Client` model is intentionally deferred
/// to the M2.3 adapter: this view has no alias until managed metadata is parsed.
/// That conversion must require one unambiguous identity and route shapes that
/// fit `ClientAddresses`; it must reject rather than truncate other AllowedIPs.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerInspection {
pub index: usize,
pub section: SourceSpan,
pub section_line: usize,
/// Present only when this section has exactly one valid PublicKey directive.
pub identity: Option<PublicKey>,
/// Every valid public key value, even when the section is ambiguous.
pub public_key_candidates: Vec<PublicKey>,
/// Every PublicKey directive is retained as a span, including duplicates and malformed values.
pub public_key_fields: Vec<DirectiveSpan>,
/// Parsed route values. Unparseable items are represented by `PeerIssue` entries.
pub allowed_ips: Vec<IpNet>,
pub allowed_ips_fields: Vec<DirectiveSpan>,
/// Parsed alias and safe owned block span, or an explicit metadata issue.
pub metadata: MetadataAttachment,
pub issues: Vec<PeerIssue>,
}
/// Issues found while inspecting a peer. Variants contain no source text.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PeerIssue {
MissingPublicKey,
MalformedPublicKey { field: usize },
DuplicatePublicKeyField { count: usize },
DuplicatePeerIdentity { other_peer_indices: Vec<usize> },
MalformedAllowedIp { field: usize, item: usize },
UnsupportedAllowedIpShape { field: usize, item: usize },
MissingIpv4HostRoute,
}
/// Structural issues found outside peer-level inspection.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DocumentIssue {
MalformedSectionHeader { line: usize },
DirectiveOutsideSection { line: usize },
MalformedDirective { line: usize },
ManagedMetadata(MetadataIssue),
}
/// A document-level parse failure. It intentionally never contains input bytes or text.
#[derive(Debug, Error, Eq, PartialEq)]
pub enum DocumentError {
#[error("server configuration is not valid UTF-8")]
InvalidEncoding,
#[error("server configuration could not be inspected safely")]
InvalidStructure,
}
/// Parsed view over the exact caller-supplied configuration bytes.
pub struct ConfigDocument {
source: String,
sections: Vec<DocumentSection>,
peers: Vec<PeerInspection>,
issues: Vec<DocumentIssue>,
}
impl fmt::Debug for ConfigDocument {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ConfigDocument")
.field("source_len", &self.source.len())
.field("sections", &self.sections)
.field("peers", &self.peers)
.field("issues", &self.issues)
.finish()
}
}
impl ConfigDocument {
/// Parse UTF-8 configuration supplied by the caller while retaining every source byte.
pub fn parse(source: impl AsRef<[u8]>) -> Result<Self, DocumentError> {
let bytes = source.as_ref();
let source = std::str::from_utf8(bytes).map_err(|_| DocumentError::InvalidEncoding)?;
let mut sections = Vec::<DocumentSection>::new();
let mut issues = Vec::<DocumentIssue>::new();
let mut peer_fields = Vec::<(
usize,
Vec<DirectiveSpan>,
Vec<(DirectiveSpan, Vec<(usize, Result<IpNet, ()>)>)>,
)>::new();
let mut current_section: Option<usize> = None;
let mut offset = 0usize;
let mut line_number = 0usize;
for line_with_ending in source.split_inclusive('\n') {
line_number += 1;
let line_start = offset;
offset += line_with_ending.len();
let content = line_with_ending
.strip_suffix('\n')
.unwrap_or(line_with_ending);
let content = content.strip_suffix('\r').unwrap_or(content);
let line_end = line_start + content.len();
let span = SourceSpan {
start: line_start,
end: offset,
};
let content_span = SourceSpan {
start: line_start,
end: line_end,
};
let trimmed = content.trim_matches(|ch: char| ch.is_ascii_whitespace());
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if trimmed.starts_with('[') || trimmed.ends_with(']') {
if let Some(name) = parse_section_name(trimmed) {
if let Some(previous) = current_section {
sections[previous].span.end = line_start;
}
let normalized = name.to_ascii_lowercase();
let kind = match normalized.as_str() {
"interface" => SectionKind::Interface,
"peer" => SectionKind::Peer,
_ => SectionKind::Other,
};
sections.push(DocumentSection {
kind,
name: name.to_owned(),
span: SourceSpan {
start: line_start,
end: source.len(),
},
header_span: content_span,
line: line_number,
});
current_section = Some(sections.len() - 1);
if kind == SectionKind::Peer {
peer_fields.push((line_number, Vec::new(), Vec::new()));
}
continue;
}
issues.push(DocumentIssue::MalformedSectionHeader { line: line_number });
continue;
}
let Some(equal_index) = content.find('=') else {
issues.push(DocumentIssue::MalformedDirective { line: line_number });
continue;
};
let raw_key = &content[..equal_index];
let raw_value = &content[equal_index + 1..];
let key = raw_key.trim_matches(|ch: char| ch.is_ascii_whitespace());
if key.is_empty() {
issues.push(DocumentIssue::MalformedDirective { line: line_number });
continue;
}
let raw_key_start = raw_key.len()
- raw_key
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
.len();
let raw_key_end = raw_key
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
.len();
let raw_value_start = raw_value.len()
- raw_value
.trim_start_matches(|ch: char| ch.is_ascii_whitespace())
.len();
let raw_value_end = raw_value
.trim_end_matches(|ch: char| ch.is_ascii_whitespace())
.len();
let directive = DirectiveSpan {
line: line_number,
whole: span,
key: SourceSpan {
start: line_start + raw_key_start,
end: line_start + raw_key_end,
},
value: SourceSpan {
start: line_start + equal_index + 1 + raw_value_start,
end: line_start + equal_index + 1 + raw_value_end,
},
};
let Some(section_index) = current_section else {
issues.push(DocumentIssue::DirectiveOutsideSection { line: line_number });
continue;
};
if sections[section_index].kind != SectionKind::Peer {
continue;
}
let peer_index = peer_fields.len() - 1;
let (_, public_keys, allowed_fields) = &mut peer_fields[peer_index];
if key.eq_ignore_ascii_case("PublicKey") {
public_keys.push(directive);
} else if key.eq_ignore_ascii_case("AllowedIPs") {
let value = &source[directive.value.start..directive.value.end];
let parsed = value
.split(',')
.enumerate()
.map(|(item, raw)| {
let item_value = raw.trim();
(item, IpNet::from_str(item_value).map_err(|_| ()))
})
.collect();
allowed_fields.push((directive, parsed));
}
}
// `split_inclusive` yields no item for an empty string and its final item does not
// necessarily end in LF; offset therefore tracks all source bytes exactly.
let mut peers = Vec::with_capacity(peer_fields.len());
for (index, (section_line, public_key_fields, allowed_fields)) in
peer_fields.into_iter().enumerate()
{
let Some(section) = sections
.iter()
.filter(|section| section.kind == SectionKind::Peer)
.nth(index)
else {
return Err(DocumentError::InvalidStructure);
};
let mut peer_issues = Vec::new();
let parsed_keys: Vec<_> = public_key_fields
.iter()
.enumerate()
.map(|(field, directive)| {
let value = &source[directive.value.start..directive.value.end];
match PublicKey::from_str(value) {
Ok(key) => Some(key),
Err(_) => {
peer_issues.push(PeerIssue::MalformedPublicKey { field });
None
}
}
})
.collect();
if public_key_fields.is_empty() {
peer_issues.push(PeerIssue::MissingPublicKey);
}
if public_key_fields.len() > 1 {
peer_issues.push(PeerIssue::DuplicatePublicKeyField {
count: public_key_fields.len(),
});
}
let identity = if public_key_fields.len() == 1 {
parsed_keys[0]
} else {
None
};
let mut allowed_ips = Vec::new();
let mut allowed_ips_fields = Vec::new();
let mut has_ipv4_host_route = false;
let mut has_ipv6_host_route = false;
for (field, (directive, items)) in allowed_fields.into_iter().enumerate() {
allowed_ips_fields.push(directive);
for (item, result) in items {
match result {
Ok(net) => {
let supported = match net {
IpNet::V4(network) if network.prefix_len() == 32 => {
let first = !has_ipv4_host_route;
has_ipv4_host_route = true;
first
}
IpNet::V6(network) if network.prefix_len() == 128 => {
let first = !has_ipv6_host_route;
has_ipv6_host_route = true;
first
}
_ => false,
};
if !supported {
peer_issues
.push(PeerIssue::UnsupportedAllowedIpShape { field, item });
}
allowed_ips.push(net);
}
Err(()) => peer_issues.push(PeerIssue::MalformedAllowedIp { field, item }),
}
}
}
if !has_ipv4_host_route {
peer_issues.push(PeerIssue::MissingIpv4HostRoute);
}
peers.push(PeerInspection {
index,
section: section.span,
section_line,
identity,
public_key_candidates: parsed_keys.into_iter().flatten().collect(),
public_key_fields,
allowed_ips,
allowed_ips_fields,
metadata: MetadataAttachment::Absent,
issues: peer_issues,
});
}
let mut by_identity = BTreeMap::<PublicKey, Vec<usize>>::new();
for peer in &peers {
for identity in &peer.public_key_candidates {
by_identity.entry(*identity).or_default().push(peer.index);
}
}
for indices in by_identity.values().filter(|indices| indices.len() > 1) {
let unique_indices: Vec<_> =
indices.iter().copied().fold(Vec::new(), |mut acc, index| {
if !acc.contains(&index) {
acc.push(index);
}
acc
});
for index in &unique_indices {
let other_peer_indices: Vec<_> = unique_indices
.iter()
.copied()
.filter(|other| other != index)
.collect();
if other_peer_indices.is_empty() {
continue;
}
peers[*index]
.issues
.push(PeerIssue::DuplicatePeerIdentity { other_peer_indices });
}
}
let peer_headers: Vec<_> = peers.iter().map(|peer| peer.section.start()).collect();
let (metadata_attachments, metadata_issues) =
metadata::inspect_document(source, &peer_headers);
for (peer, attachment) in peers.iter_mut().zip(metadata_attachments) {
peer.metadata = attachment;
}
issues.extend(
metadata_issues
.into_iter()
.map(DocumentIssue::ManagedMetadata),
);
Ok(Self {
source: source.to_owned(),
sections,
peers,
issues,
})
}
/// Exact original bytes, without normalization or reserialization.
pub fn render(&self) -> &[u8] {
self.source.as_bytes()
}
pub fn sections(&self) -> &[DocumentSection] {
&self.sections
}
pub fn peers(&self) -> &[PeerInspection] {
&self.peers
}
pub fn issues(&self) -> &[DocumentIssue] {
&self.issues
}
/// A raw source slice for an explicitly selected span. Callers must treat it as secret-bearing.
/// This narrow primitive supports later edit code that owns and replaces exact spans.
pub fn source_slice(&self, span: SourceSpan) -> Option<&str> {
self.source.get(span.start..span.end)
}
}
fn parse_section_name(trimmed: &str) -> Option<&str> {
let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
let name = inner.trim_matches(|ch: char| ch.is_ascii_whitespace());
(!name.is_empty() && !name.contains(['[', ']'])).then_some(name)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
#[test]
fn wireguard_document_round_trips_line_ending_variants_byte_for_byte() {
let inputs: &[&[u8]] = &[
b"[Interface]\nAddress = 10.0.0.1/24\n# admin\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.0.0.2/32\n",
b"[Interface]\r\nAddress = 10.0.0.1/24\r\n[Peer]\r\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n",
b"# first\r\n[Interface]\nUnknown = value\r\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n# tail",
b"[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
];
for input in inputs {
let document = ConfigDocument::parse(input).unwrap();
assert_eq!(document.render(), *input);
}
}
#[test]
fn wireguard_peer_inspection_preserves_multiple_routes_and_unknown_directives() {
let input = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 2001:db8::2/128, 0.0.0.0/0\nPresharedKey = dummy-secret\nUnknown = retained\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 2001:db8::3/128\n"
);
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
assert_eq!(document.peers().len(), 2);
assert_eq!(document.peers()[0].allowed_ips.len(), 3);
assert_eq!(document.peers()[1].allowed_ips.len(), 1);
assert_eq!(document.peers()[0].identity.unwrap().to_string(), KEY_A);
assert!(document.peers()[0].issues.iter().any(|issue| matches!(
issue,
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 2 }
)));
assert!(
document.peers()[1]
.issues
.contains(&PeerIssue::MissingIpv4HostRoute)
);
}
#[test]
fn wireguard_unsupported_route_text_is_preserved_and_reported() {
let input =
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = not-a-route, 2001:db8::2/128\n");
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
let peer = &document.peers()[0];
assert_eq!(peer.allowed_ips.len(), 1);
assert!(
peer.issues
.iter()
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }))
);
assert_eq!(document.render(), input.as_bytes());
assert_eq!(
document.source_slice(peer.allowed_ips_fields[0].value),
Some("not-a-route, 2001:db8::2/128")
);
}
#[test]
fn wireguard_peer_issues_report_missing_malformed_and_duplicate_identity_fields() {
let input = format!(
"[Peer]\nPresharedKey = secret-a\n[Peer]\nPublicKey = bad-secret-shaped-input\nPublicKey = {KEY_A}\n[Peer]\nPublicKey = {KEY_A}\n"
);
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
assert!(
document.peers()[0]
.issues
.contains(&PeerIssue::MissingPublicKey)
);
assert!(
document.peers()[1]
.issues
.iter()
.any(|issue| matches!(issue, PeerIssue::MalformedPublicKey { .. }))
);
assert!(
document.peers()[1]
.issues
.contains(&PeerIssue::DuplicatePublicKeyField { count: 2 })
);
assert!(
document.peers()[2]
.issues
.iter()
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. }))
);
let debug = format!("{document:?}");
assert!(!debug.contains("secret-a"));
assert!(!debug.contains("bad-secret-shaped-input"));
}
#[test]
fn wireguard_errors_and_debug_redact_invalid_text_and_private_values() {
let error =
ConfigDocument::parse(b"[Interface]\nPrivateKey = definitely-secret\xff").unwrap_err();
assert_eq!(error.to_string(), "server configuration is not valid UTF-8");
assert!(!format!("{error:?}").contains("definitely-secret"));
let document = ConfigDocument::parse(
b"[Interface]\nPrivateKey = private-value\n[Peer]\nPresharedKey = peer-secret\n",
)
.unwrap();
let debug = format!("{document:?}");
assert!(!debug.contains("private-value"));
assert!(!debug.contains("peer-secret"));
}
#[test]
fn wireguard_sections_and_directive_spans_are_byte_offsets() {
let input =
format!("[Interface]\r\nPrivateKey = hidden\r\n[Peer]\r\nPublicKey = {KEY_A}\r\n");
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
let peer = &document.peers()[0];
let key_text = document
.source_slice(peer.public_key_fields[0].value)
.unwrap();
assert_eq!(key_text, KEY_A);
assert_eq!(peer.section.start(), input.find("[Peer]").unwrap());
assert_eq!(peer.section.end(), input.len());
}
#[test]
fn wireguard_document_exposes_valid_metadata_and_safe_owned_span() {
let block = super::super::metadata::render_block("# café = \"laptop\"", None).unwrap();
let input = format!("{block}[Peer]\nPublicKey = {KEY_A}\n");
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
let peer = &document.peers()[0];
let MetadataAttachment::Valid {
metadata,
block: span,
} = &peer.metadata
else {
panic!("valid attached metadata should be available");
};
assert_eq!(metadata.alias, "# café = \"laptop\"");
assert_eq!(document.source_slice(*span), Some(block.as_str()));
assert!(document.issues().is_empty());
assert_eq!(document.render(), input.as_bytes());
}
#[test]
fn wireguard_document_reports_unsupported_metadata_without_source_text() {
let input = format!(
"# wg-admin:begin\n# wg-admin:version=99\n# wg-admin:alias=c2VjcmV0LW5vdGU\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\n"
);
let document = ConfigDocument::parse(input.as_bytes()).unwrap();
assert!(matches!(
document.peers()[0].metadata,
MetadataAttachment::Invalid(MetadataIssue::UnsupportedVersion { .. })
));
let debug = format!("{document:?}");
assert!(!debug.contains("c2VjcmV0LW5vdGU"));
assert!(!debug.contains("secret-note"));
assert_eq!(document.render(), input.as_bytes());
}
}