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

502 lines
17 KiB
Rust

//! Versioned, comment-only metadata immediately attached to a WireGuard peer.
//!
//! The alias is encoded as canonical unpadded base64url of its UTF-8 bytes. This
//! keeps arbitrary valid aliases on one comment line without introducing config
//! syntax, comment termination, or line injection.
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crate::domain::validate_alias;
use super::SourceSpan;
const BEGIN: &str = "# wg-admin:begin";
const END: &str = "# wg-admin:end";
const VERSION_PREFIX: &str = "# wg-admin:version=";
const ALIAS_PREFIX: &str = "# wg-admin:alias=";
const CREATED_PREFIX: &str = "# wg-admin:created-utc=";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ManagedMetadata {
pub version: u32,
pub alias: String,
pub created_utc: Option<String>,
}
/// A safe half-open source span for a validated, uniquely attached block.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MetadataAttachment {
Absent,
Valid {
metadata: ManagedMetadata,
block: SourceSpan,
},
Invalid(MetadataIssue),
}
/// Redacted metadata problems. Variants identify a line, never include its text.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MetadataIssue {
MalformedBlock { line: usize },
UnsupportedVersion { line: usize },
InvalidAlias { line: usize },
InvalidTimestamp { line: usize },
UnknownField { line: usize },
MissingField { line: usize },
DuplicateBlocks { line: usize, count: usize },
Misattached { line: usize },
}
#[derive(Clone, Debug)]
struct Line<'a> {
content: &'a str,
start: usize,
end: usize,
number: usize,
}
#[derive(Clone, Debug)]
struct Candidate {
start_line: usize,
end_line: usize,
span: SourceSpan,
issue: Option<MetadataIssue>,
}
/// Inspect all reserved marker pairs and attach blocks only when their final byte
/// is the peer header's first byte. Administrator comments outside this namespace
/// are ignored completely.
pub(super) fn inspect_document(
source: &str,
peer_headers: &[usize],
) -> (Vec<MetadataAttachment>, Vec<MetadataIssue>) {
let lines = lines(source);
let mut candidates = Vec::new();
let mut issues = Vec::new();
let mut index = 0;
while index < lines.len() {
if marker(lines[index].content) != Some(Marker::Begin) {
if marker(lines[index].content) == Some(Marker::End) {
issues.push(MetadataIssue::MalformedBlock {
line: lines[index].number,
});
}
index += 1;
continue;
}
let start = index;
let mut end = None;
let mut malformed = false;
let mut cursor = index + 1;
while cursor < lines.len() {
match marker(lines[cursor].content) {
Some(Marker::Begin) => {
malformed = true;
break;
}
Some(Marker::End) => {
end = Some(cursor);
break;
}
None => {}
}
cursor += 1;
}
let attached_peer = peer_headers.iter().find(|header| {
**header > lines[start].start
&& (end.is_none()
|| **header <= lines[cursor.min(lines.len().saturating_sub(1))].start)
});
let block_end_line = end.unwrap_or_else(|| {
if malformed {
cursor
} else if let Some(header) = attached_peer {
lines
.partition_point(|line| line.start < *header)
.saturating_sub(1)
} else {
lines.len().saturating_sub(1)
}
});
let span_end = if let Some(end_line) = end {
lines[end_line].end
} else if malformed {
lines[block_end_line].start
} else if let Some(header) = attached_peer {
*header
} else {
source.len()
};
let Some(span) = SourceSpan::from_offsets(lines[start].start, span_end) else {
index += 1;
continue;
};
let issue = if malformed || end.is_none() {
Some(MetadataIssue::MalformedBlock {
line: lines[start].number,
})
} else {
parse_block(&lines[start..=block_end_line]).err()
};
if let Some(issue) = &issue {
issues.push(issue.clone());
}
candidates.push(Candidate {
start_line: start,
end_line: block_end_line,
span,
issue,
});
if let Some(end) = end {
index = end + 1;
} else if malformed {
index = cursor;
} else {
break;
}
}
let mut attachments = Vec::with_capacity(peer_headers.len());
for header in peer_headers {
let ending_here: Vec<_> = candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.span.end() == *header)
.map(|(candidate, _)| candidate)
.collect();
if ending_here.is_empty() {
attachments.push(MetadataAttachment::Absent);
continue;
}
let Some(last) = ending_here.last().copied() else {
attachments.push(MetadataAttachment::Absent);
continue;
};
let mut contiguous = 1;
let mut previous = last;
while previous > 0
&& candidates[previous - 1].span.end() == candidates[previous].span.start()
{
contiguous += 1;
previous -= 1;
}
let candidate = &candidates[last];
if contiguous > 1 {
let issue = MetadataIssue::DuplicateBlocks {
line: lines[candidate.start_line].number,
count: contiguous,
};
issues.push(issue.clone());
attachments.push(MetadataAttachment::Invalid(issue));
} else if let Some(issue) = &candidate.issue {
attachments.push(MetadataAttachment::Invalid(issue.clone()));
} else {
let parsed = parse_block(&lines[candidate.start_line..=candidate.end_line]);
match parsed {
Ok(metadata) => attachments.push(MetadataAttachment::Valid {
metadata,
block: candidate.span,
}),
Err(issue) => {
attachments.push(MetadataAttachment::Invalid(issue));
}
}
}
}
for candidate in &candidates {
if !peer_headers.contains(&candidate.span.end()) {
let issue = MetadataIssue::Misattached {
line: lines[candidate.start_line].number,
};
issues.push(issue);
}
}
(attachments, issues)
}
/// Render the canonical LF-terminated v1 block. The caller may adapt line endings
/// when inserting it into a document; this function performs no source mutation.
pub fn render_block(alias: &str, created_utc: Option<&str>) -> Result<String, MetadataError> {
validate_alias(alias).map_err(|_| MetadataError::InvalidAlias)?;
if let Some(timestamp) = created_utc
&& !valid_utc_timestamp(timestamp)
{
return Err(MetadataError::InvalidTimestamp);
}
let encoded = URL_SAFE_NO_PAD.encode(alias.as_bytes());
let mut block = format!("{BEGIN}\n{VERSION_PREFIX}1\n{ALIAS_PREFIX}{encoded}\n");
if let Some(timestamp) = created_utc {
block.push_str(CREATED_PREFIX);
block.push_str(timestamp);
block.push('\n');
}
block.push_str(END);
block.push('\n');
Ok(block)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub enum MetadataError {
#[error("client alias is invalid")]
InvalidAlias,
#[error("creation timestamp must be a valid UTC timestamp")]
InvalidTimestamp,
}
fn parse_block(lines: &[Line<'_>]) -> Result<ManagedMetadata, MetadataIssue> {
let first_line = lines.first().map_or(1, |line| line.number);
if lines.len() < 4 || lines[0].content != BEGIN || lines[lines.len() - 1].content != END {
return Err(MetadataIssue::MalformedBlock { line: first_line });
}
let version_line = &lines[1];
let Some(version_text) = version_line.content.strip_prefix(VERSION_PREFIX) else {
return Err(MetadataIssue::MissingField { line: first_line });
};
if version_text.is_empty() || !version_text.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(MetadataIssue::MalformedBlock {
line: version_line.number,
});
}
let version = version_text
.parse::<u32>()
.map_err(|_| MetadataIssue::MalformedBlock {
line: version_line.number,
})?;
if version != 1 {
return Err(MetadataIssue::UnsupportedVersion {
line: version_line.number,
});
}
let alias_line = &lines[2];
let Some(encoded_alias) = alias_line.content.strip_prefix(ALIAS_PREFIX) else {
return Err(MetadataIssue::MissingField {
line: alias_line.number,
});
};
let decoded =
URL_SAFE_NO_PAD
.decode(encoded_alias)
.map_err(|_| MetadataIssue::InvalidAlias {
line: alias_line.number,
})?;
if URL_SAFE_NO_PAD.encode(&decoded) != encoded_alias {
return Err(MetadataIssue::InvalidAlias {
line: alias_line.number,
});
}
let alias = String::from_utf8(decoded).map_err(|_| MetadataIssue::InvalidAlias {
line: alias_line.number,
})?;
validate_alias(&alias).map_err(|_| MetadataIssue::InvalidAlias {
line: alias_line.number,
})?;
let created_lines = &lines[3..lines.len() - 1];
let created_utc = match created_lines {
[] => None,
[line] => {
let Some(value) = line.content.strip_prefix(CREATED_PREFIX) else {
return Err(if line.content.starts_with("# wg-admin:") {
MetadataIssue::UnknownField { line: line.number }
} else {
MetadataIssue::MalformedBlock { line: line.number }
});
};
if !valid_utc_timestamp(value) {
return Err(MetadataIssue::InvalidTimestamp { line: line.number });
}
Some(value.to_owned())
}
[line, ..] => {
return Err(if line.content.starts_with("# wg-admin:") {
MetadataIssue::UnknownField { line: line.number }
} else {
MetadataIssue::MalformedBlock { line: line.number }
});
}
};
Ok(ManagedMetadata {
version,
alias,
created_utc,
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Marker {
Begin,
End,
}
fn marker(line: &str) -> Option<Marker> {
if line == BEGIN
|| line.starts_with(&format!("{BEGIN} "))
|| line.starts_with(&format!("{BEGIN}\t"))
{
Some(Marker::Begin)
} else if line == END
|| line.starts_with(&format!("{END} "))
|| line.starts_with(&format!("{END}\t"))
{
Some(Marker::End)
} else {
None
}
}
fn lines(source: &str) -> Vec<Line<'_>> {
let mut result = Vec::new();
let mut offset = 0;
for (index, raw) in source.split_inclusive('\n').enumerate() {
let end = offset + raw.len();
let content = raw.strip_suffix('\n').unwrap_or(raw);
let content = content.strip_suffix('\r').unwrap_or(content);
result.push(Line {
content,
start: offset,
end,
number: index + 1,
});
offset = end;
}
if source.is_empty() {
return result;
}
result
}
fn valid_utc_timestamp(value: &str) -> bool {
if value.len() != 20
|| !value.is_ascii()
|| value.as_bytes()[4] != b'-'
|| value.as_bytes()[7] != b'-'
|| value.as_bytes()[10] != b'T'
|| value.as_bytes()[13] != b':'
|| value.as_bytes()[16] != b':'
|| value.as_bytes()[19] != b'Z'
{
return false;
}
let digits = |range: std::ops::Range<usize>| value[range].parse::<u32>().ok();
let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = (
digits(0..4),
digits(5..7),
digits(8..10),
digits(11..13),
digits(14..16),
digits(17..19),
) else {
return false;
};
if year == 0 || !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
return false;
}
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
let days = match month {
2 if leap => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
};
(1..=days).contains(&day)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wireguard_metadata_round_trips_aliases_and_optional_timestamp() {
let alias = "# café = \"laptop\" <tag>";
for timestamp in [None, Some("2024-02-29T23:59:59Z")] {
let rendered = render_block(alias, timestamp).unwrap();
let header = rendered.len();
let (attachments, issues) = inspect_document(
&format!(
"{rendered}[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"
),
&[header],
);
assert!(issues.is_empty());
let MetadataAttachment::Valid { metadata, block } = &attachments[0] else {
panic!("canonical metadata should inspect successfully");
};
assert_eq!(metadata.alias, alias);
assert_eq!(metadata.created_utc.as_deref(), timestamp);
assert_eq!(block.end(), header);
assert_eq!(block.start(), 0);
}
}
#[test]
fn wireguard_metadata_rejects_control_aliases_and_invalid_utc_dates() {
assert_eq!(
render_block("bad\n# wg-admin:end", None),
Err(MetadataError::InvalidAlias)
);
for timestamp in [
"2023-02-29T12:00:00Z",
"2024-13-01T12:00:00Z",
"2024-01-01T24:00:00Z",
"2024-01-01T12:00:60Z",
"2024-01-01T12:00:00+00:00",
] {
assert_eq!(
render_block("ok", Some(timestamp)),
Err(MetadataError::InvalidTimestamp)
);
}
}
#[test]
fn wireguard_metadata_reports_unsupported_and_misattached_blocks_redacted() {
let source = "# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2s\n# wg-admin:end\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
let header = source.find("[Peer]").unwrap();
let (attachments, issues) = inspect_document(source, &[header]);
assert!(matches!(attachments[0], MetadataAttachment::Absent));
assert!(
issues
.iter()
.any(|issue| matches!(issue, MetadataIssue::Misattached { .. }))
);
assert!(
issues
.iter()
.any(|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }))
);
assert!(!format!("{issues:?}").contains("b2s"));
}
#[test]
fn wireguard_metadata_missing_end_marker_still_attaches_as_invalid() {
let source = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2s\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n";
let header = source.find("[Peer]").unwrap();
let (attachments, _) = inspect_document(source, &[header]);
assert!(matches!(
attachments[0],
MetadataAttachment::Invalid(MetadataIssue::MalformedBlock { .. })
));
}
#[test]
fn wireguard_metadata_ignores_unrelated_admin_comments_and_rejects_duplicate_blocks() {
let first = render_block("one", None).unwrap();
let second = render_block("two", None).unwrap();
let source = format!(
"# administrator note: wg-admin is not mentioned as an owner\n{first}{second}[Peer]\n"
);
let header = source.find("[Peer]").unwrap();
let (attachments, _) = inspect_document(&source, &[header]);
assert!(matches!(
attachments[0],
MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. })
));
let source = "# administrator note: wg-admin is not mentioned as an owner\n[Peer]\n";
let header = source.find("[Peer]").unwrap();
let (attachments, issues) = inspect_document(source, &[header]);
assert!(matches!(attachments[0], MetadataAttachment::Absent));
assert!(issues.is_empty());
}
}