added exit-node list, exit-node start and exit-node stop
This commit is contained in:
parent
0c045dd828
commit
9d0a5289ea
@ -1,5 +1,5 @@
|
|||||||
use hmac::{Hmac, Mac as HamcMac};
|
use hmac::{Hmac, Mac as HamcMac};
|
||||||
use punchnet::{TokenLogin, get_hostname};
|
use punchnet::{ExitNodeConfiguration, TokenLogin, get_hostname};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use sdlan_sn_rs::utils::{Mac, Result, SDLanError};
|
use sdlan_sn_rs::utils::{Mac, Result, SDLanError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -104,7 +104,7 @@ pub struct LoginData {
|
|||||||
// pub exit_node: Vec<ExitNode>,
|
// pub exit_node: Vec<ExitNode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct ExitNode{
|
pub struct ExitNode{
|
||||||
pub node_id: u32,
|
pub node_id: u32,
|
||||||
pub node_name: String,
|
pub node_name: String,
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
use std::{net::SocketAddr, sync::atomic::Ordering, time::Duration};
|
use std::{net::SocketAddr, sync::atomic::Ordering, time::Duration};
|
||||||
|
|
||||||
|
use ipnet::Ipv4Net;
|
||||||
use num_enum::TryFromPrimitive;
|
use num_enum::TryFromPrimitive;
|
||||||
use punchnet::get_edge;
|
use punchnet::{LocalConfigInfo, RouteInfo, get_edge, load_configuration, set_route_from_net, store_configuration};
|
||||||
use sdlan_sn_rs::utils::{Mac, Result, SDLanError};
|
use sdlan_sn_rs::{config, utils::{Mac, Result, SDLanError}};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::{net::UdpSocket, time::sleep};
|
use tokio::{net::UdpSocket, time::sleep};
|
||||||
|
|
||||||
@ -18,14 +19,39 @@ pub enum InfoFuncCode {
|
|||||||
Info = 0x00,
|
Info = 0x00,
|
||||||
// query for exit node list,
|
// query for exit node list,
|
||||||
ExitNodeList = 0x01,
|
ExitNodeList = 0x01,
|
||||||
|
// set exit node
|
||||||
|
ExitNodeSet = 0x02,
|
||||||
|
ExitNodeStop = 0x03,
|
||||||
|
|
||||||
InfoFeedback = 0x80,
|
InfoFeedback = 0x80,
|
||||||
ExitNodeListFeedback = 0x81,
|
ExitNodeListFeedback = 0x81,
|
||||||
|
ExitNodeSetFeedback = 0x82,
|
||||||
|
ExitNodeStopFeedback = 0x83,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct SingleExitNodeInfo {
|
||||||
|
pub in_use: bool,
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub gw: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ExitNodeSetFeedback {
|
||||||
|
pub ok: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ExitNodeSetQuery {
|
||||||
|
pub id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ExitNodeListFeedback {
|
pub struct ExitNodeListFeedback {
|
||||||
pub nodes: Vec<String>,
|
pub nodes: Vec<SingleExitNodeInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@ -92,13 +118,54 @@ pub async fn do_query<T: serde::de::DeserializeOwned>(data_type: u8, data: &[u8]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn query_for_exit_node_stop() {
|
||||||
|
let result = do_query::<ExitNodeSetFeedback>(InfoFuncCode::ExitNodeStop as u8, &[], Duration::from_secs(3)).await;
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
if data.ok {
|
||||||
|
println!("set exit-node ok");
|
||||||
|
} else {
|
||||||
|
println!("failed to set exit-node: {}", data.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("failed to set exit-node: {}", e.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn query_for_exit_node_set(nodeid: u32) {
|
||||||
|
let data = ExitNodeSetQuery {
|
||||||
|
id: nodeid,
|
||||||
|
};
|
||||||
|
let data = serde_json::to_vec(&data).unwrap();
|
||||||
|
let result = do_query::<ExitNodeSetFeedback>(InfoFuncCode::ExitNodeSet as u8, &data, Duration::from_secs(3)).await;
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
if data.ok {
|
||||||
|
println!("set exit-node ok");
|
||||||
|
} else {
|
||||||
|
println!("failed to set exit-node: {}", data.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("failed to set exit-node: {}", e.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn query_for_exit_node_list() {
|
pub async fn query_for_exit_node_list() {
|
||||||
let result = do_query::<ExitNodeListFeedback>(InfoFuncCode::ExitNodeList as u8, &[], Duration::from_secs(3)).await;
|
let result = do_query::<ExitNodeListFeedback>(InfoFuncCode::ExitNodeList as u8, &[], Duration::from_secs(3)).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
println!("exit nodes:");
|
println!("exit nodes:");
|
||||||
for node in &data.nodes {
|
for node in &data.nodes {
|
||||||
println!(" {}", node);
|
if node.in_use {
|
||||||
|
println!(" * id: {}, name: {}, gw: {}", node.id, node.name, node.gw);
|
||||||
|
} else {
|
||||||
|
println!(" id: {}, name: {}, gw: {}", node.id, node.name, node.gw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -154,7 +221,7 @@ pub async fn handle_query_for_info_info() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_query_info(udp: &UdpSocket, buf: &[u8], from: SocketAddr) {
|
async fn handle_query_info(udp: &UdpSocket, buf: &[u8], from: SocketAddr) {
|
||||||
if buf.len() < 1 {
|
if buf.len() < 3 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let tp = buf[0];
|
let tp = buf[0];
|
||||||
@ -163,16 +230,139 @@ async fn handle_query_info(udp: &UdpSocket, buf: &[u8], from: SocketAddr) {
|
|||||||
eprintln!("invalid type: {}", tp);
|
eprintln!("invalid type: {}", tp);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let size_field = u16::from_be_bytes([buf[1], buf[2]]);
|
||||||
|
if size_field as usize + 3 != buf.len() {
|
||||||
|
// error!("got invalid size");
|
||||||
|
eprintln!("got invalid size");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match typecode {
|
match typecode {
|
||||||
InfoFuncCode::Info => {
|
InfoFuncCode::Info => {
|
||||||
send_info_back(udp, from).await;
|
send_info_back(udp, from).await;
|
||||||
}
|
}
|
||||||
|
InfoFuncCode::ExitNodeList => {
|
||||||
|
send_exit_node_back(udp, from).await;
|
||||||
|
}
|
||||||
|
InfoFuncCode::ExitNodeSet => {
|
||||||
|
if let Ok(data) = serde_json::from_slice::<ExitNodeSetQuery>(&buf[3..]) {
|
||||||
|
handle_set_exit_node(udp, from, data).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InfoFuncCode::ExitNodeStop => {
|
||||||
|
println!("got exit node stop");
|
||||||
|
handle_stop_exit_node(udp, from).await;
|
||||||
|
}
|
||||||
_other => {
|
_other => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_exit_node_back(udp: &UdpSocket, from: SocketAddr) {
|
||||||
|
let config = load_configuration();
|
||||||
|
|
||||||
|
let mut temp = Vec::new();
|
||||||
|
for (id, node) in &config.exit_node {
|
||||||
|
temp.push(SingleExitNodeInfo {
|
||||||
|
name: node.node_name.clone(),
|
||||||
|
gw: node.gateway.clone(),
|
||||||
|
id: node.node_id,
|
||||||
|
in_use: node.in_use,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let res = ExitNodeListFeedback {
|
||||||
|
nodes: temp
|
||||||
|
};
|
||||||
|
let value = serde_json::to_string(&res).unwrap();
|
||||||
|
|
||||||
|
let mut content = Vec::with_capacity(value.len() + 3);
|
||||||
|
content.push(InfoFuncCode::ExitNodeListFeedback as u8);
|
||||||
|
|
||||||
|
let size = value.len() as u16;
|
||||||
|
let size_buf = size.to_be_bytes();
|
||||||
|
|
||||||
|
content.extend_from_slice(&size_buf);
|
||||||
|
content.extend_from_slice(value.as_bytes());
|
||||||
|
udp.send_to(content.as_slice(), from).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_set_exit_node_inner(data: u32) -> std::result::Result<LocalConfigInfo, String>{
|
||||||
|
let configuration = load_configuration();
|
||||||
|
|
||||||
|
let Some(data) = configuration.exit_node.get(&data) else {
|
||||||
|
return Err("no such id found".to_string());
|
||||||
|
};
|
||||||
|
let Ok(ip) = data.gateway.parse() else {
|
||||||
|
return Err("no such id found".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let net = "0.0.0.0/0".parse::<Ipv4Net>().unwrap();
|
||||||
|
set_route_from_net(vec![RouteInfo{
|
||||||
|
net,
|
||||||
|
gw: ip,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return Ok(configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn _handle_set_exit_node(udp: &UdpSocket, from: SocketAddr, data:Option<ExitNodeSetQuery>, result_code: InfoFuncCode) {
|
||||||
|
let result = if data.is_none() {
|
||||||
|
let mut configuration = load_configuration();
|
||||||
|
for (node, value) in configuration.exit_node.iter_mut() {
|
||||||
|
value.in_use = false;
|
||||||
|
}
|
||||||
|
store_configuration(&configuration);
|
||||||
|
set_route_from_net(vec![]);
|
||||||
|
|
||||||
|
ExitNodeSetFeedback {
|
||||||
|
ok: true,
|
||||||
|
message: "ok".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let data = data.unwrap();
|
||||||
|
let result = match handle_set_exit_node_inner(data.id) {
|
||||||
|
Ok(mut configuration) => {
|
||||||
|
if let Some(conf) = configuration.exit_node.get_mut(&data.id) {
|
||||||
|
conf.in_use = true;
|
||||||
|
}
|
||||||
|
store_configuration(&configuration);
|
||||||
|
ExitNodeSetFeedback {
|
||||||
|
ok: true,
|
||||||
|
message: "ok".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(estr) => {
|
||||||
|
ExitNodeSetFeedback {
|
||||||
|
ok: false,
|
||||||
|
message: estr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_string(&result).unwrap();
|
||||||
|
|
||||||
|
let mut content = Vec::with_capacity(value.len() + 3);
|
||||||
|
content.push(result_code as u8);
|
||||||
|
|
||||||
|
let size = value.len() as u16;
|
||||||
|
let size_buf = size.to_be_bytes();
|
||||||
|
|
||||||
|
content.extend_from_slice(&size_buf);
|
||||||
|
content.extend_from_slice(value.as_bytes());
|
||||||
|
udp.send_to(content.as_slice(), from).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_stop_exit_node(udp: &UdpSocket, from: SocketAddr) {
|
||||||
|
_handle_set_exit_node(udp, from, None, InfoFuncCode::ExitNodeStopFeedback).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_set_exit_node(udp: &UdpSocket, from: SocketAddr, data:ExitNodeSetQuery) {
|
||||||
|
_handle_set_exit_node(udp, from, Some(data), InfoFuncCode::ExitNodeSetFeedback).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_info_back(udp: &UdpSocket, from: SocketAddr) {
|
async fn send_info_back(udp: &UdpSocket, from: SocketAddr) {
|
||||||
let edge = get_edge();
|
let edge = get_edge();
|
||||||
let ip = edge.device_config.get_ip();
|
let ip = edge.device_config.get_ip();
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod local_udp_info;
|
mod local_udp_info;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use punchnet::ExitNodeConfiguration;
|
||||||
|
use punchnet::store_configuration;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
@ -50,6 +53,8 @@ use crate::api::LoginResponse;
|
|||||||
use crate::api::TEST_PREFIX;
|
use crate::api::TEST_PREFIX;
|
||||||
use crate::local_udp_info::handle_query_for_info_info;
|
use crate::local_udp_info::handle_query_for_info_info;
|
||||||
use crate::local_udp_info::query_for_exit_node_list;
|
use crate::local_udp_info::query_for_exit_node_list;
|
||||||
|
use crate::local_udp_info::query_for_exit_node_set;
|
||||||
|
use crate::local_udp_info::query_for_exit_node_stop;
|
||||||
use crate::local_udp_info::query_for_info;
|
use crate::local_udp_info::query_for_info;
|
||||||
|
|
||||||
const APP_USER_ENV_NAME: &str = "PUNCH_USER";
|
const APP_USER_ENV_NAME: &str = "PUNCH_USER";
|
||||||
@ -347,8 +352,12 @@ fn main() {
|
|||||||
ExitNodeCmd::List => {
|
ExitNodeCmd::List => {
|
||||||
query_for_exit_node_list().await;
|
query_for_exit_node_list().await;
|
||||||
}
|
}
|
||||||
ExitNodeCmd::Start(info) => {}
|
ExitNodeCmd::Start(info) => {
|
||||||
ExitNodeCmd::Stop => {}
|
query_for_exit_node_set(info.id).await;
|
||||||
|
}
|
||||||
|
ExitNodeCmd::Stop => {
|
||||||
|
query_for_exit_node_stop().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
process::exit(0);
|
process::exit(0);
|
||||||
@ -425,7 +434,7 @@ fn main() {
|
|||||||
run_it(cmd, client_id, allow_routing, mac, system, version);
|
run_it(cmd, client_id, allow_routing, mac, system, version);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("failed to daemonize");
|
eprintln!("failed to daemonize: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -436,6 +445,24 @@ fn main() {
|
|||||||
run_it(cmd, client_id, allow_routing, mac, system, version);
|
run_it(cmd, client_id, allow_routing, mac, system, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_exit_node(connect_info: &ConnectData) {
|
||||||
|
let mut local_configuration = load_configuration();
|
||||||
|
let mut temp = HashMap::new();
|
||||||
|
for node in &connect_info.exit_node {
|
||||||
|
temp.insert(node.node_id, ExitNodeConfiguration {
|
||||||
|
in_use: false,
|
||||||
|
node_id: node.node_id,
|
||||||
|
node_name: node.node_name.clone(),
|
||||||
|
gateway: node.gateway.clone(),
|
||||||
|
target_network: node.target_network.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
local_configuration.exit_node = temp;
|
||||||
|
if let Err(e) = store_configuration(&local_configuration) {
|
||||||
|
error!("failed to store configuration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run_it(
|
fn run_it(
|
||||||
cmd: CommandLineInput2,
|
cmd: CommandLineInput2,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
@ -456,6 +483,8 @@ fn run_it(
|
|||||||
let connect_info = parse_connect_result(
|
let connect_info = parse_connect_result(
|
||||||
connect(TEST_PREFIX, &client_id, &remembered.access_token).await,
|
connect(TEST_PREFIX, &client_id, &remembered.access_token).await,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
record_exit_node(&connect_info);
|
||||||
daemonize_me(
|
daemonize_me(
|
||||||
rtinfo.allow_routing || allow_routing,
|
rtinfo.allow_routing || allow_routing,
|
||||||
connect_info,
|
connect_info,
|
||||||
@ -490,6 +519,8 @@ fn run_it(
|
|||||||
let connect_info = parse_connect_result(
|
let connect_info = parse_connect_result(
|
||||||
connect(TEST_PREFIX, &client_id, &remembered.access_token).await,
|
connect(TEST_PREFIX, &client_id, &remembered.access_token).await,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
record_exit_node(&connect_info);
|
||||||
daemonize_me(
|
daemonize_me(
|
||||||
tk.allow_routing || allow_routing,
|
tk.allow_routing || allow_routing,
|
||||||
connect_info,
|
connect_info,
|
||||||
|
|||||||
@ -874,7 +874,7 @@ pub fn get_install_channel() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn check_has_resolvectl() -> bool {
|
fn check_has_resolvectl() -> bool {
|
||||||
return false;
|
// return false;
|
||||||
let res = Command::new("resolvectl").arg("status").output();
|
let res = Command::new("resolvectl").arg("status").output();
|
||||||
if let Ok(_) = res {
|
if let Ok(_) = res {
|
||||||
true
|
true
|
||||||
@ -912,7 +912,8 @@ fn add_resolvectl(name: &str, network_domain: &str) -> Result<()> {
|
|||||||
if !Command::new("resolvectl")
|
if !Command::new("resolvectl")
|
||||||
.arg("domain")
|
.arg("domain")
|
||||||
.arg(name)
|
.arg(name)
|
||||||
.arg(format!("~{}", network_domain))
|
// .arg(format!("~{}", network_domain))
|
||||||
|
.arg("~.")
|
||||||
.output()?.status.success() {
|
.output()?.status.success() {
|
||||||
error!("failed to run resolvectl domain");
|
error!("failed to run resolvectl domain");
|
||||||
return Err(SDLanError::IOError("failed to resolvectl domain".to_owned()))
|
return Err(SDLanError::IOError("failed to resolvectl domain".to_owned()))
|
||||||
|
|||||||
@ -49,8 +49,8 @@ pub enum ExitNodeCmd {
|
|||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct ExitNodeInfo {
|
pub struct ExitNodeInfo {
|
||||||
#[arg(short, long, default_value="")]
|
#[arg(short, long)]
|
||||||
pub name: String,
|
pub id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
use std::path::Path;
|
use std::{collections::HashMap, path::Path, sync::atomic::AtomicBool};
|
||||||
|
|
||||||
use sdlan_sn_rs::utils::{Mac, Result};
|
use sdlan_sn_rs::utils::{Mac, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{generate_mac_address, get_base_dir};
|
use crate::{ExitNodeInfo, generate_mac_address, get_base_dir};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct ExitNodeConfiguration {
|
||||||
|
pub in_use: bool,
|
||||||
|
pub node_id: u32,
|
||||||
|
pub node_name: String,
|
||||||
|
pub gateway: String,
|
||||||
|
pub target_network: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct LocalConfigInfo {
|
pub struct LocalConfigInfo {
|
||||||
pub allow_routing: Option<bool>,
|
pub allow_routing: Option<bool>,
|
||||||
pub mac: Option<[u8; 6]>
|
pub mac: Option<[u8; 6]>,
|
||||||
|
pub exit_node: HashMap<u32, ExitNodeConfiguration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn store_configuration(config: &LocalConfigInfo) -> Result<()> {
|
pub fn store_configuration(config: &LocalConfigInfo) -> Result<()> {
|
||||||
@ -52,6 +61,7 @@ pub fn load_configuration() -> LocalConfigInfo {
|
|||||||
let config = LocalConfigInfo {
|
let config = LocalConfigInfo {
|
||||||
allow_routing: Some(false),
|
allow_routing: Some(false),
|
||||||
mac,
|
mac,
|
||||||
|
exit_node: HashMap::new(),
|
||||||
};
|
};
|
||||||
let _ = store_configuration(&config);
|
let _ = store_configuration(&config);
|
||||||
config
|
config
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user