added simple API + PAM authorization

This commit is contained in:
2026-03-28 17:59:02 +03:00
parent b24ab91860
commit 52337e390d
10 changed files with 1755 additions and 10 deletions

85
src/api/auth.rs Normal file
View File

@@ -0,0 +1,85 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use pam::Client;
use serde::{Deserialize, Serialize};
use crate::auth::{issue_token, AuthState};
#[derive(Debug, Deserialize)]
pub(crate) struct LoginRequest {
username: String,
password: String,
}
#[derive(Debug, Serialize)]
struct TokenResponse {
token: String,
token_type: String,
expires_in: i64,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: String,
}
pub async fn login(
State(state): State<AuthState>,
Json(payload): Json<LoginRequest>,
) -> impl IntoResponse {
if payload.username.trim().is_empty() || payload.password.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "username/password required".to_string(),
}),
)
.into_response();
}
let service = state.pam_service.clone();
let username = payload.username.clone();
let password = payload.password.clone();
let pam_result = tokio::task::spawn_blocking(move || {
let mut client = Client::with_password(&service)
.map_err(|e| format!("pam init failed: {}", e))?;
client
.conversation_mut()
.set_credentials(&username, &password);
client
.authenticate()
.map_err(|e| format!("pam auth failed: {}", e))?;
Ok::<(), String>(())
})
.await
.map_err(|e| format!("pam task failed: {}", e));
match pam_result {
Ok(Ok(())) => {
let (token, ttl) = match issue_token(&payload.username, &state) {
Ok(v) => v,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response();
}
};
Json(TokenResponse {
token,
token_type: "Bearer".to_string(),
expires_in: ttl,
})
.into_response()
}
_ => (
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "invalid credentials".to_string(),
}),
)
.into_response(),
}
}

710
src/api/ip.rs Normal file
View File

@@ -0,0 +1,710 @@
use axum::{Json, response::IntoResponse, http::StatusCode, extract::Path};
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use tokio::process::Command;
#[derive(Debug, Serialize, Deserialize)]
pub struct IpAddrInfo {
pub interfaces: Vec<InterfaceInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InterfaceInfo {
pub name: String,
pub index: u32,
pub state: String,
pub mac_address: Option<String>,
pub ipv4_addresses: Vec<Ipv4Address>,
pub ipv6_addresses: Vec<Ipv6Address>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Ipv4Address {
pub address: String,
pub prefix_len: u8,
pub scope: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Ipv6Address {
pub address: String,
pub prefix_len: u8,
pub scope: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IpRouteInfo {
pub routes: Vec<RouteEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RouteEntry {
pub destination: Option<String>,
pub gateway: Option<String>,
pub device: String,
pub metric: Option<u32>,
pub route_type: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IpLinkInfo {
pub links: Vec<LinkInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LinkInfo {
pub index: u32,
pub name: String,
pub state: String,
pub mac_address: Option<String>,
pub mtu: u32,
pub qdisc: Option<String>,
pub link_type: String,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: String,
}
#[derive(Debug, Serialize)]
struct OkResponse {
status: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum AddrAction {
Add,
Del,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum RouteAction {
Add,
Del,
Replace,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum IpFamily {
Inet,
Inet6,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LinkMtuRequest {
pub mtu: u32,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AddrRequest {
pub action: AddrAction,
pub dev: String,
pub address: String,
pub prefix_len: u8,
pub family: Option<IpFamily>,
pub scope: Option<String>,
pub label: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RouteRequest {
pub action: RouteAction,
pub destination: String,
pub gateway: Option<String>,
pub dev: Option<String>,
pub metric: Option<u32>,
pub table: Option<String>,
pub proto: Option<String>,
pub scope: Option<String>,
}
pub async fn get_ip_addr() -> impl IntoResponse {
handle_ip("addr").await
}
pub async fn get_ip_route() -> impl IntoResponse {
handle_ip("route").await
}
pub async fn get_ip_link() -> impl IntoResponse {
handle_ip("link").await
}
pub async fn set_link_up(Path(ifname): Path<String>) -> impl IntoResponse {
handle_link_state(&ifname, "up").await
}
pub async fn set_link_down(Path(ifname): Path<String>) -> impl IntoResponse {
handle_link_state(&ifname, "down").await
}
pub async fn set_link_mtu(
Path(ifname): Path<String>,
Json(payload): Json<LinkMtuRequest>,
) -> impl IntoResponse {
if let Err(e) = validate_ifname(&ifname) {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
if payload.mtu < 68 || payload.mtu > 65535 {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "mtu must be between 68 and 65535".to_string(),
}),
)
.into_response();
}
let args = vec![
"link".to_string(),
"set".to_string(),
"dev".to_string(),
ifname,
"mtu".to_string(),
payload.mtu.to_string(),
];
match run_ip_cmd(&args).await {
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
pub async fn addr_modify(Json(payload): Json<AddrRequest>) -> impl IntoResponse {
if let Err(e) = validate_ifname(&payload.dev) {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
let family = payload.family.unwrap_or(IpFamily::Inet);
let max_prefix = match family {
IpFamily::Inet => 32,
IpFamily::Inet6 => 128,
};
if payload.prefix_len > max_prefix {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("prefix_len must be <= {}", max_prefix),
}),
)
.into_response();
}
let mut args: Vec<String> = Vec::new();
match family {
IpFamily::Inet => args.push("-4".to_string()),
IpFamily::Inet6 => args.push("-6".to_string()),
}
args.push("addr".to_string());
args.push(match payload.action {
AddrAction::Add => "add".to_string(),
AddrAction::Del => "del".to_string(),
});
args.push(format!("{}/{}", payload.address, payload.prefix_len));
args.push("dev".to_string());
args.push(payload.dev);
if let Some(scope) = payload.scope {
args.push("scope".to_string());
args.push(scope);
}
if let Some(label) = payload.label {
args.push("label".to_string());
args.push(label);
}
match run_ip_cmd(&args).await {
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
pub async fn route_modify(Json(payload): Json<RouteRequest>) -> impl IntoResponse {
if let Some(dev) = payload.dev.as_ref() {
if let Err(e) = validate_ifname(dev) {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
}
if payload.gateway.is_none() && payload.dev.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "either gateway or dev must be provided".to_string(),
}),
)
.into_response();
}
let mut args: Vec<String> = Vec::new();
args.push("route".to_string());
args.push(match payload.action {
RouteAction::Add => "add".to_string(),
RouteAction::Del => "del".to_string(),
RouteAction::Replace => "replace".to_string(),
});
args.push(payload.destination);
if let Some(gw) = payload.gateway {
args.push("via".to_string());
args.push(gw);
}
if let Some(dev) = payload.dev {
args.push("dev".to_string());
args.push(dev);
}
if let Some(metric) = payload.metric {
args.push("metric".to_string());
args.push(metric.to_string());
}
if let Some(table) = payload.table {
args.push("table".to_string());
args.push(table);
}
if let Some(proto) = payload.proto {
args.push("proto".to_string());
args.push(proto);
}
if let Some(scope) = payload.scope {
args.push("scope".to_string());
args.push(scope);
}
match run_ip_cmd(&args).await {
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
async fn handle_ip(subcmd: &str) -> impl IntoResponse {
match execute_ip_cmd(subcmd).await {
Ok(data) => Json(data).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
async fn handle_link_state(ifname: &str, state: &str) -> impl IntoResponse {
if let Err(e) = validate_ifname(ifname) {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
let args = vec![
"link".to_string(),
"set".to_string(),
"dev".to_string(),
ifname.to_string(),
state.to_string(),
];
match run_ip_cmd(&args).await {
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
async fn run_ip_cmd(args: &[String]) -> Result<(), String> {
let output = Command::new("ip")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to execute ip {}: {}", args.join(" "), e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ip {} failed: {}", args.join(" "), stderr));
}
Ok(())
}
fn validate_ifname(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("ifname must not be empty".to_string());
}
if name.len() > 64 {
return Err("ifname is слишком длинное".to_string());
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' ))
{
return Err("ifname содержит недопустимые символы".to_string());
}
Ok(())
}
async fn execute_ip_cmd(subcmd: &str) -> Result<serde_json::Value, String> {
// Prefer native JSON output from iproute2 for a complete and robust payload.
let mut json_errors: Vec<String> = Vec::new();
match execute_ip_cmd_json(subcmd, true).await {
Ok(value) => return Ok(value),
Err(e) => json_errors.push(e),
}
// Some ip versions support JSON but not -details.
match execute_ip_cmd_json(subcmd, false).await {
Ok(value) => return Ok(value),
Err(e) => json_errors.push(e),
}
// Fallback to legacy text parsing for older ip versions without JSON support.
match execute_ip_cmd_legacy(subcmd).await {
Ok(value) => Ok(value),
Err(legacy_err) => {
let mut msg = String::new();
if !json_errors.is_empty() {
msg.push_str(&format!("JSON attempts failed: {}; ", json_errors.join(" | ")));
}
msg.push_str(&format!("legacy parsing failed: {}", legacy_err));
Err(msg)
}
}
}
async fn execute_ip_cmd_json(subcmd: &str, details: bool) -> Result<serde_json::Value, String> {
let mut args: Vec<&str> = vec!["-j"];
if details {
args.push("-details");
}
args.push(subcmd);
let output = Command::new("ip")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to execute ip -j {}: {}", subcmd, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ip -j {} failed: {}", subcmd, stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str::<serde_json::Value>(&stdout)
.map_err(|e| format!("Failed to parse ip -j {} output: {}", subcmd, e))
}
async fn execute_ip_cmd_legacy(subcmd: &str) -> Result<serde_json::Value, String> {
let output = Command::new("ip")
.arg(subcmd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to execute ip {}: {}", subcmd, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("ip {} failed: {}", subcmd, stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let result = match subcmd {
"addr" => parse_ip_addr(&stdout)?,
"route" => parse_ip_route(&stdout)?,
"link" => parse_ip_link(&stdout)?,
_ => return Err(format!("Unknown subcommand: {}", subcmd)),
};
Ok(result)
}
fn parse_ip_addr(output: &str) -> Result<serde_json::Value, String> {
let mut interfaces = Vec::new();
let mut current_interface: Option<InterfaceInfo> = None;
for line in output.lines() {
let line = line.trim();
// Начало описания интерфейса: "1: lo: <LOOPBACK> ..."
if let Some(idx) = line.split(':').next() {
if let Ok(index) = idx.parse::<u32>() {
if let Some(iface) = current_interface.take() {
interfaces.push(iface);
}
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 3 {
let name = parts[1].trim().trim_start_matches('@').to_string();
let state = if line.contains("<UP>") || line.contains("UP") {
"UP"
} else if line.contains("DOWN") {
"DOWN"
} else {
"UNKNOWN"
}.to_string();
current_interface = Some(InterfaceInfo {
name,
index,
state,
mac_address: None,
ipv4_addresses: Vec::new(),
ipv6_addresses: Vec::new(),
});
}
continue;
}
}
if let Some(ref mut iface) = current_interface {
// MAC адрес: "link/ether aa:bb:cc:dd:ee:ff"
if line.starts_with("link/") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[1] != "none" {
iface.mac_address = Some(parts[1].to_string());
}
continue;
}
// IPv4 адрес: "inet 192.168.1.1/24 brd ..."
if line.starts_with("inet ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let addr_parts: Vec<&str> = parts[1].split('/').collect();
if addr_parts.len() == 2 {
if let Ok(prefix) = addr_parts[1].parse::<u8>() {
let scope = if let Some(idx) = line.find("scope") {
line[idx..].split_whitespace().nth(1).unwrap_or("global").to_string()
} else {
"global".to_string()
};
iface.ipv4_addresses.push(Ipv4Address {
address: addr_parts[0].to_string(),
prefix_len: prefix,
scope,
});
}
}
}
continue;
}
// IPv6 адрес: "inet6 ::1/128 scope ..."
if line.starts_with("inet6 ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let addr_parts: Vec<&str> = parts[1].split('/').collect();
if addr_parts.len() == 2 {
if let Ok(prefix) = addr_parts[1].parse::<u8>() {
let scope = if let Some(idx) = line.find("scope") {
line[idx..].split_whitespace().nth(1).unwrap_or("global").to_string()
} else {
"global".to_string()
};
iface.ipv6_addresses.push(Ipv6Address {
address: addr_parts[0].to_string(),
prefix_len: prefix,
scope,
});
}
}
}
continue;
}
}
}
if let Some(iface) = current_interface {
interfaces.push(iface);
}
Ok(serde_json::to_value(IpAddrInfo { interfaces }).unwrap())
}
fn parse_ip_route(output: &str) -> Result<serde_json::Value, String> {
let mut routes = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
let mut route = RouteEntry {
destination: None,
gateway: None,
device: String::new(),
metric: None,
route_type: "unicast".to_string(),
};
let mut i = 0;
while i < parts.len() {
match parts[i] {
"default" => {
route.destination = Some("default".to_string());
}
"via" => {
if i + 1 < parts.len() {
route.gateway = Some(parts[i + 1].to_string());
i += 1;
}
}
"dev" => {
if i + 1 < parts.len() {
route.device = parts[i + 1].to_string();
i += 1;
}
}
"metric" => {
if i + 1 < parts.len() {
route.metric = parts[i + 1].parse::<u32>().ok();
i += 1;
}
}
"src" | "proto" | "scope" | "onlink" => {
// Пропускаем эти параметры
}
_ => {
if route.destination.is_none() && !["via", "dev", "metric", "src", "proto", "scope", "onlink"].contains(&parts[i]) {
route.destination = Some(parts[i].to_string());
}
}
}
i += 1;
}
if !route.device.is_empty() {
routes.push(route);
}
}
Ok(serde_json::to_value(IpRouteInfo { routes }).unwrap())
}
fn parse_ip_link(output: &str) -> Result<serde_json::Value, String> {
let mut links = Vec::new();
let mut current_link: Option<LinkInfo> = None;
for line in output.lines() {
let line = line.trim();
if let Some(idx) = line.split(':').next() {
if let Ok(index) = idx.parse::<u32>() {
if let Some(link) = current_link.take() {
links.push(link);
}
let parts: Vec<&str> = line.split(':').collect();
let name = if parts.len() >= 3 {
parts[1].trim().trim_start_matches('@').to_string()
} else {
"unknown".to_string()
};
let state = if line.contains("<UP>") || line.contains("UP") {
"UP"
} else if line.contains("DOWN") {
"DOWN"
} else {
"UNKNOWN"
}.to_string();
current_link = Some(LinkInfo {
index,
name,
state,
mac_address: None,
mtu: 1500,
qdisc: None,
link_type: "unknown".to_string(),
});
continue;
}
}
if let Some(ref mut link) = current_link {
// link/ether или link/type
if line.starts_with("link/") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
link.link_type = parts[0].trim_start_matches("link/").to_string();
if parts[1] != "none" {
link.mac_address = Some(parts[1].to_string());
}
}
continue;
}
// mtu
if line.starts_with("mtu ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
link.mtu = parts[1].parse::<u32>().unwrap_or(1500);
}
continue;
}
// qdisc
if let Some(idx) = line.find("qdisc ") {
let rest = &line[idx + 6..];
if let Some(qdisc) = rest.split_whitespace().next() {
link.qdisc = Some(qdisc.to_string());
}
continue;
}
}
}
if let Some(link) = current_link {
links.push(link);
}
Ok(serde_json::to_value(IpLinkInfo { links }).unwrap())
}

3
src/api/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod ip;
pub mod nft;
pub mod auth;

463
src/api/nft.rs Normal file
View File

@@ -0,0 +1,463 @@
use axum::{
extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: String,
}
#[derive(Debug, Serialize)]
struct OkResponse {
status: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum NftFamily {
Ip,
Ip6,
Inet,
Arp,
Bridge,
Netdev,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum TableAction {
Add,
Del,
Flush,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ChainAction {
Add,
Del,
Flush,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum RuleAction {
Add,
Insert,
Del,
}
#[derive(Debug, Deserialize)]
pub(crate) struct TableRequest {
pub action: TableAction,
pub family: NftFamily,
pub table: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ChainRequest {
pub action: ChainAction,
pub family: NftFamily,
pub table: String,
pub chain: String,
#[serde(rename = "type")]
pub chain_type: Option<String>,
pub hook: Option<String>,
pub priority: Option<i32>,
pub policy: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RuleRequest {
pub action: RuleAction,
pub family: NftFamily,
pub table: String,
pub chain: String,
pub expr: Option<String>,
pub handle: Option<u64>,
pub position: Option<u32>,
}
pub async fn get_nft_ruleset() -> impl IntoResponse {
handle_nft_list(vec!["list".to_string(), "ruleset".to_string()]).await
}
pub async fn get_nft_tables() -> impl IntoResponse {
handle_nft_list(vec!["list".to_string(), "tables".to_string()]).await
}
pub async fn get_nft_chains() -> impl IntoResponse {
handle_nft_list(vec!["list".to_string(), "chains".to_string()]).await
}
pub async fn get_nft_rules(Path((family, table, chain)): Path<(String, String, String)>) -> impl IntoResponse {
let family = match normalize_family(&family) {
Ok(v) => v,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
};
if let Err(e) = validate_ident(&table, "table") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
if let Err(e) = validate_ident(&chain, "chain") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
handle_nft_list(vec![
"list".to_string(),
"chain".to_string(),
family,
table,
chain,
])
.await
}
pub async fn table_modify(Json(payload): Json<TableRequest>) -> impl IntoResponse {
if let Err(e) = validate_ident(&payload.table, "table") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
let family = family_to_str(&payload.family);
let cmd = match payload.action {
TableAction::Add => format!("add table {} {}", family, payload.table),
TableAction::Del => format!("delete table {} {}", family, payload.table),
TableAction::Flush => format!("flush table {} {}", family, payload.table),
};
match run_nft_script(&cmd).await {
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
pub async fn chain_modify(Json(payload): Json<ChainRequest>) -> impl IntoResponse {
if let Err(e) = validate_ident(&payload.table, "table") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
if let Err(e) = validate_ident(&payload.chain, "chain") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
if let Some(ref t) = payload.chain_type {
if let Err(e) = validate_ident(t, "type") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
}
if let Some(ref h) = payload.hook {
if let Err(e) = validate_ident(h, "hook") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
}
if let Some(ref p) = payload.policy {
if let Err(e) = validate_ident(p, "policy") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
}
let family = family_to_str(&payload.family);
let base = match payload.action {
ChainAction::Add => "add chain",
ChainAction::Del => "delete chain",
ChainAction::Flush => "flush chain",
};
let mut cmd = format!("{} {} {} {}", base, family, payload.table, payload.chain);
if let ChainAction::Add = payload.action {
if let Some(block) = build_chain_block(&payload) {
cmd.push(' ');
cmd.push_str(&block);
}
}
match run_nft_script(&cmd).await {
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
pub async fn rule_modify(Json(payload): Json<RuleRequest>) -> impl IntoResponse {
if let Err(e) = validate_ident(&payload.table, "table") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
if let Err(e) = validate_ident(&payload.chain, "chain") {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
if let Some(ref expr) = payload.expr {
if let Err(e) = validate_expr(expr) {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: e }),
)
.into_response();
}
}
let family = family_to_str(&payload.family);
let cmd = match payload.action {
RuleAction::Add => {
let expr = match payload.expr {
Some(e) => e,
None => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: "expr is required for add".to_string() }),
)
.into_response();
}
};
format!("add rule {} {} {} {}", family, payload.table, payload.chain, expr)
}
RuleAction::Insert => {
let expr = match payload.expr {
Some(e) => e,
None => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: "expr is required for insert".to_string() }),
)
.into_response();
}
};
if let Some(pos) = payload.position {
format!(
"insert rule {} {} {} position {} {}",
family, payload.table, payload.chain, pos, expr
)
} else {
format!("insert rule {} {} {} {}", family, payload.table, payload.chain, expr)
}
}
RuleAction::Del => {
let handle = match payload.handle {
Some(h) => h,
None => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: "handle is required for del".to_string() }),
)
.into_response();
}
};
format!(
"delete rule {} {} {} handle {}",
family, payload.table, payload.chain, handle
)
}
};
match run_nft_script(&cmd).await {
Ok(()) => Json(OkResponse { status: "ok".to_string() }).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
async fn handle_nft_list(args: Vec<String>) -> Response {
match run_nft_json(&args).await {
Ok(data) => Json(data).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
.into_response(),
}
}
async fn run_nft_json(args: &[String]) -> Result<serde_json::Value, String> {
let output = Command::new("nft")
.arg("-j")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to execute nft -j {}: {}", args.join(" "), e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("nft -j {} failed: {}", args.join(" "), stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str::<serde_json::Value>(&stdout)
.map_err(|e| format!("Failed to parse nft -j output: {}", e))
}
async fn run_nft_script(cmd: &str) -> Result<(), String> {
let mut child = Command::new("nft")
.arg("-f")
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to execute nft: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(cmd.as_bytes())
.await
.map_err(|e| format!("Failed to write nft stdin: {}", e))?;
stdin
.write_all(b"\n")
.await
.map_err(|e| format!("Failed to write nft stdin: {}", e))?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| format!("Failed to wait for nft: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("nft failed: {}", stderr));
}
Ok(())
}
fn family_to_str(family: &NftFamily) -> &'static str {
match family {
NftFamily::Ip => "ip",
NftFamily::Ip6 => "ip6",
NftFamily::Inet => "inet",
NftFamily::Arp => "arp",
NftFamily::Bridge => "bridge",
NftFamily::Netdev => "netdev",
}
}
fn normalize_family(value: &str) -> Result<String, String> {
let v = value.to_ascii_lowercase();
match v.as_str() {
"ip" | "ip6" | "inet" | "arp" | "bridge" | "netdev" => Ok(v),
_ => Err("unknown family".to_string()),
}
}
fn validate_ident(value: &str, field: &str) -> Result<(), String> {
if value.is_empty() {
return Err(format!("{} must not be empty", field));
}
if value.len() > 64 {
return Err(format!("{} is слишком длинное", field));
}
if !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' ))
{
return Err(format!("{} содержит недопустимые символы", field));
}
Ok(())
}
fn validate_expr(expr: &str) -> Result<(), String> {
if expr.trim().is_empty() {
return Err("expr must not be empty".to_string());
}
if expr.contains('\n') || expr.contains('\r') {
return Err("expr must be single-line".to_string());
}
if expr.contains(';') {
return Err("expr must not contain ';'".to_string());
}
Ok(())
}
fn build_chain_block(req: &ChainRequest) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if req.chain_type.is_some() || req.hook.is_some() || req.priority.is_some() {
let mut line = Vec::new();
if let Some(ref t) = req.chain_type {
line.push(format!("type {}", t));
}
if let Some(ref h) = req.hook {
line.push(format!("hook {}", h));
}
if let Some(p) = req.priority {
line.push(format!("priority {}", p));
}
if !line.is_empty() {
parts.push(format!("{};", line.join(" ")));
}
}
if let Some(ref p) = req.policy {
parts.push(format!("policy {};", p));
}
if parts.is_empty() {
None
} else {
Some(format!("{{ {} }}", parts.join(" ")))
}
}

140
src/auth.rs Normal file
View File

@@ -0,0 +1,140 @@
use axum::{
extract::State,
body::Body,
http::{header::AUTHORIZATION, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::Utc;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
#[derive(Clone)]
pub struct AuthState {
pub secret: String,
pub token_ttl_seconds: i64,
pub pam_service: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub iat: usize,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: String,
}
pub fn issue_token(username: &str, state: &AuthState) -> Result<(String, i64), String> {
let now = Utc::now().timestamp();
let ttl = if state.token_ttl_seconds > 0 {
state.token_ttl_seconds
} else {
3600
};
let exp = now + ttl;
let claims = Claims {
sub: username.to_string(),
iat: now as usize,
exp: exp as usize,
};
let payload = serde_json::to_vec(&claims)
.map_err(|e| format!("token encode failed: {}", e))?;
let signature = sign(&payload, state.secret.as_bytes())?;
let token = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(payload),
URL_SAFE_NO_PAD.encode(signature)
);
Ok((token, ttl))
}
pub fn validate_token(token: &str, state: &AuthState) -> Result<Claims, String> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err("invalid token format".to_string());
}
let payload = URL_SAFE_NO_PAD
.decode(parts[0].as_bytes())
.map_err(|_| "invalid token payload".to_string())?;
let signature = URL_SAFE_NO_PAD
.decode(parts[1].as_bytes())
.map_err(|_| "invalid token signature".to_string())?;
verify(&payload, &signature, state.secret.as_bytes())?;
let claims: Claims = serde_json::from_slice(&payload)
.map_err(|_| "invalid token payload".to_string())?;
let now = Utc::now().timestamp() as usize;
if claims.exp <= now {
return Err("token expired".to_string());
}
Ok(claims)
}
pub async fn auth_middleware(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let auth_header = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let token = match auth_header {
Some(value) if value.starts_with("Bearer ") => &value[7..],
_ => {
return (
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "missing or invalid Authorization header".to_string(),
}),
)
.into_response();
}
};
match validate_token(token, &state) {
Ok(claims) => {
req.extensions_mut().insert(claims);
next.run(req).await
}
Err(_) => (
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "invalid or expired token".to_string(),
}),
)
.into_response(),
}
}
fn sign(payload: &[u8], secret: &[u8]) -> Result<Vec<u8>, String> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret)
.map_err(|_| "invalid secret".to_string())?;
mac.update(payload);
Ok(mac.finalize().into_bytes().to_vec())
}
fn verify(payload: &[u8], signature: &[u8], secret: &[u8]) -> Result<(), String> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret)
.map_err(|_| "invalid secret".to_string())?;
mac.update(payload);
mac.verify_slice(signature)
.map_err(|_| "invalid token signature".to_string())?;
Ok(())
}

View File

@@ -6,6 +6,14 @@ pub struct Config {
pub host: String,
pub port: u16,
pub debug: bool,
pub auth: AuthConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthConfig {
pub secret: String,
pub token_ttl_seconds: i64,
pub pam_service: String,
}
impl Config {

View File

@@ -1,5 +1,6 @@
use axum::{
routing::get,
routing::{get, post},
middleware,
Router,
response::{Html, IntoResponse},
};
@@ -11,6 +12,9 @@ use crate::config::Config;
mod utils;
use utils::logger;
mod api;
mod auth;
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
@@ -48,10 +52,48 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = format!("{}:{}", cfg.host, cfg.port);
logger::success(format!("Running on {}", addr), "Core");
let auth_state = auth::AuthState {
secret: cfg.auth.secret.clone(),
token_ttl_seconds: cfg.auth.token_ttl_seconds,
pam_service: cfg.auth.pam_service.clone(),
};
let protected_api = Router::new()
// IP API
.route("/ip/addr", get(api::ip::get_ip_addr))
.route("/ip/addr", post(api::ip::addr_modify))
.route("/ip/route", get(api::ip::get_ip_route))
.route("/ip/route", post(api::ip::route_modify))
.route("/ip/link", get(api::ip::get_ip_link))
.route("/ip/link/:ifname/up", post(api::ip::set_link_up))
.route("/ip/link/:ifname/down", post(api::ip::set_link_down))
.route("/ip/link/:ifname/mtu", post(api::ip::set_link_mtu))
// nftables API
.route("/nft/ruleset", get(api::nft::get_nft_ruleset))
.route("/nft/tables", get(api::nft::get_nft_tables))
.route("/nft/chains", get(api::nft::get_nft_chains))
.route(
"/nft/rules/:family/:table/:chain",
get(api::nft::get_nft_rules),
)
.route("/nft/table", post(api::nft::table_modify))
.route("/nft/chain", post(api::nft::chain_modify))
.route("/nft/rule", post(api::nft::rule_modify))
.route_layer(middleware::from_fn_with_state(
auth_state.clone(),
auth::auth_middleware,
));
let api = Router::new()
.route("/auth/login", post(api::auth::login))
.merge(protected_api);
// Build our application with routes
let app = Router::new()
.route("/", get(index))
.route("/prefs", get(about));
.route("/prefs", get(about))
.nest("/api", api)
.with_state(auth_state);
// Запускаем сервер
let listener = tokio::net::TcpListener::bind(&addr).await?;