Skip to content

Commit cfe4cee

Browse files
ytsssunKCSesh
authored andcommitted
apiserver: add network configure endpoint with netdog integration
Adds POST /actions/network/configure endpoint to write network configuration content directly to /.bottlerocket/net.toml. Includes error handling for UTF-8 validation, directory creation, and file writing operations. The endpoint integrates with netdog commit functionality to apply network configuration changes. Signed-off-by: Yutong Sun <yutongsu@amazon.com> Signed-off-by: Kyle Sessions <kssessio@amazon.com>
1 parent 0ba0d76 commit cfe4cee

File tree

4 files changed

+139
-50
lines changed

4 files changed

+139
-50
lines changed

sources/api/apiclient/src/network.rs

Lines changed: 59 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -30,64 +30,67 @@ pub async fn get_content<S>(input_source: Option<S>) -> Result<String>
3030
where
3131
S: Into<String>,
3232
{
33-
get_content_with_reader(input_source, tokio::io::stdin()).await
33+
match input_source {
34+
Some(source) => get_content_from_source(source.into()).await,
35+
None => get_content_with_stdin(tokio::io::stdin()).await,
36+
}
3437
}
3538

36-
/// Internal function that accepts a custom reader
37-
async fn get_content_with_reader<S, R>(input_source: Option<S>, mut reader: R) -> Result<String>
39+
/// Reads network configuration content from the provided reader.
40+
async fn get_content_with_stdin<R>(mut reader: R) -> Result<String>
3841
where
39-
S: Into<String>,
4042
R: tokio::io::AsyncRead + Unpin,
4143
{
42-
let input_source = match input_source {
43-
Some(source) => source.into(),
44-
// Read from provided reader when no input source is provided
45-
None => {
46-
let mut output = String::new();
47-
reader
48-
.read_to_string(&mut output)
49-
.await
50-
.context(error::StdinReadSnafu)?;
51-
return Ok(output);
52-
}
53-
};
44+
let mut output = String::new();
45+
reader
46+
.read_to_string(&mut output)
47+
.await
48+
.context(error::StdinReadSnafu)?;
49+
Ok(output)
50+
}
5451

52+
/// Retrieves network configuration content from a source URI.
53+
///
54+
/// Supports file:// and base64: URI schemes.
55+
async fn get_content_from_source(input_source: String) -> Result<String> {
5556
if let Some(base64_data) = input_source.strip_prefix("base64:") {
56-
let decoded_bytes = engine::general_purpose::STANDARD
57-
.decode(base64_data.as_bytes())
58-
.context(error::Base64DecodeSnafu {
59-
input_source: &input_source,
60-
})?;
61-
62-
let decoded_string = String::from_utf8(decoded_bytes).context(error::Base64Utf8Snafu {
63-
input_source: &input_source,
64-
})?;
65-
66-
return Ok(decoded_string);
57+
return get_content_from_base64(base64_data, &input_source);
6758
}
6859

6960
let uri = Url::parse(&input_source).context(error::UriSnafu {
7061
input_source: &input_source,
7162
})?;
7263

73-
if uri.scheme() == "file" {
74-
let path = uri.to_file_path().ok().context(error::FileUriSnafu {
75-
input_source: &input_source,
76-
})?;
77-
tokio::fs::read_to_string(path)
78-
.await
79-
.context(error::FileReadSnafu { input_source })
80-
} else {
81-
// Only file:// and base64: schemes are supported.
82-
// Expect this case to be updated when we expand the support for more schemes.
83-
error::UnsupportedUriSchemeSnafu {
64+
match uri.scheme() {
65+
"file" => get_content_from_file(uri, &input_source).await,
66+
scheme => error::UnsupportedUriSchemeSnafu {
8467
input_source: &input_source,
85-
scheme: uri.scheme(),
68+
scheme,
8669
}
87-
.fail()
70+
.fail(),
8871
}
8972
}
9073

74+
/// Decodes and returns content from a base64-encoded string.
75+
fn get_content_from_base64(base64_data: &str, input_source: &str) -> Result<String> {
76+
let decoded_bytes = engine::general_purpose::STANDARD
77+
.decode(base64_data.as_bytes())
78+
.context(error::Base64DecodeSnafu { input_source })?;
79+
80+
String::from_utf8(decoded_bytes).context(error::Base64Utf8Snafu { input_source })
81+
}
82+
83+
/// Reads content from a file URI.
84+
async fn get_content_from_file(uri: Url, input_source: &str) -> Result<String> {
85+
let path = uri
86+
.to_file_path()
87+
.ok()
88+
.context(error::FileUriSnafu { input_source })?;
89+
tokio::fs::read_to_string(path)
90+
.await
91+
.context(error::FileReadSnafu { input_source })
92+
}
93+
9194
mod error {
9295
use snafu::Snafu;
9396

@@ -162,11 +165,16 @@ mod tests {
162165
use std::io::Cursor;
163166

164167
// Given network config content to simulate stdin input
165-
let test_content = "version = 2\n\n[eth0]\ndhcp4 = true\nprimary = true\n";
168+
let test_content = r"version = 2
169+
170+
[eth0]
171+
dhcp4 = true
172+
primary = true
173+
";
166174
let mock_stdin = tokio::io::BufReader::new(Cursor::new(test_content.as_bytes()));
167175

168-
// When reading from mock stdin (None input source)
169-
let result = get_content_with_reader::<String, _>(None, mock_stdin).await;
176+
// When reading from mock stdin
177+
let result = get_content_with_stdin(mock_stdin).await;
170178

171179
// Then content should be read successfully
172180
assert!(result.is_ok());
@@ -181,7 +189,7 @@ mod tests {
181189
let mock_stdin = tokio::io::BufReader::new(Cursor::new(b""));
182190

183191
// When reading from empty mock stdin
184-
let result = get_content_with_reader::<String, _>(None, mock_stdin).await;
192+
let result = get_content_with_stdin(mock_stdin).await;
185193

186194
// Then should return empty string successfully
187195
assert!(result.is_ok());
@@ -192,13 +200,16 @@ mod tests {
192200
async fn test_stdin_vs_uri_behavior() {
193201
use std::io::Cursor;
194202

195-
let content = "version = 2\n\n[eth0]\ndhcp4 = true";
203+
let content = r"version = 2
204+
205+
[eth0]
206+
dhcp4 = true";
196207
let mock_stdin = tokio::io::BufReader::new(Cursor::new(content.as_bytes()));
197208

198-
// Test that None input reads from stdin
199-
let stdin_result = get_content_with_reader::<String, _>(None, mock_stdin).await;
209+
// Test that stdin reader works
210+
let stdin_result = get_content_with_stdin(mock_stdin).await;
200211

201-
// Test that Some input ignores stdin and uses URI
212+
// Test that Some input uses URI processing
202213
let uri_result =
203214
get_content(Some("base64:dmVyc2lvbiA9IDIKCltldGgwXQpkaGNwNCA9IHRydWU=")).await;
204215

sources/api/apiserver/src/server/error.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,18 @@ pub enum Error {
220220

221221
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
222222

223+
// Network configuration errors
224+
#[snafu(display("Network configuration content is not valid UTF-8"))]
225+
NetworkConfigContent { source: std::string::FromUtf8Error },
226+
227+
#[snafu(display("Failed to validate network configuration: {}", source))]
228+
NetworkConfigValidation { source: std::io::Error },
229+
230+
#[snafu(display("Invalid network configuration: {}", stderr))]
231+
NetworkConfigInvalid { stderr: String },
232+
233+
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
234+
223235
// Update related errors
224236
#[snafu(display("Unable to start the update dispatcher: {} ", source))]
225237
UpdateDispatcher { source: io::Error },

sources/api/apiserver/src/server/mod.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use actix_web::{
1414
use datastore::{serialize_scalar, Committed, FilesystemDataStore, Key, KeyType, Value};
1515
use error::Result;
1616
use http::StatusCode;
17-
use log::info;
17+
use log::{debug, info};
1818
use model::ephemeral_storage::{Bind, Init};
1919
use model::generator::{RawSettingsGenerator, Strength};
2020
use model::{ConfigurationFiles, Model, Report, Services, Settings};
@@ -24,6 +24,7 @@ use snafu::{ensure, OptionExt, ResultExt};
2424
use std::collections::{HashMap, HashSet};
2525
use std::env;
2626
use std::fs::{set_permissions, File, Permissions};
27+
use std::io::Write;
2728
use std::os::unix::fs::PermissionsExt;
2829
use std::os::unix::process::ExitStatusExt;
2930
use std::path::{Path, PathBuf};
@@ -36,7 +37,7 @@ use tokio::process::Command as AsyncCommand;
3637
const BLOODHOUND_BIN: &str = "/usr/bin/bloodhound";
3738
const BLOODHOUND_K8S_CHECKS: &str = "/usr/libexec/cis-checks/kubernetes";
3839
const BLOODHOUND_FIPS_CHECKS: &str = "/usr/libexec/fips-checks/bottlerocket";
39-
40+
const NETDOG_BIN: &str = "/usr/bin/netdog";
4041
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
4142

4243
// sd_notify helper
@@ -137,6 +138,7 @@ where
137138
.route("/prepare-update", web::post().to(prepare_update))
138139
.route("/activate-update", web::post().to(activate_update))
139140
.route("/deactivate-update", web::post().to(deactivate_update))
141+
.route("/network/configure", web::post().to(configure_network))
140142
.route(
141143
"/ephemeral-storage/init",
142144
web::post().to(initialize_ephemeral_storage),
@@ -687,6 +689,43 @@ async fn reboot() -> Result<HttpResponse> {
687689
Ok(HttpResponse::NoContent().finish())
688690
}
689691

692+
/// Configures network settings by invoking netdog commit.
693+
/// The configuration will be applied at next boot - a reboot is required for changes to take effect.
694+
async fn configure_network(body: web::Bytes) -> Result<HttpResponse> {
695+
debug!("Configuring network settings");
696+
697+
// Convert the body bytes to a UTF-8 string
698+
let content = String::from_utf8(body.to_vec()).context(error::NetworkConfigContentSnafu)?;
699+
700+
info!("Invoking netdog commit to validate and write network configuration");
701+
702+
// Validate and write net.toml using netdog commit
703+
let validation_result = std::process::Command::new(NETDOG_BIN)
704+
.arg("commit")
705+
.stdin(std::process::Stdio::piped())
706+
.stdout(std::process::Stdio::piped())
707+
.stderr(std::process::Stdio::piped())
708+
.spawn()
709+
.and_then(|mut child| {
710+
if let Some(stdin) = child.stdin.as_mut() {
711+
stdin.write_all(content.as_bytes())?;
712+
}
713+
child.wait_with_output()
714+
})
715+
.context(error::NetworkConfigValidationSnafu)?;
716+
717+
if !validation_result.status.success() {
718+
let stderr = String::from_utf8_lossy(&validation_result.stderr);
719+
error!(
720+
"netdog commit failed: network configuration validation failed: {}",
721+
stderr
722+
);
723+
return error::NetworkConfigInvalidSnafu { stderr }.fail();
724+
}
725+
726+
Ok(HttpResponse::NoContent().finish())
727+
}
728+
690729
/// Gets the set of report types supported by this host.
691730
async fn list_reports() -> Result<ReportListResponse> {
692731
// Add each report to list response when adding a new handler
@@ -965,6 +1004,9 @@ impl ResponseError for error::Error {
9651004
InvalidPrefix { .. } => StatusCode::BAD_REQUEST,
9661005
DeserializeJson { .. } => StatusCode::BAD_REQUEST,
9671006
InvalidKeyPair { .. } => StatusCode::BAD_REQUEST,
1007+
NetworkConfigContent { .. } => StatusCode::BAD_REQUEST,
1008+
NetworkConfigValidation { .. } => StatusCode::BAD_REQUEST,
1009+
NetworkConfigInvalid { .. } => StatusCode::BAD_REQUEST,
9681010

9691011
// 404 Not Found
9701012
MissingData { .. } => StatusCode::NOT_FOUND,

sources/api/openapi.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,30 @@ paths:
722722
423:
723723
description: "Update write lock held. Try again in a moment"
724724

725+
/actions/network/configure:
726+
post:
727+
summary: "Configure network settings from net.toml content"
728+
operationId: "configure_network"
729+
requestBody:
730+
required: true
731+
content:
732+
text/plain:
733+
schema:
734+
type: string
735+
example: |
736+
version = 2
737+
738+
[eth0]
739+
dhcp4 = true
740+
primary = true
741+
responses:
742+
204:
743+
description: "Network configuration successfully written"
744+
400:
745+
description: "Invalid UTF-8 content or malformed configuration"
746+
500:
747+
description: "Server error writing configuration"
748+
725749
/updates/status:
726750
get:
727751
summary: "Get update status"

0 commit comments

Comments
 (0)