Initial working version, lots of unwrap, etc.

Also contains some dev helpers.
This commit is contained in:
redxef 2024-01-21 22:51:32 +01:00
parent a62b3c37af
commit a273947c27
Signed by: redxef
GPG key ID: 7DAC3AA211CBD921
16 changed files with 1253 additions and 238 deletions

997
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,25 @@
name = "wgvirtipd" name = "wgvirtipd"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
build = "build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
cidr = { version = "0" } cidr = { version = "0", features = ["serde"] }
bytes = { version = "1" } bytes = { version = "1" }
serde = "1" serde = { version = "1", features = ["derive"] }
regex = "1"
tokio = { version = "1", features=["full"] } tokio = { version = "1", features=["full"] }
clap = { version = "4", features=["derive"] } clap = { version = "4", features=["derive"] }
hyper = "0"
warp = "0" warp = "0"
anyhow = "1.0.75"
serde_ini = { git = "https://github.com/devyn/serde-ini" }
url = { version = "2.5.0", features = ["serde"] }
serde_with = "3.5.0"
urlencoding = "2.1.3"
[build-dependencies]
chrono = "0.4.31"
git2 = "0.18.1"

22
build.rs Normal file
View file

@ -0,0 +1,22 @@
use git2::Repository;
use std::{env, fs, io::Write};
fn main() {
let outdir = env::var("OUT_DIR").unwrap();
let outfile = format!("{}/buildinfo.txt", outdir);
let repo = Repository::open(env::current_dir().unwrap()).unwrap();
let head = repo.head().unwrap();
let head_name = head.shorthand().unwrap();
let commit = head.peel_to_commit().unwrap();
let commit_id = commit.id();
let now = chrono::Local::now();
let mut fh = fs::File::create(&outfile).unwrap();
write!(
fh,
r#""wgvirtipd {} ({}) on {}""#,
commit_id, head_name, now
)
.ok();
}

1
dev/.gitignore vendored
View file

@ -1,2 +1,3 @@
config/vm*.conf config/vm*.conf
config/vm*.html
docker-compose.yaml docker-compose.yaml

3
dev/config/check.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
exit 0

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Test - {{ item.item }}</title>
</head>
<body>
<p>
This server is {{ item.item }} and reachable on wg0:{{ item.ip }}.
</p>
</body>
</html>

View file

@ -0,0 +1,65 @@
! Configuration File for keepalived
global_defs {
notification_email {
root@localhost
}
notification_email_from keepalived@localhost
smtp_server 127.0.0.1
smtp_connect_timeout 30
router_id LVS_DEVEL
vrrp_skip_check_adv_addr
vrrp_garp_interval 0
vrrp_gna_interval 0
}
vrrp_script check {
script /etc/keepalived/check.sh
interval 1
timeout 1
rise 5
fall 5
}
vrrp_instance VI_1 {
state {{ item.keepalived_state }}
interface wg0
priority {{ item.keepalived_priority }}
virtual_router_id 51
advert_int 1
virtual_ipaddress {
{{ keepalived_ip }}/{{ mask_bits }} dev wg0 label wg0:0
}
unicast_src_ip {{ item.ip }}
unicast_peer {
{% for iitem in keypairs %}
{% if iitem.item != item.item %}
{{ iitem.ip }}
{% endif %}
{% endfor %}
}
authentication {
auth_type PASS
auth_pass password
}
track_script {
check
}
notify_master /etc/keepalived/master.sh
}
virtual_server {{ keepalived_ip }} 8080 {
delay_loop 6
lb_algo rr
lb_kind NAT
protocol TCP
real_server {{ item.ip }} 8080 {
TCP_CHECK {
connect_timeout 10
}
}
}

2
dev/config/lighttpd.conf Normal file
View file

@ -0,0 +1,2 @@
server.document-root = "/var/www/"
server.port = 8080

3
dev/config/master.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
echo "$(date) I am now master" >> /var/log/_keepalived.log

View file

@ -8,7 +8,12 @@ services:
context: ./server/ context: ./server/
volumes: volumes:
- ./config/{{ item.item }}-wg0.conf:/etc/wireguard/wg0.conf - ./config/{{ item.item }}-wg0.conf:/etc/wireguard/wg0.conf
- ../target/x86_64-unknown-linux-musl/debug/wgvirtipd:/usr/local/bin/wgvirtipd - ./config/{{ item.item }}-keepalived.conf:/etc/keepalived/keepalived.conf
- ./config/check.sh:/etc/keepalived/check.sh
- ./config/master.sh:/etc/keepalived/master.sh
- ./config/lighttpd.conf:/etc/lighttpd/lighttpd.conf
- ./config/{{ item.item }}-index.html:/var/www/index.html
- ../target/x86_64-unknown-linux-musl/debug:/opt/wgvirtipd
networks: networks:
- default - default
expose: expose:

View file

@ -1,5 +1,10 @@
FROM alpine FROM alpine
RUN apk add --no-cache wireguard-tools-wg-quick RUN apk add --no-cache \
wireguard-tools-wg-quick \
keepalived \
lighttpd \
curl \
&& ln -s /opt/wgvirtipd/wgvirtipd /usr/local/bin/wgvirtipd
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ]

View file

@ -1,5 +1,7 @@
#!/bin/sh #!/bin/sh
lighthttpd_conf=/etc/lighttpd/lighttpd.conf
wg-quick up wg0 wg-quick up wg0
lighttpd -tt -f "$lighthttpd_conf" && lighttpd -f "$lighthttpd_conf" || true
"$@" "$@"

View file

@ -5,6 +5,7 @@
mask_bits: 24 mask_bits: 24
base_ip: 10.2.0.0 base_ip: 10.2.0.0
port: 51871 port: 51871
keepalived_ip: 10.2.0.100
tasks: tasks:
- name: generate keypair - name: generate keypair
shell: | shell: |
@ -13,11 +14,19 @@
pub="$(echo "$priv" | wg pubkey)" pub="$(echo "$priv" | wg pubkey)"
base_ip="{{ base_ip }}" base_ip="{{ base_ip }}"
my_ip="$(echo "$base_ip" | sed 's/0$/{{ item }}/')" my_ip="$(echo "$base_ip" | sed 's/0$/{{ item }}/')"
if [[ {{item}} -eq 1 ]]; then
state=MASTER
else
state=BACKUP
fi
priority=$((100 - {{ item }}))
jq --null-input \ jq --null-input \
--arg priv "$priv" \ --arg priv "$priv" \
--arg pub "$pub" \ --arg pub "$pub" \
--arg my_ip "$my_ip" \ --arg my_ip "$my_ip" \
'{"private_key": $priv, "public_key": $pub, "item": "vm{{ item }}", "ip": $my_ip}' --arg state "$state" \
--arg priority "$priority" \
'{"private_key": $priv, "public_key": $pub, "item": "vm{{ item }}", "ip": $my_ip, "keepalived_state": $state, "keepalived_priority": $priority}'
with_items: ["1", "2", "3", "4"] with_items: ["1", "2", "3", "4"]
register: keypairs_ register: keypairs_
- set_fact: - set_fact:
@ -30,6 +39,14 @@
src: ./config/wg0.conf.tmpl src: ./config/wg0.conf.tmpl
dest: ./config/{{ item.item }}-wg0.conf dest: ./config/{{ item.item }}-wg0.conf
with_items: "{{ keypairs }}" with_items: "{{ keypairs }}"
- template:
src: ./config/keepalived.conf.tmpl
dest: ./config/{{ item.item }}-keepalived.conf
with_items: "{{ keypairs }}"
- template:
src: ./config/index.html.tmpl
dest: ./config/{{ item.item }}-index.html
with_items: "{{ keypairs }}"
- template: - template:
src: ./docker-compose.yaml.tmpl src: ./docker-compose.yaml.tmpl
dest: ./docker-compose.yaml dest: ./docker-compose.yaml

68
src/event.rs Normal file
View file

@ -0,0 +1,68 @@
use std::str::FromStr;
use std::sync::Arc;
use cidr::IpInet;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
type WgLink = String;
type WgPeer = String;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct EventInfo {
link: WgLink,
cidr: IpInet,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum Event {
Add(EventInfo),
Del(EventInfo),
}
struct EventParseError<T>(T);
impl FromStr for Event {
type Err = EventParseError<&'static str>;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let re = regex::Regex::new(r"^(Deleted )?\d+: ([a-z0-9]+)\s+inet ((\d+\.?){4}(/\d+)?).*$")
.unwrap();
if let Some(captures) = re.captures(s) {
let is_add = captures.get(1).is_none();
let _is_delete = captures.get(1).is_some();
let link: WgLink = captures.get(2).unwrap().as_str().into();
let cidr: IpInet = IpInet::from_str(captures.get(3).unwrap().as_str()).unwrap();
if is_add {
Ok(Event::Add(EventInfo { link, cidr }))
} else {
Ok(Event::Del(EventInfo { link, cidr }))
}
} else {
Err(Self::Err {
0: "Line couldn't be parsed as event",
})
}
//if re_send_add.is_match(s) {
// send_add = true;
//} else if re_send_del.is_match(s) {
// send_del = true;
//}
//if send_add | send_del {
// cidr_ = re_extract_ip.captures(s).unwrap().get(1).unwrap().as_str();
// public_key = send_daemon_get_public_key(&iface).await;
//} else {
// return Err<Self::Err>;
//}
//if send_add {
// let request = hyper::Request::builder()
// .method(hyper::Method::PATCH)
// .uri(format!("http://{peer}:port/wireguard/{iface}/peer/{public_key}/address", peer=cidr_, iface=iface, public_key=public_key))
// .body(hyper::Body::empty()).unwrap();
// hyper::Client::new().request(request).await.unwrap();
//}
}
}
type EventStore = Arc<Mutex<Vec<Event>>>;

View file

@ -1,6 +1,14 @@
use clap::Parser; use clap::Parser;
use std::str::FromStr; use std::str::FromStr;
use tokio::io::{self, AsyncBufReadExt, BufReader};
use warp::Filter; use warp::Filter;
use wg::Peer;
mod event;
mod wg;
type WgLink = String;
type WgPeer = String;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
@ -8,12 +16,9 @@ struct CliArguments {
#[arg(short, long)] #[arg(short, long)]
addr: std::net::SocketAddr, addr: std::net::SocketAddr,
#[arg(short, long)] #[arg(short, long)]
link: String, link: WgLink,
} }
type WgLink = String;
type WgPeer = String;
#[derive(Debug)] #[derive(Debug)]
struct RejectCommandFailedToExecute; struct RejectCommandFailedToExecute;
impl warp::reject::Reject for RejectCommandFailedToExecute {} impl warp::reject::Reject for RejectCommandFailedToExecute {}
@ -154,9 +159,106 @@ async fn wg_del_address(
} }
} }
async fn send_daemon_get_public_key(iface: &WgLink) -> WgPeer {
let mut command = tokio::process::Command::new("wg");
command.arg("show").arg(&iface).arg("public-key");
println!("command = {:?}", command);
let output = command.output().await.unwrap();
std::str::from_utf8(output.stdout.as_ref())
.unwrap()
.trim_end_matches("\n")
.to_string()
}
async fn send_daemon(iface: WgLink, port: u16, peers: Vec<Peer>) -> () {
let re_send_add = regex::Regex::new(r"^\d+:.*\n$").unwrap();
let re_send_del = regex::Regex::new(r"^Deleted \d+:.*\n$").unwrap();
let re_extract_ip = regex::Regex::new(r"^.*inet ((\d+\.?){4}(/\d+)?).*\n$").unwrap();
let mut command = tokio::process::Command::new("ip");
command.arg("monitor").arg("address").arg("dev").arg(&iface);
command.stdout(std::process::Stdio::piped());
println!("{:?}", command);
let mut child = command.spawn().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
loop {
let mut buffer = String::new();
let mut send_add = false;
let mut send_del = false;
let cidr_: String;
let public_key;
reader.read_line(&mut buffer).await.unwrap();
if re_send_add.is_match(&buffer) {
send_add = true;
} else if re_send_del.is_match(&buffer) {
send_del = true;
}
if send_add | send_del {
cidr_ = urlencoding::encode(
re_extract_ip
.captures(&buffer)
.unwrap()
.get(1)
.unwrap()
.as_str(),
)
.into();
public_key = send_daemon_get_public_key(&iface).await;
} else {
continue;
}
if send_add {
for peer in peers.iter() {
let peer_ip = peer.allowed_ips.get(0).unwrap();
let u = format!(
"http://{peer}:{port}/wireguard/{iface}/peer/{public_key}/address",
peer = peer_ip,
port = port,
iface = iface,
public_key = public_key
);
println!("{:?}", u);
let request = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(&u)
.body(hyper::Body::from(cidr_.clone()))
.unwrap();
let r = hyper::Client::new().request(request).await;
println!("{:?}", r);
}
} else if send_del {
for peer in peers.iter() {
let peer_ip = peer.allowed_ips.get(0).unwrap();
let u = format!(
"http://{peer}:{port}/wireguard/{iface}/peer/{public_key}/address/{cidr}",
peer = peer_ip,
port = port,
iface = iface,
public_key = public_key,
cidr = &cidr_
);
println!("{:?}", u);
let request = hyper::Request::builder()
.method(hyper::Method::DELETE)
.uri(&u)
.body(hyper::Body::empty())
.unwrap();
let r = hyper::Client::new().request(request).await;
println!("{:?}", r);
}
}
}
}
const BUILD_INFO: &str = include!(concat!(env!("OUT_DIR"), "/buildinfo.txt"));
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args = CliArguments::parse(); let args = CliArguments::parse();
let config = wg::Wg::from_file(&args.link).await;
let base = warp::path("wireguard") let base = warp::path("wireguard")
.and(warp::path::param::<WgLink>()) .and(warp::path::param::<WgLink>())
@ -167,7 +269,13 @@ async fn main() {
.and(warp::path::end()) .and(warp::path::end())
.and(warp::post()) .and(warp::post())
.and(warp::body::bytes().map(|b: bytes::Bytes| { .and(warp::body::bytes().map(|b: bytes::Bytes| {
cidr::IpInet::from_str(std::str::from_utf8(b.as_ref()).unwrap().trim()).unwrap() cidr::IpInet::from_str(
urlencoding::decode(std::str::from_utf8(b.as_ref()).unwrap().trim())
.unwrap()
.to_string()
.as_str(),
)
.unwrap()
})) }))
.and_then(wg_add_address); .and_then(wg_add_address);
let address_del = base let address_del = base
@ -177,5 +285,10 @@ async fn main() {
.and_then(wg_del_address); .and_then(wg_del_address);
let routes = address_add.or(address_del); let routes = address_add.or(address_del);
warp::serve(routes).run(args.addr).await; println!("{}", BUILD_INFO);
let (r0, r1) = tokio::join!(
warp::serve(routes).run(args.addr),
send_daemon(args.link, args.addr.port(), config.peers),
);
} }

141
src/wg.rs Normal file
View file

@ -0,0 +1,141 @@
use cidr::IpInet;
use serde::de::{self, Error as _, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use serde_with::formats::CommaSeparator;
use serde_with::StringWithSeparator;
use serde_with::{serde_as, Map, Seq};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
fn de_one_many<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: serde::Deserialize<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneMany<T> {
One(T),
Vec(Vec<T>),
}
match OneMany::<T>::deserialize(deserializer)? {
OneMany::One(t) => Ok(vec![t]),
OneMany::Vec(v) => Ok(v),
}
}
fn de_one_many_none<'de, T, D>(deserializer: D) -> Result<Option<Vec<T>>, D::Error>
where
D: Deserializer<'de>,
T: serde::Deserialize<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneManyNone<T> {
None,
One(T),
Vec(Vec<T>),
}
match OneManyNone::<T>::deserialize(deserializer)? {
OneManyNone::None => Ok(None),
OneManyNone::One(t) => Ok(Some(vec![t])),
OneManyNone::Vec(v) => Ok(Some(v)),
}
}
fn de_host<'de, D>(deserializer: D) -> Result<(url::Host<String>, u16), D::Error>
where
D: Deserializer<'de>,
{
let v = String::deserialize(deserializer)?;
let u = url::Url::parse(format!("a://{}", v).as_ref()).unwrap();
let host = match u.host().unwrap() {
url::Host::Domain(s) => url::Host::Domain(String::from(s)),
url::Host::Ipv4(a) => url::Host::Ipv4(a),
url::Host::Ipv6(a) => url::Host::Ipv6(a),
};
let port = u.port().unwrap();
return Ok((host, port));
}
#[derive(Clone, Debug)]
#[serde_as]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Peer {
pub public_key: String,
pub preshared_key: Option<String>,
#[serde(rename = "AllowedIPs")]
#[serde_as(as = "StringWithSeparator::<CommaSeparator, IpInet>")]
pub allowed_ips: Vec<IpInet>,
#[serde(deserialize_with = "de_host")]
pub endpoint: (url::Host, u16),
pub persistent_keepalive: Option<u16>,
}
#[derive(Clone, Debug)]
#[serde_as]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct Interface {
pub private_key: String,
pub listen_port: Option<u16>,
pub fw_mark: Option<u32>,
// wg-quick
#[serde_as(as = "StringWithSeparator::<CommaSeparator, IpInet>")]
pub address: Vec<IpInet>, // TODO address can actually be left out with normal wg set link
#[serde(rename = "DNS")]
pub dns: Option<url::Host>,
#[serde(rename = "MTU")]
pub mtu: Option<u32>,
pub table: Option<String>,
pub pre_up: Option<String>,
pub post_up: Option<String>,
pub pre_down: Option<String>,
pub post_down: Option<String>,
pub save_config: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
enum PeerOrInterface {
Peer(Peer),
Interface(Interface),
}
type _Wg = Vec<PeerOrInterface>;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Wg {
pub interface: Interface,
pub peers: Vec<Peer>,
}
impl Wg {
pub fn from_str(s: &str) -> Self {
let w: _Wg = serde_ini::de::from_str(s).unwrap();
let mut interface: Option<Interface> = None;
let mut peers: Vec<Peer> = vec![];
for i in w {
match i {
PeerOrInterface::Interface(i) => interface = Some(i),
PeerOrInterface::Peer(p) => peers.push(p),
}
}
let interface = interface.unwrap();
Self { interface, peers }
}
pub async fn from_file(path: impl AsRef<Path>) -> Self {
let mut p: &Path = path.as_ref();
let mut new_p;
if p.parent() == Some(Path::new("")) {
new_p = PathBuf::new();
new_p.push("/etc/wireguard");
new_p.push(format!("{}.conf", p.to_str().unwrap()));
p = &new_p;
}
let s = tokio::fs::read_to_string(p).await.unwrap();
Self::from_str(&s)
}
}