mirror of
https://github.com/tonarino/innernet.git
synced 2024-11-21 17:56:29 +02:00
kabloomers. public release v1.0.0
This commit is contained in:
commit
c49f061bb7
74
.github/workflows/release-artifacts.yml
vendored
Normal file
74
.github/workflows/release-artifacts.yml
vendored
Normal file
@ -0,0 +1,74 @@
|
||||
name: "Upload Release Artifacts"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Creating environment variables
|
||||
run: echo "release_version=${git_ref#v}" >> $GITHUB_ENV
|
||||
env:
|
||||
git_ref: ${{ github.ref }}
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
ACCEPT_EULA: Y
|
||||
run: sudo apt-get -y update && sudo apt-get install -f && sudo apt-get -y install libsqlite3-dev libclang-9-dev
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cargo/git
|
||||
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo build
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: target
|
||||
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Cache cargo bin
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cargo/bin
|
||||
key: ${{ runner.os }}-cargo-bin-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: build
|
||||
args: --verbose
|
||||
- name: Test
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --verbose
|
||||
- name: Install cargo-deb (if missing)
|
||||
run: |
|
||||
which cargo-deb || cargo install cargo-deb
|
||||
- name: Build Debian Server Package
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: deb
|
||||
args: -p server
|
||||
- name: Build Debian Client Package
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: deb
|
||||
args: -p client
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: target/debian/*.deb
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
41
.github/workflows/rust.yml
vendored
Normal file
41
.github/workflows/rust.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
name: Rust
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
- name: Install Dependencies (if Ubuntu)
|
||||
env:
|
||||
ACCEPT_EULA: Y
|
||||
run: sudo apt-get -y update && sudo apt-get install -f && sudo apt-get -y install libsqlite3-dev libclang-9-dev
|
||||
if: contains(runner.os, 'Linux')
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-v1
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: build
|
||||
args: --verbose
|
||||
- name: Test
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --verbose
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
target/
|
||||
*.db
|
||||
*.conf
|
||||
arch/pkg
|
||||
arch/innernet-git
|
1666
Cargo.lock
generated
Normal file
1666
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
Cargo.toml
Normal file
6
Cargo.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[workspace]
|
||||
members = ["server", "client", "hostsfile", "shared"]
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 tonari株式会社, 一般社団法人tonari
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
178
README.md
Normal file
178
README.md
Normal file
@ -0,0 +1,178 @@
|
||||
# innernet
|
||||
|
||||
A private network system that uses [WireGuard](https://wireguard.com) under the hood. See the [announcement blog post](https://blog.tonari.no/introducing-innernet) for a longer-winded explanation.
|
||||
|
||||
`innernet` is similar in its goals to Slack's [nebula](https://github.com/slackhq/nebula) or [Tailscale](https://tailscale.com/), but takes a bit of a different approach. It aims to take advantage of existing networking concepts like CIDRs and the security properties of WireGuard to turn your computer's basic IP networking into more powerful ACL primitives.
|
||||
|
||||
`innernet` is not an official WireGuard project, and WireGuard is a registered trademark of Jason A. Donenfeld.
|
||||
|
||||
This has not received an independent security audit, and should be considered experimental software at this early point in its lifetime.
|
||||
|
||||
## Usage
|
||||
|
||||
### Server Creation
|
||||
|
||||
Every `innernet` network needs a coordination server to manage peers and provide endpoint information so peers can contact each other. Create a new one with
|
||||
|
||||
```sh
|
||||
sudo innernet-server new
|
||||
```
|
||||
|
||||
The init wizard will ask you questions about your network and give you some reasonable defaults. It's good to familiarize yourself with [network CIDRs](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) as a lot of innernet's access control is based upon them. As an example, let's say the root CIDR for this network is `10.60.0.0/16`. Server initialization creates a special "infra" CIDR which contains the `innernet` server itself and is reachable from all CIDRs on the network.
|
||||
|
||||
Next we'll also create a `humans` CIDR where we can start adding some peers.
|
||||
|
||||
```sh
|
||||
sudo innernet-server add-cidr <interface>
|
||||
```
|
||||
|
||||
For the parent CIDR, you can simply choose your network's root CIDR. The name will be `humans`, and the CIDR will be `10.60.64.0/24` (not a great example unless you only want to support 256 humans, but it works for now...).
|
||||
|
||||
By default, peers which exist in this new CIDR will only be able to contact peers in the same CIDR, and the special "infra" CIDR which was created when the server was initialized.
|
||||
|
||||
A typical workflow for creating a new network is to create an admin peer from the `innernet-server` CLI, and then continue using that admin peer via the `innernet` client CLI to add any further peers or network CIDRs.
|
||||
|
||||
```sh
|
||||
sudo innernet-server add-peer <interface>
|
||||
```
|
||||
|
||||
Select the `humans` CIDR, and the CLI will automatically suggest the next available IP address. Any name is fine, just answer "yes" when asked if you would like to make the peer an admin. The process of adding a peer results in an invitation file. This file contains just enough information for the new peer to contact the `innernet` server and redeem its invitation. It should be transferred securely to the new peer, and it can only be used once to initialize the peer.
|
||||
|
||||
You can run the server with `innernet-server serve <interface>`, or if you're on Linux and want to run it via `systemctl`, run `systemctl enable --now innernet-server@<interface>`. If you're on a home network, don't forget to configure port forwarding to the `Listen Port` you specified when creating the `innernet` server.
|
||||
|
||||
### Peer Initialization
|
||||
|
||||
Let's assume the invitation file generated in the steps above have been transferred to the machine a network admin will be using.
|
||||
|
||||
You can initialize the client with
|
||||
|
||||
```sh
|
||||
sudo inn install /path/to/invitation.toml
|
||||
```
|
||||
|
||||
You can customize the network name if you want to, or leave it at the default. `innernet` will then connect to the `innernet` server via WireGuard, generate a new key pair, and register that pair with the server. The private key in the invitation file can no longer be used.
|
||||
|
||||
If everything was successful, the new peer is on the network. You can run things like
|
||||
|
||||
```sh
|
||||
sudo inn list
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```sh
|
||||
sudo inn list --tree
|
||||
```
|
||||
|
||||
to view the current network and all CIDRs visible to this peer.
|
||||
|
||||
Since we created an admin peer, we can also add new peers and CIDRs from this peer via `innernet` instead of having to always run commands on the server.
|
||||
|
||||
### Adding Associations between CIDRs
|
||||
|
||||
In order for peers from one CIDR to be able to contact peers in another CIDR, those two CIDRs must be "associated" with each other.
|
||||
|
||||
With the admin peer we created above, let's add a new CIDR for some theoretical CI servers we have.
|
||||
|
||||
```sh
|
||||
sudo inn add-cidr <interface>
|
||||
```
|
||||
|
||||
The name is `ci-servers` and the CIDR is `10.60.64.0/24`, but for this example it can be anything.
|
||||
|
||||
For now, we want peers in the `humans` CIDR to be able to access peers in the `ci-servers` CIDR.
|
||||
|
||||
```sh
|
||||
sudo inn add-association <interface>
|
||||
```
|
||||
|
||||
The CLI will ask you to select the two CIDRs you want to associate. That's all it takes to allow peers in two different CIDRs to communicate!
|
||||
|
||||
You can verify the association with
|
||||
|
||||
```sh
|
||||
sudo inn list-associations <interface>
|
||||
```
|
||||
|
||||
and associations can be deleted with
|
||||
|
||||
```sh
|
||||
sudo inn delete-associations <interface>
|
||||
```
|
||||
|
||||
### Enabling/Disabling Peers
|
||||
|
||||
For security reasons, IP addresses cannot be re-used by new peers, and therefore peers cannot be deleted. However, they can be disabled. Disabled peers will not show up in the list of peers when fetching the config for an interface.
|
||||
|
||||
Disable a peer with
|
||||
|
||||
```su
|
||||
sudo inn disable-peer <interface>
|
||||
```
|
||||
|
||||
Or re-enable a peer with
|
||||
|
||||
```su
|
||||
sudo inn enable-peer <interface>
|
||||
```
|
||||
|
||||
### Specifying a Manual Endpoint
|
||||
|
||||
The `innernet` server will try to use the internet endpoint it sees from a peer so other peers can connect to that peer as well. This doesn't always work and you may want to set an endpoint explicitly. To set an endpoint, use
|
||||
|
||||
```sh
|
||||
sudo inn override-endpoint <interface>
|
||||
```
|
||||
|
||||
You can go back to automatic endpoint discovery with
|
||||
|
||||
```sh
|
||||
sudo inn override-endpoint -u <interface>
|
||||
```
|
||||
|
||||
### Setting the Local WireGuard Listen Port
|
||||
|
||||
If you want to change the port which WireGuard listens on, use
|
||||
|
||||
```sh
|
||||
sudo inn set-listen-port <interface>
|
||||
```
|
||||
|
||||
or unset the port and use a randomized port with
|
||||
|
||||
```sh
|
||||
sudo innernet set-listen-port -u <interface>
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Arch
|
||||
|
||||
```sh
|
||||
yay -S innernet
|
||||
```
|
||||
|
||||
### Ubuntu
|
||||
|
||||
Fetch the appropriate `.deb` packages from
|
||||
https://github.com/tonarino/innernet/releases and install with
|
||||
|
||||
```sh
|
||||
sudo apt install ./innernet*.deb
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```sh
|
||||
./macos/install.sh
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Releases
|
||||
|
||||
1. Run `cargo release [--dry-run] [{]minor|major|patch|...]` to automatically bump the crates appropriately.
|
||||
2. Create a new git tag (ex. `v0.6.0`).
|
||||
3. Push (with tags) to the repo.
|
||||
|
||||
innernet uses GitHub Actions to automatically produce a debian package for the [releases page](https://github.com/tonarino/innernet/releases).
|
3
arch/.gitignore
vendored
Normal file
3
arch/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
wg-manage-git/
|
||||
src/
|
||||
*.pkg.*
|
52
arch/PKGBUILD
Normal file
52
arch/PKGBUILD
Normal file
@ -0,0 +1,52 @@
|
||||
# Maintainer: Jake McGinty <jake@tonari.no>
|
||||
|
||||
# TODO(mcginty): Eventually move this to AUR once this is released publicly.
|
||||
|
||||
pkgname=innernet-git
|
||||
pkgver=v0.12.7.r5.493d2b0
|
||||
pkgrel=1
|
||||
pkgdesc="A tool to manage WireGuard network topologies."
|
||||
#epoch=0
|
||||
arch=('x86_64')
|
||||
url="https://github.com/tonarino/innernet"
|
||||
license=('custom')
|
||||
depends=('sqlite')
|
||||
makedepends=('git' 'cargo')
|
||||
source=("$pkgname::git+ssh://git@github.com/tonarino/innernet")
|
||||
sha1sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
local tag=$(git tag --sort=-v:refname | grep '^v[0-9]' | head -1)
|
||||
local commits_since=$(git rev-list $tag..HEAD --count)
|
||||
echo "$tag.r$commits_since.$(git log --pretty=format:'%h' -n 1)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
|
||||
cargo build --release --locked
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname"
|
||||
|
||||
cargo test --release --locked
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
install -Dm755 "target/release/innernet" "$pkgdir/usr/bin/innernet"
|
||||
install -Dm755 "target/release/innernet-server" "$pkgdir/usr/bin/innernet-server"
|
||||
ln -s "innernet" "$pkgdir/usr/bin/inn"
|
||||
|
||||
install -Dm644 "client/innernet@.service" "$pkgdir/usr/lib/systemd/system/innernet@.service"
|
||||
install -Dm644 "server/innernet-server@.service" "$pkgdir/usr/lib/systemd/system/innernet-server@.service"
|
||||
|
||||
install -Dm644 "doc/innernet.8.gz" "$pkgdir/usr/share/man/man8/innernet.8.gz"
|
||||
install -Dm644 "doc/innernet-server.8.gz" "$pkgdir/usr/share/man/man8/innernet-server.8.gz"
|
||||
}
|
||||
|
||||
# vim:set ts=2 sw=2 et:
|
||||
|
1
client/.gitignore
vendored
Normal file
1
client/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
47
client/Cargo.toml
Normal file
47
client/Cargo.toml
Normal file
@ -0,0 +1,47 @@
|
||||
[package]
|
||||
authors = ["Jake McGinty <jake@tonari.no>"]
|
||||
description = "A client to manage innernet network interfaces."
|
||||
edition = "2018"
|
||||
homepage = "https://github.com/tonarino/innernet"
|
||||
repository = "https://github.com/tonarino/innernet"
|
||||
license = "MIT"
|
||||
name = "client"
|
||||
publish = false
|
||||
version = "1.0.0"
|
||||
|
||||
[[bin]]
|
||||
name = "innernet"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
colored = "2"
|
||||
dialoguer = "0.8"
|
||||
hostsfile = { path = "../hostsfile" }
|
||||
indoc = "1"
|
||||
ipnetwork = { git = "https://github.com/mcginty/ipnetwork" }
|
||||
lazy_static = "1"
|
||||
libc = "0.2"
|
||||
regex = { version = "1", default-features = false, features = ["std"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
shared = { path = "../shared", default-features = false }
|
||||
structopt = "0.3"
|
||||
ureq = { version = "2", default-features = false, features = ["json"] }
|
||||
wgctrl = { path = "../wgctrl-rs" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[package.metadata.deb]
|
||||
assets = [
|
||||
["target/release/innernet", "usr/bin/", "755"],
|
||||
["target/release/innernet", "usr/bin/inn", "755"],
|
||||
["innernet@.service", "usr/lib/systemd/system/", "644"],
|
||||
["../doc/innernet.8.gz", "usr/share/man/man8/", "644"],
|
||||
]
|
||||
depends = "libc6, libgcc1, systemd"
|
||||
extended-description = "innernet client binary for fetching peer information and conducting admin tasks such as adding a new peer."
|
||||
maintainer = "tonari <hey@tonari.no>"
|
||||
name = "innernet"
|
||||
priority = "optional"
|
||||
section = "net"
|
12
client/innernet@.service
Normal file
12
client/innernet@.service
Normal file
@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=innernet client daemon for %I
|
||||
After=network-online.target nss-lookup.target
|
||||
Wants=network-online.target nss-lookup.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/innernet up %i --daemon --interval 60
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
192
client/src/data_store.rs
Normal file
192
client/src/data_store.rs
Normal file
@ -0,0 +1,192 @@
|
||||
use crate::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{ensure_dirs_exist, Cidr, IoErrorContext, Peer, CLIENT_DATA_PATH};
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DataStore {
|
||||
file: File,
|
||||
contents: Contents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "version")]
|
||||
pub enum Contents {
|
||||
#[serde(rename = "1")]
|
||||
V1 { peers: Vec<Peer>, cidrs: Vec<Cidr> },
|
||||
}
|
||||
|
||||
impl DataStore {
|
||||
pub(self) fn open_with_path<P: AsRef<Path>>(path: P, create: bool) -> Result<Self, Error> {
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(create)
|
||||
.open(&path)
|
||||
.with_path(&path)?;
|
||||
let mut json = String::new();
|
||||
file.read_to_string(&mut json).with_path(path)?;
|
||||
let contents = serde_json::from_str(&json).unwrap_or_else(|_| Contents::V1 {
|
||||
peers: vec![],
|
||||
cidrs: vec![],
|
||||
});
|
||||
|
||||
Ok(Self { file, contents })
|
||||
}
|
||||
|
||||
fn _open(interface: &str, create: bool) -> Result<Self, Error> {
|
||||
ensure_dirs_exist(&[*CLIENT_DATA_PATH])?;
|
||||
Self::open_with_path(
|
||||
CLIENT_DATA_PATH.join(interface).with_extension("json"),
|
||||
create,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open(interface: &str) -> Result<Self, Error> {
|
||||
Self::_open(interface, false)
|
||||
}
|
||||
|
||||
pub fn open_or_create(interface: &str) -> Result<Self, Error> {
|
||||
Self::_open(interface, true)
|
||||
}
|
||||
|
||||
pub fn peers(&self) -> &[Peer] {
|
||||
match &self.contents {
|
||||
Contents::V1 { peers, .. } => peers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add new peers to the PeerStore, never deleting old ones.
|
||||
///
|
||||
/// This is done as a protective measure, validating that the (IP, PublicKey) tuple
|
||||
/// of the interface's peers never change, i.e. "pinning" them. This prevents a compromised
|
||||
/// server from impersonating an existing peer.
|
||||
///
|
||||
/// Note, however, that this does not prevent a compromised server from adding a new
|
||||
/// peer under its control, of course.
|
||||
pub fn add_peers(&mut self, new_peers: Vec<Peer>) -> Result<(), Error> {
|
||||
let peers = match &mut self.contents {
|
||||
Contents::V1 { ref mut peers, .. } => peers,
|
||||
};
|
||||
|
||||
for new_peer in new_peers {
|
||||
if let Some(existing_peer) = peers.iter_mut().find(|p| p.ip == new_peer.ip) {
|
||||
if existing_peer.public_key != new_peer.public_key {
|
||||
return Err(
|
||||
"PINNING ERROR: New peer has same IP but different public key.".into(),
|
||||
);
|
||||
} else {
|
||||
*existing_peer = new_peer;
|
||||
}
|
||||
} else {
|
||||
peers.push(new_peer);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cidrs(&self) -> &[Cidr] {
|
||||
match &self.contents {
|
||||
Contents::V1 { cidrs, .. } => cidrs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_cidrs(&mut self, new_cidrs: Vec<Cidr>) {
|
||||
match &mut self.contents {
|
||||
Contents::V1 { ref mut cidrs, .. } => *cidrs = new_cidrs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&mut self) -> Result<(), Error> {
|
||||
self.file.seek(SeekFrom::Start(0))?;
|
||||
self.file.set_len(0)?;
|
||||
self.file
|
||||
.write_all(serde_json::to_string_pretty(&self.contents)?.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lazy_static::lazy_static;
|
||||
use shared::{Cidr, CidrContents, Peer, PeerContents};
|
||||
lazy_static! {
|
||||
static ref BASE_PEERS: Vec<Peer> = vec![Peer {
|
||||
id: 0,
|
||||
contents: PeerContents {
|
||||
name: "blah".to_string(),
|
||||
ip: "10.0.0.1".parse().unwrap(),
|
||||
cidr_id: 1,
|
||||
public_key: "abc".to_string(),
|
||||
endpoint: None,
|
||||
is_admin: false,
|
||||
is_disabled: false,
|
||||
is_redeemed: true,
|
||||
persistent_keepalive_interval: None,
|
||||
}
|
||||
}];
|
||||
static ref BASE_CIDRS: Vec<Cidr> = vec![Cidr {
|
||||
id: 1,
|
||||
contents: CidrContents {
|
||||
name: "cidr".to_string(),
|
||||
cidr: "10.0.0.0/24".parse().unwrap(),
|
||||
parent: None
|
||||
}
|
||||
}];
|
||||
}
|
||||
fn setup_basic_store(dir: &Path) {
|
||||
let mut store = DataStore::open_with_path(&dir.join("peer_store.json"), true).unwrap();
|
||||
|
||||
println!("{:?}", store);
|
||||
assert_eq!(0, store.peers().len());
|
||||
assert_eq!(0, store.cidrs().len());
|
||||
|
||||
store.add_peers(BASE_PEERS.to_owned()).unwrap();
|
||||
store.set_cidrs(BASE_CIDRS.to_owned());
|
||||
store.write().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanity() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
setup_basic_store(dir.path());
|
||||
let store = DataStore::open_with_path(&dir.path().join("peer_store.json"), false).unwrap();
|
||||
assert_eq!(store.peers(), &*BASE_PEERS);
|
||||
assert_eq!(store.cidrs(), &*BASE_CIDRS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pinning() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
setup_basic_store(dir.path());
|
||||
let mut store =
|
||||
DataStore::open_with_path(&dir.path().join("peer_store.json"), false).unwrap();
|
||||
|
||||
// Should work, since peer is unmodified.
|
||||
store.add_peers(BASE_PEERS.clone()).unwrap();
|
||||
|
||||
let mut modified = BASE_PEERS.clone();
|
||||
modified[0].contents.public_key = "foo".to_string();
|
||||
|
||||
// Should NOT work, since peer is unmodified.
|
||||
assert!(store.add_peers(modified).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
setup_basic_store(dir.path());
|
||||
let mut store =
|
||||
DataStore::open_with_path(&dir.path().join("peer_store.json"), false).unwrap();
|
||||
|
||||
// Should work, since peer is unmodified.
|
||||
store.add_peers(vec![]).unwrap();
|
||||
assert_eq!(store.peers(), &*BASE_PEERS);
|
||||
}
|
||||
}
|
752
client/src/main.rs
Normal file
752
client/src/main.rs
Normal file
@ -0,0 +1,752 @@
|
||||
use colored::*;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
|
||||
use hostsfile::HostsBuilder;
|
||||
use indoc::printdoc;
|
||||
use shared::{
|
||||
interface_config::InterfaceConfig, prompts, Association, AssociationContents, Cidr, CidrTree,
|
||||
EndpointContents, Interface, IoErrorContext, Peer, RedeemContents, State, CLIENT_CONFIG_PATH,
|
||||
REDEEM_TRANSITION_WAIT,
|
||||
};
|
||||
use std::{
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use wgctrl::{DeviceConfigBuilder, DeviceInfo, PeerConfigBuilder, PeerInfo};
|
||||
|
||||
mod data_store;
|
||||
mod util;
|
||||
|
||||
use data_store::DataStore;
|
||||
use shared::{wg, Error};
|
||||
use util::{http_delete, http_get, http_post, http_put, human_duration, human_size};
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
#[structopt(name = "innernet", about)]
|
||||
struct Opt {
|
||||
#[structopt(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
enum Command {
|
||||
/// Install a new innernet config.
|
||||
#[structopt(alias = "redeem")]
|
||||
Install { config: PathBuf },
|
||||
|
||||
/// Enumerate all innernet connections.
|
||||
#[structopt(alias = "list")]
|
||||
Show {
|
||||
#[structopt(short, long)]
|
||||
short: bool,
|
||||
|
||||
#[structopt(short, long)]
|
||||
tree: bool,
|
||||
|
||||
interface: Option<Interface>,
|
||||
},
|
||||
|
||||
/// Bring up your local interface, and update it with latest peer list.
|
||||
Up {
|
||||
/// Enable daemon mode i.e. keep the process running, while fetching
|
||||
/// the latest peer list periodically.
|
||||
#[structopt(short, long)]
|
||||
daemon: bool,
|
||||
|
||||
/// Keep fetching the latest peer list at the specified interval in
|
||||
/// seconds. Valid only in daemon mode.
|
||||
#[structopt(long, default_value = "60")]
|
||||
interval: u64,
|
||||
|
||||
interface: Interface,
|
||||
},
|
||||
|
||||
/// Fetch and update your local interface with the latest peer list.
|
||||
Fetch { interface: Interface },
|
||||
|
||||
/// Bring down the interface (equivalent to "wg-quick down [interface]")
|
||||
Down { interface: Interface },
|
||||
|
||||
/// Add a new peer.
|
||||
AddPeer { interface: Interface },
|
||||
|
||||
/// Add a new CIDR.
|
||||
AddCidr { interface: Interface },
|
||||
|
||||
/// Disable an enabled peer.
|
||||
DisablePeer { interface: Interface },
|
||||
|
||||
/// Enable a disabled peer.
|
||||
EnablePeer { interface: Interface },
|
||||
|
||||
/// Add an association between CIDRs.
|
||||
AddAssociation { interface: Interface },
|
||||
|
||||
/// Delete an association between CIDRs.
|
||||
DeleteAssociation { interface: Interface },
|
||||
|
||||
/// List existing assocations between CIDRs.
|
||||
ListAssociations { interface: Interface },
|
||||
|
||||
/// Set the local listen port.
|
||||
SetListenPort {
|
||||
interface: Interface,
|
||||
|
||||
/// Unset the local listen port to use a randomized port.
|
||||
#[structopt(short, long)]
|
||||
unset: bool,
|
||||
},
|
||||
|
||||
/// Override your external endpoint that the server sends to other peers.
|
||||
OverrideEndpoint {
|
||||
interface: Interface,
|
||||
|
||||
/// Unset an existing override to use the automatic endpoint discovery.
|
||||
#[structopt(short, long)]
|
||||
unset: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Application-level error.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ClientError(String);
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn update_hosts_file(interface: &str, peers: &Vec<Peer>) -> Result<(), Error> {
|
||||
println!(
|
||||
"{} updating {} with the latest peers.",
|
||||
"[*]".dimmed(),
|
||||
"/etc/hosts".yellow()
|
||||
);
|
||||
|
||||
let mut hosts_builder = HostsBuilder::new(format!("innernet {}", interface));
|
||||
for peer in peers {
|
||||
hosts_builder.add_hostname(
|
||||
peer.contents.ip,
|
||||
&format!("{}.{}.wg", peer.contents.name, interface),
|
||||
);
|
||||
}
|
||||
hosts_builder.write()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install(invite: &Path) -> Result<(), Error> {
|
||||
let theme = ColorfulTheme::default();
|
||||
shared::ensure_dirs_exist(&[*CLIENT_CONFIG_PATH])?;
|
||||
let mut config = InterfaceConfig::from_file(invite)?;
|
||||
|
||||
let iface = Input::with_theme(&theme)
|
||||
.with_prompt("Interface name")
|
||||
.default(config.interface.network_name.clone())
|
||||
.interact()?;
|
||||
|
||||
let target_conf = CLIENT_CONFIG_PATH.join(&iface).with_extension("conf");
|
||||
if target_conf.exists() {
|
||||
return Err("An interface with this name already exists in innernet.".into());
|
||||
}
|
||||
|
||||
println!("{} bringing up the interface.", "[*]".dimmed());
|
||||
wg::up(
|
||||
&iface,
|
||||
&config.interface.private_key,
|
||||
config.interface.address,
|
||||
None,
|
||||
Some((
|
||||
&config.server.public_key,
|
||||
config.server.internal_endpoint.ip(),
|
||||
config.server.external_endpoint,
|
||||
)),
|
||||
)?;
|
||||
|
||||
println!("{} Generating new keypair.", "[*]".dimmed());
|
||||
let keypair = wgctrl::KeyPair::generate();
|
||||
|
||||
println!(
|
||||
"{} Registering keypair with server (at {}).",
|
||||
"[*]".dimmed(),
|
||||
&config.server.internal_endpoint
|
||||
);
|
||||
http_post(
|
||||
&config.server.internal_endpoint,
|
||||
"/user/redeem",
|
||||
RedeemContents {
|
||||
public_key: keypair.public.to_base64(),
|
||||
},
|
||||
)?;
|
||||
|
||||
config.interface.private_key = keypair.private.to_base64();
|
||||
config.write_to_path(&target_conf, false, Some(0o600))?;
|
||||
println!(
|
||||
"{} New keypair registered. Copied config to {}.\n",
|
||||
"[*]".dimmed(),
|
||||
target_conf.to_string_lossy().yellow()
|
||||
);
|
||||
println!(
|
||||
"{} Waiting for server's WireGuard interface to transition to new key.",
|
||||
"[*]".dimmed(),
|
||||
);
|
||||
thread::sleep(*REDEEM_TRANSITION_WAIT);
|
||||
|
||||
DeviceConfigBuilder::new()
|
||||
.set_private_key(keypair.private)
|
||||
.apply(&iface)?;
|
||||
|
||||
fetch(&iface, false)?;
|
||||
|
||||
if Confirm::with_theme(&theme)
|
||||
.with_prompt(&format!(
|
||||
"Delete invitation file \"{}\" now? (It's no longer needed)",
|
||||
invite.to_string_lossy().yellow()
|
||||
))
|
||||
.default(true)
|
||||
.interact()?
|
||||
{
|
||||
std::fs::remove_file(invite).with_path(invite)?;
|
||||
}
|
||||
|
||||
printdoc!(
|
||||
"
|
||||
{star} Done!
|
||||
|
||||
{interface} has been {installed}.
|
||||
|
||||
It's recommended to now keep the interface automatically refreshing via systemd:
|
||||
|
||||
{systemctl_enable}{interface}
|
||||
|
||||
See the documentation for more detailed instruction on managing your interface
|
||||
and your network.
|
||||
|
||||
",
|
||||
star = "[*]".dimmed(),
|
||||
interface = iface.yellow(),
|
||||
installed = "installed".green(),
|
||||
systemctl_enable = "systemctl enable --now innernet@".yellow(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn up(interface: &str, loop_interval: Option<Duration>) -> Result<(), Error> {
|
||||
loop {
|
||||
fetch(interface, true)?;
|
||||
match loop_interval {
|
||||
Some(interval) => thread::sleep(interval),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch(interface: &str, bring_up_interface: bool) -> Result<(), Error> {
|
||||
let config = InterfaceConfig::from_interface(interface)?;
|
||||
let interface_up = if let Ok(interfaces) = DeviceInfo::enumerate() {
|
||||
interfaces.iter().any(|name| name == interface)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !interface_up {
|
||||
if !bring_up_interface {
|
||||
return Err(format!(
|
||||
"Interface is not up. Use 'innernet up {}' instead",
|
||||
interface
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
println!("{} bringing up the interface.", "[*]".dimmed());
|
||||
wg::up(
|
||||
interface,
|
||||
&config.interface.private_key,
|
||||
config.interface.address,
|
||||
config.interface.listen_port,
|
||||
Some((
|
||||
&config.server.public_key,
|
||||
config.server.internal_endpoint.ip(),
|
||||
config.server.external_endpoint,
|
||||
)),
|
||||
)?
|
||||
}
|
||||
|
||||
println!("{} fetching state from server.", "[*]".dimmed());
|
||||
let mut store = DataStore::open_or_create(&interface)?;
|
||||
let State { peers, cidrs } = http_get(&config.server.internal_endpoint, "/user/state")?;
|
||||
|
||||
let device_info = DeviceInfo::get_by_name(&interface)?;
|
||||
let interface_public_key = device_info
|
||||
.public_key
|
||||
.as_ref()
|
||||
.map(|k| k.to_base64())
|
||||
.unwrap_or_default();
|
||||
let existing_peers = &device_info.peers;
|
||||
|
||||
let peer_configs_diff = peers
|
||||
.iter()
|
||||
.filter(|peer| !peer.is_disabled && peer.public_key != interface_public_key)
|
||||
.filter_map(|peer| {
|
||||
let existing_peer = existing_peers
|
||||
.iter()
|
||||
.find(|p| p.config.public_key.to_base64() == peer.public_key);
|
||||
|
||||
let change = match existing_peer {
|
||||
Some(existing_peer) => peer
|
||||
.diff(&existing_peer.config)
|
||||
.map(|diff| (PeerConfigBuilder::from(&diff), peer, "modified".normal())),
|
||||
None => Some((PeerConfigBuilder::from(peer), peer, "added".green())),
|
||||
};
|
||||
|
||||
change.map(|(builder, peer, text)| {
|
||||
println!(
|
||||
" peer {} ({}...) was {}.",
|
||||
peer.name.yellow(),
|
||||
&peer.public_key[..10].dimmed(),
|
||||
text
|
||||
);
|
||||
builder
|
||||
})
|
||||
})
|
||||
.collect::<Vec<PeerConfigBuilder>>();
|
||||
|
||||
let mut device_config_builder = DeviceConfigBuilder::new();
|
||||
let mut device_config_changed = false;
|
||||
|
||||
if !peer_configs_diff.is_empty() {
|
||||
device_config_builder = device_config_builder.add_peers(&peer_configs_diff);
|
||||
device_config_changed = true;
|
||||
}
|
||||
|
||||
for peer in existing_peers {
|
||||
let public_key = peer.config.public_key.to_base64();
|
||||
if peers.iter().find(|p| p.public_key == public_key).is_none() {
|
||||
println!(
|
||||
" peer ({}...) was {}.",
|
||||
&public_key[..10].yellow(),
|
||||
"removed".red()
|
||||
);
|
||||
|
||||
device_config_builder =
|
||||
device_config_builder.remove_peer_by_key(&peer.config.public_key);
|
||||
device_config_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if device_config_changed {
|
||||
device_config_builder.apply(&interface)?;
|
||||
|
||||
update_hosts_file(interface, &peers)?;
|
||||
|
||||
println!(
|
||||
"\n{} updated interface {}\n",
|
||||
"[*]".dimmed(),
|
||||
interface.yellow()
|
||||
);
|
||||
} else {
|
||||
println!("{}", " peers are already up to date.".green());
|
||||
}
|
||||
store.set_cidrs(cidrs);
|
||||
store.add_peers(peers)?;
|
||||
store.write()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_cidr(interface: &str) -> Result<(), Error> {
|
||||
let InterfaceConfig { server, .. } = InterfaceConfig::from_interface(interface)?;
|
||||
println!("Fetching CIDRs");
|
||||
let cidrs: Vec<Cidr> = http_get(&server.internal_endpoint, "/admin/cidrs")?;
|
||||
|
||||
let cidr_request = prompts::add_cidr(&cidrs)?;
|
||||
|
||||
println!("Creating CIDR...");
|
||||
let cidr: Cidr = http_post(&server.internal_endpoint, "/admin/cidrs", cidr_request)?;
|
||||
|
||||
printdoc!(
|
||||
"
|
||||
CIDR \"{cidr_name}\" added.
|
||||
|
||||
Right now, peers within {cidr_name} can only see peers in the same CIDR
|
||||
, and in the special \"infra\" CIDR that includes the innernet server peer.
|
||||
|
||||
You'll need to add more associations for peers in diffent CIDRs to communicate.
|
||||
",
|
||||
cidr_name = cidr.name.bold()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_peer(interface: &str) -> Result<(), Error> {
|
||||
let InterfaceConfig { server, .. } = InterfaceConfig::from_interface(interface)?;
|
||||
println!("Fetching CIDRs");
|
||||
let cidrs: Vec<Cidr> = http_get(&server.internal_endpoint, "/admin/cidrs")?;
|
||||
println!("Fetching peers");
|
||||
let peers: Vec<Peer> = http_get(&server.internal_endpoint, "/admin/peers")?;
|
||||
let cidr_tree = CidrTree::new(&cidrs[..]);
|
||||
|
||||
if let Some((peer_request, keypair)) = prompts::add_peer(&peers, &cidr_tree)? {
|
||||
println!("Creating peer...");
|
||||
let peer: Peer = http_post(&server.internal_endpoint, "/admin/peers", peer_request)?;
|
||||
let server_peer = peers.iter().find(|p| p.id == 1).unwrap();
|
||||
prompts::save_peer_invitation(
|
||||
interface,
|
||||
&peer,
|
||||
server_peer,
|
||||
&cidr_tree,
|
||||
keypair,
|
||||
&server.internal_endpoint,
|
||||
)?;
|
||||
} else {
|
||||
println!("exited without creating peer.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enable_or_disable_peer(interface: &str, enable: bool) -> Result<(), Error> {
|
||||
let InterfaceConfig { server, .. } = InterfaceConfig::from_interface(interface)?;
|
||||
println!("Fetching peers.");
|
||||
let peers: Vec<Peer> = http_get(&server.internal_endpoint, "/admin/peers")?;
|
||||
|
||||
if let Some(peer) = prompts::enable_or_disable_peer(&peers[..], enable)? {
|
||||
let Peer { id, mut contents } = peer;
|
||||
contents.is_disabled = !enable;
|
||||
http_put(
|
||||
&server.internal_endpoint,
|
||||
&format!("/admin/peers/{}", id),
|
||||
contents,
|
||||
)?;
|
||||
} else {
|
||||
println!("exited without disabling peer.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_association(interface: &str) -> Result<(), Error> {
|
||||
let InterfaceConfig { server, .. } = InterfaceConfig::from_interface(interface)?;
|
||||
|
||||
println!("Fetching CIDRs");
|
||||
let cidrs: Vec<Cidr> = http_get(&server.internal_endpoint, "/admin/cidrs")?;
|
||||
|
||||
if let Some((cidr1, cidr2)) = prompts::add_association(&cidrs[..])? {
|
||||
http_post(
|
||||
&server.internal_endpoint,
|
||||
"/admin/associations",
|
||||
AssociationContents {
|
||||
cidr_id_1: cidr1.id,
|
||||
cidr_id_2: cidr2.id,
|
||||
},
|
||||
)?;
|
||||
} else {
|
||||
println!("exited without adding association.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_association(interface: &str) -> Result<(), Error> {
|
||||
let InterfaceConfig { server, .. } = InterfaceConfig::from_interface(interface)?;
|
||||
|
||||
println!("Fetching CIDRs");
|
||||
let cidrs: Vec<Cidr> = http_get(&server.internal_endpoint, "/admin/cidrs")?;
|
||||
println!("Fetching associations");
|
||||
let associations: Vec<Association> =
|
||||
http_get(&server.internal_endpoint, "/admin/associations")?;
|
||||
|
||||
if let Some(association) = prompts::delete_association(&associations[..], &cidrs[..])? {
|
||||
http_delete(
|
||||
&server.internal_endpoint,
|
||||
&format!("/admin/associations/{}", association.id),
|
||||
)?;
|
||||
} else {
|
||||
println!("exited without adding association.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_associations(interface: &str) -> Result<(), Error> {
|
||||
let InterfaceConfig { server, .. } = InterfaceConfig::from_interface(interface)?;
|
||||
println!("Fetching CIDRs");
|
||||
let cidrs: Vec<Cidr> = http_get(&server.internal_endpoint, "/admin/cidrs")?;
|
||||
println!("Fetching associations");
|
||||
let associations: Vec<Association> =
|
||||
http_get(&server.internal_endpoint, "/admin/associations")?;
|
||||
|
||||
for association in associations {
|
||||
println!(
|
||||
"{}: {} <=> {}",
|
||||
association.id,
|
||||
&cidrs
|
||||
.iter()
|
||||
.find(|c| c.id == association.cidr_id_1)
|
||||
.unwrap()
|
||||
.name
|
||||
.yellow(),
|
||||
&cidrs
|
||||
.iter()
|
||||
.find(|c| c.id == association.cidr_id_2)
|
||||
.unwrap()
|
||||
.name
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_listen_port(interface: &str, unset: bool) -> Result<(), Error> {
|
||||
let mut config = InterfaceConfig::from_interface(interface)?;
|
||||
|
||||
if let Some(listen_port) = prompts::set_listen_port(&config.interface, unset)? {
|
||||
wg::set_listen_port(interface, listen_port)?;
|
||||
println!("{} the interface is updated", "[*]".dimmed(),);
|
||||
|
||||
config.interface.listen_port = listen_port;
|
||||
config.write_to_interface(interface)?;
|
||||
println!("{} the config file is updated", "[*]".dimmed(),);
|
||||
} else {
|
||||
println!("exited without updating listen port.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn override_endpoint(interface: &str, unset: bool) -> Result<(), Error> {
|
||||
let config = InterfaceConfig::from_interface(interface)?;
|
||||
if !unset && config.interface.listen_port.is_none() {
|
||||
println!(
|
||||
"{}: you need to set a listen port for your interface first.",
|
||||
"note".bold().yellow()
|
||||
);
|
||||
set_listen_port(interface, unset)?;
|
||||
}
|
||||
|
||||
if let Some(endpoint) = prompts::override_endpoint(unset)? {
|
||||
println!("Updating endpoint.");
|
||||
http_put(
|
||||
&config.server.internal_endpoint,
|
||||
"/user/endpoint",
|
||||
EndpointContents::from(endpoint),
|
||||
)?;
|
||||
} else {
|
||||
println!("exited without overriding endpoint.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show(short: bool, tree: bool, interface: Option<Interface>) -> Result<(), Error> {
|
||||
let interfaces = interface.map_or_else(
|
||||
|| DeviceInfo::enumerate(),
|
||||
|interface| Ok(vec![interface.to_string()]),
|
||||
)?;
|
||||
|
||||
let devices = interfaces.into_iter().filter_map(|name| {
|
||||
DataStore::open(&name)
|
||||
.and_then(|store| Ok((DeviceInfo::get_by_name(&name)?, store)))
|
||||
.ok()
|
||||
});
|
||||
for (mut device_info, store) in devices {
|
||||
let peers = store.peers();
|
||||
let cidrs = store.cidrs();
|
||||
let me = peers
|
||||
.iter()
|
||||
.find(|p| p.public_key == device_info.public_key.as_ref().unwrap().to_base64())
|
||||
.ok_or("missing peer info")?;
|
||||
|
||||
print_interface(&device_info, me, short)?;
|
||||
// Sort the peers by last handshake time (descending),
|
||||
// then by IP address (ascending)
|
||||
device_info.peers.sort_by_key(|peer| {
|
||||
let our_peer = peers
|
||||
.iter()
|
||||
.find(|p| p.public_key == peer.config.public_key.to_base64())
|
||||
.ok_or("missing peer info")
|
||||
.unwrap();
|
||||
|
||||
(
|
||||
std::cmp::Reverse(peer.stats.last_handshake_time),
|
||||
our_peer.ip,
|
||||
)
|
||||
});
|
||||
|
||||
if tree {
|
||||
let cidr_tree = CidrTree::new(&cidrs[..]);
|
||||
print_tree(&cidr_tree, &peers, 1);
|
||||
} else {
|
||||
for peer in device_info.peers {
|
||||
let our_peer = peers
|
||||
.iter()
|
||||
.find(|p| p.public_key == peer.config.public_key.to_base64())
|
||||
.ok_or("missing peer info")?;
|
||||
print_peer(our_peer, &peer, short)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_tree(cidr: &CidrTree, peers: &[Peer], level: usize) {
|
||||
println!(
|
||||
"{:pad$}{} {}",
|
||||
"",
|
||||
cidr.cidr.to_string().bold().blue(),
|
||||
cidr.name.blue(),
|
||||
pad = level * 2
|
||||
);
|
||||
|
||||
cidr.children()
|
||||
.for_each(|child| print_tree(&child, peers, level + 1));
|
||||
|
||||
for peer in peers.iter().filter(|p| p.cidr_id == cidr.id) {
|
||||
println!(
|
||||
"{:pad$}| {} {}",
|
||||
"",
|
||||
peer.ip.to_string().yellow().bold(),
|
||||
peer.name.yellow(),
|
||||
pad = level * 2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_interface(device_info: &DeviceInfo, me: &Peer, short: bool) -> Result<(), Error> {
|
||||
let public_key = device_info
|
||||
.public_key
|
||||
.as_ref()
|
||||
.ok_or("interface has no private key set.")?
|
||||
.to_base64();
|
||||
|
||||
if short {
|
||||
println!("{}", device_info.name.green().bold());
|
||||
println!(
|
||||
" {} {}: {} ({}...)",
|
||||
"(you)".bold(),
|
||||
me.ip.to_string().yellow().bold(),
|
||||
me.name.yellow(),
|
||||
public_key[..10].dimmed()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}: {} ({}...)",
|
||||
"interface".green().bold(),
|
||||
device_info.name.green(),
|
||||
public_key[..10].yellow()
|
||||
);
|
||||
if !short {
|
||||
if let Some(listen_port) = device_info.listen_port {
|
||||
println!(" {}: {}", "listening_port".bold(), listen_port);
|
||||
}
|
||||
println!(" {}: {}", "ip".bold(), me.ip);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_peer(our_peer: &Peer, peer: &PeerInfo, short: bool) -> Result<(), Error> {
|
||||
if short {
|
||||
println!(
|
||||
" {}: {} ({}...)",
|
||||
peer.config.allowed_ips[0]
|
||||
.address
|
||||
.to_string()
|
||||
.yellow()
|
||||
.bold(),
|
||||
our_peer.name.yellow(),
|
||||
&our_peer.public_key[..10].dimmed()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}: {} ({}...)",
|
||||
"peer".yellow().bold(),
|
||||
our_peer.name.yellow(),
|
||||
&our_peer.public_key[..10].yellow()
|
||||
);
|
||||
println!(" {}: {}", "ip".bold(), our_peer.ip);
|
||||
if let Some(endpoint) = our_peer.endpoint {
|
||||
println!(" {}: {}", "endpoint".bold(), endpoint);
|
||||
}
|
||||
if let Some(last_handshake) = peer.stats.last_handshake_time {
|
||||
let duration = last_handshake.elapsed()?;
|
||||
println!(
|
||||
" {}: {}",
|
||||
"last handshake".bold(),
|
||||
human_duration(duration),
|
||||
);
|
||||
}
|
||||
if peer.stats.tx_bytes > 0 || peer.stats.rx_bytes > 0 {
|
||||
println!(
|
||||
" {}: {} received, {} sent",
|
||||
"transfer".bold(),
|
||||
human_size(peer.stats.rx_bytes),
|
||||
human_size(peer.stats.tx_bytes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let opt = Opt::from_args();
|
||||
|
||||
if let Err(e) = run(opt) {
|
||||
eprintln!("\n{} {}\n", "[ERROR]".red(), e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(opt: Opt) -> Result<(), Error> {
|
||||
if unsafe { libc::getuid() } != 0 {
|
||||
return Err("innernet must run as root.".into());
|
||||
}
|
||||
|
||||
let command = opt.command.unwrap_or(Command::Show {
|
||||
short: false,
|
||||
tree: false,
|
||||
interface: None,
|
||||
});
|
||||
|
||||
match command {
|
||||
Command::Install { config } => install(&config)?,
|
||||
Command::Show {
|
||||
short,
|
||||
tree,
|
||||
interface,
|
||||
} => show(short, tree, interface)?,
|
||||
Command::Fetch { interface } => fetch(&interface, false)?,
|
||||
Command::Up {
|
||||
interface,
|
||||
daemon,
|
||||
interval,
|
||||
} => up(&interface, daemon.then(|| Duration::from_secs(interval)))?,
|
||||
Command::Down { interface } => wg::down(&interface)?,
|
||||
Command::AddPeer { interface } => add_peer(&interface)?,
|
||||
Command::AddCidr { interface } => add_cidr(&interface)?,
|
||||
Command::DisablePeer { interface } => enable_or_disable_peer(&interface, false)?,
|
||||
Command::EnablePeer { interface } => enable_or_disable_peer(&interface, true)?,
|
||||
Command::AddAssociation { interface } => add_association(&interface)?,
|
||||
Command::DeleteAssociation { interface } => delete_association(&interface)?,
|
||||
Command::ListAssociations { interface } => list_associations(&interface)?,
|
||||
Command::SetListenPort { interface, unset } => set_listen_port(&interface, unset)?,
|
||||
Command::OverrideEndpoint { interface, unset } => override_endpoint(&interface, unset)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
86
client/src/util.rs
Normal file
86
client/src/util.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use crate::{ClientError, Error};
|
||||
use colored::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
pub fn human_duration(duration: Duration) -> String {
|
||||
match duration.as_secs() {
|
||||
n if n < 1 => "just now".cyan().to_string(),
|
||||
n if n < 60 => format!("{} {} ago", n, "seconds".cyan()),
|
||||
n if n < 60 * 60 => {
|
||||
let mins = n / 60;
|
||||
let secs = n % 60;
|
||||
format!(
|
||||
"{} {}, {} {} ago",
|
||||
mins,
|
||||
if mins == 1 { "minute" } else { "minutes" }.cyan(),
|
||||
secs,
|
||||
if secs == 1 { "second" } else { "seconds" }.cyan(),
|
||||
)
|
||||
},
|
||||
n => {
|
||||
let hours = n / (60 * 60);
|
||||
let mins = (n / 60) % 60;
|
||||
format!(
|
||||
"{} {}, {} {} ago",
|
||||
hours,
|
||||
if hours == 1 { "hour" } else { "hours" }.cyan(),
|
||||
mins,
|
||||
if mins == 1 { "minute" } else { "minutes" }.cyan(),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn human_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
const TB: u64 = 1024 * GB;
|
||||
match bytes {
|
||||
n if n < 2 * KB => format!("{} {}", n, "B".cyan()),
|
||||
n if n < 2 * MB => format!("{:.2} {}", n as f64 / KB as f64, "KiB".cyan()),
|
||||
n if n < 2 * GB => format!("{:.2} {}", n as f64 / MB as f64, "MiB".cyan()),
|
||||
n if n < 2 * TB => format!("{:.2} {}", n as f64 / GB as f64, "GiB".cyan()),
|
||||
n => format!("{:.2} {}", n as f64 / TB as f64, "TiB".cyan()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http_get<T: DeserializeOwned>(server: &SocketAddr, endpoint: &str) -> Result<T, Error> {
|
||||
let response = ureq::get(&format!("http://{}/v1{}", server, endpoint)).call()?;
|
||||
process_response(response)
|
||||
}
|
||||
|
||||
pub fn http_delete(server: &SocketAddr, endpoint: &str) -> Result<(), Error> {
|
||||
ureq::get(&format!("http://{}/v1{}", server, endpoint)).call()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn http_post<S: Serialize, D: DeserializeOwned>(
|
||||
server: &SocketAddr,
|
||||
endpoint: &str,
|
||||
form: S,
|
||||
) -> Result<D, Error> {
|
||||
let response = ureq::post(&format!("http://{}/v1{}", server, endpoint))
|
||||
.send_json(serde_json::to_value(form)?)?;
|
||||
process_response(response)
|
||||
}
|
||||
|
||||
pub fn http_put<S: Serialize>(server: &SocketAddr, endpoint: &str, form: S) -> Result<(), Error> {
|
||||
ureq::put(&format!("http://{}/v1{}", server, endpoint))
|
||||
.send_json(serde_json::to_value(form)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_response<T: DeserializeOwned>(response: ureq::Response) -> Result<T, Error> {
|
||||
let mut response = response.into_string()?;
|
||||
if response.is_empty() {
|
||||
response = "null".into();
|
||||
}
|
||||
Ok(serde_json::from_str(&response).map_err(|e| {
|
||||
ClientError(format!(
|
||||
"failed to deserialize JSON response from the server: {}, response={}",
|
||||
e, &response
|
||||
))
|
||||
})?)
|
||||
}
|
29
deb/install.sh
Executable file
29
deb/install.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
info() {
|
||||
TERM=${TERM:-dumb} echo -e "$(tput setaf 4)- $@$(tput sgr0)" 1>&2
|
||||
}
|
||||
|
||||
cmd() {
|
||||
echo "[#] $*" >&2
|
||||
"$@"
|
||||
}
|
||||
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: $0 bootstrap_conf interface_name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
conf="$1"
|
||||
name="$2"
|
||||
|
||||
info "installing wireguard config file."
|
||||
cmd sudo mv $conf /etc/wireguard/$name.conf
|
||||
cmd sudo chown root:root /etc/wireguard/$name.conf
|
||||
cmd sudo chmod 600 /etc/wireguard/$name.conf
|
||||
|
||||
info "enabling innernet service."
|
||||
cmd sudo systemctl enable innernet@$name
|
||||
cmd sudo systemctl start innernet@$name
|
15
deb/package.sh
Executable file
15
deb/package.sh
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )"
|
||||
cd $SCRIPT_DIR/../client
|
||||
|
||||
if ! cargo install --list | grep cargo-deb > /dev/null; then
|
||||
cargo install cargo-deb
|
||||
fi
|
||||
|
||||
set -x
|
||||
|
||||
cargo build --release
|
||||
cargo deb --package client --no-build
|
BIN
doc/innernet-server.8.gz
Normal file
BIN
doc/innernet-server.8.gz
Normal file
Binary file not shown.
BIN
doc/innernet.8.gz
Normal file
BIN
doc/innernet.8.gz
Normal file
Binary file not shown.
15
generate_manpage.sh
Executable file
15
generate_manpage.sh
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -ex
|
||||
|
||||
if ! command -v help2man &> /dev/null
|
||||
then
|
||||
echo "help2man binary could not be found"
|
||||
exit
|
||||
fi
|
||||
|
||||
cargo build
|
||||
|
||||
for binary in "innernet" "innernet-server"; do
|
||||
help2man --no-discard-stderr -s8 "target/debug/$binary" -N > "doc/$binary.8"
|
||||
gzip -f "doc/$binary.8"
|
||||
done
|
11
hostsfile/Cargo.toml
Normal file
11
hostsfile/Cargo.toml
Normal file
@ -0,0 +1,11 @@
|
||||
[package]
|
||||
authors = ["Ryo Kawaguchi <ryo@kawagu.ch>"]
|
||||
description = "A simplistic /etc/hosts file editor."
|
||||
edition = "2018"
|
||||
license = "UNLICENSED"
|
||||
name = "hostsfile"
|
||||
publish = false
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
tempfile = "3"
|
199
hostsfile/src/lib.rs
Normal file
199
hostsfile/src/lib.rs
Normal file
@ -0,0 +1,199 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::IpAddr,
|
||||
path::{Path, PathBuf},
|
||||
result,
|
||||
};
|
||||
|
||||
pub type Result<T> = result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
/// A custom error struct for this crate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Error(String);
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `HostsBuilder` manages a section of /etc/hosts file that contains a list of IP to hostname
|
||||
/// mappings. A hosts file can have multiple sections that are distinguished by tag names.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use hostsfile::{HostsBuilder, Result};
|
||||
/// # fn main() -> Result<()> {
|
||||
/// let mut hosts = HostsBuilder::new("dns");
|
||||
/// hosts.add_hostname("8.8.8.8".parse().unwrap(), "google-dns1");
|
||||
/// hosts.add_hostname("8.8.4.4".parse().unwrap(), "google-dns2");
|
||||
/// hosts.write_to("/tmp/hosts")?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// `/tmp/hosts` will have a section:
|
||||
///
|
||||
/// ```text
|
||||
/// # DO NOT EDIT dns BEGIN
|
||||
/// 8.8.8.8 google-dns1
|
||||
/// 8.8.4.4 google-dns2
|
||||
/// # DO NOT EDIT dns END
|
||||
/// ```
|
||||
///
|
||||
/// Another run of `HostsBuilder` with the same tag name overrides the section.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use hostsfile::{HostsBuilder, Result};
|
||||
/// # fn main() -> Result<()> {
|
||||
/// let mut hosts = HostsBuilder::new("dns");
|
||||
/// hosts.add_hostnames("1.1.1.1".parse().unwrap(), &["cloudflare-dns", "apnic-dns"]);
|
||||
/// hosts.write_to("/tmp/hosts")?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// `/tmp/hosts` will have a section:
|
||||
///
|
||||
/// ```text
|
||||
/// # DO NOT EDIT dns BEGIN
|
||||
/// 1.1.1.1 cloudflare-dns apnic-dns
|
||||
/// # DO NOT EDIT dns END
|
||||
/// ```
|
||||
pub struct HostsBuilder {
|
||||
tag: String,
|
||||
hostname_map: HashMap<IpAddr, Vec<String>>,
|
||||
}
|
||||
|
||||
impl HostsBuilder {
|
||||
/// Creates a new `HostsBuilder` with the given tag name. It corresponds to a section in the
|
||||
/// hosts file containing a list of IP to hostname mappings.
|
||||
pub fn new<S: Into<String>>(tag: S) -> Self {
|
||||
Self {
|
||||
tag: tag.into(),
|
||||
hostname_map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a mapping of `ip` to `hostname`. If there hostnames associated with the IP already,
|
||||
/// the hostname will be appended to the list.
|
||||
pub fn add_hostname<S: ToString>(&mut self, ip: IpAddr, hostname: S) {
|
||||
let hostnames_dest = self.hostname_map.entry(ip.into()).or_insert(Vec::new());
|
||||
hostnames_dest.push(hostname.to_string());
|
||||
}
|
||||
|
||||
/// Adds a mapping of `ip` to a list of `hostname`s. If there hostnames associated with the IP
|
||||
/// already, the new hostnames will be appended to the list.
|
||||
pub fn add_hostnames<I: IntoIterator<Item = impl ToString>>(
|
||||
&mut self,
|
||||
ip: IpAddr,
|
||||
hostnames: I,
|
||||
) {
|
||||
let hostnames_dest = self.hostname_map.entry(ip.into()).or_insert(Vec::new());
|
||||
for hostname in hostnames.into_iter() {
|
||||
hostnames_dest.push(hostname.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a new section to the system's default hosts file. If there is a section with the
|
||||
/// same tag name already, it will be replaced with the new list instead. Only Unix systems are
|
||||
/// supported now.
|
||||
pub fn write(&self) -> Result<()> {
|
||||
let hosts_file = if cfg!(unix) {
|
||||
PathBuf::from("/etc/hosts")
|
||||
} else {
|
||||
return Err(Box::new(Error("unsupported operating system.".to_owned())));
|
||||
};
|
||||
|
||||
if !hosts_file.exists() {
|
||||
return Err(Box::new(Error(format!(
|
||||
"hosts file {:?} missing",
|
||||
&hosts_file
|
||||
))));
|
||||
}
|
||||
|
||||
self.write_to(&hosts_file)
|
||||
}
|
||||
|
||||
/// Inserts a new section to the specified hosts file. If there is a section with the same tag
|
||||
/// name already, it will be replaced with the new list instead.
|
||||
pub fn write_to<P: AsRef<Path>>(&self, hosts_file: P) -> Result<()> {
|
||||
let hosts_file = hosts_file.as_ref();
|
||||
let begin_marker = format!("# DO NOT EDIT {} BEGIN", &self.tag);
|
||||
let end_marker = format!("# DO NOT EDIT {} END", &self.tag);
|
||||
|
||||
let mut lines = match File::open(hosts_file) {
|
||||
Ok(file) => BufReader::new(file)
|
||||
.lines()
|
||||
.map(|line| line.unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
let begin = lines.iter().position(|line| line.trim() == begin_marker);
|
||||
let end = lines.iter().position(|line| line.trim() == end_marker);
|
||||
|
||||
let insert = match (begin, end) {
|
||||
(Some(begin), Some(end)) => {
|
||||
lines.drain(begin..end + 1);
|
||||
begin
|
||||
},
|
||||
(None, None) => {
|
||||
// Insert a blank line before a new section.
|
||||
if let Some(last_line) = lines.iter().last() {
|
||||
if last_line != "" {
|
||||
lines.push("".to_string());
|
||||
}
|
||||
}
|
||||
lines.len()
|
||||
},
|
||||
_ => {
|
||||
return Err(Box::new(Error(format!(
|
||||
"start or end marker missing in {:?}",
|
||||
&hosts_file
|
||||
))));
|
||||
},
|
||||
};
|
||||
|
||||
// The tempfile should be in the same filesystem as the hosts file.
|
||||
let hosts_dir = hosts_file
|
||||
.parent()
|
||||
.expect("hosts file must be an absolute file path");
|
||||
let temp_dir = tempfile::Builder::new().tempdir_in(hosts_dir)?;
|
||||
let temp_path = temp_dir.path().join("hosts");
|
||||
|
||||
// Copy the existing hosts file to preserve permissions.
|
||||
fs::copy(&hosts_file, &temp_path)?;
|
||||
|
||||
let mut file = File::create(&temp_path)?;
|
||||
|
||||
for line in &lines[..insert] {
|
||||
writeln!(&mut file, "{}", line)?;
|
||||
}
|
||||
if !self.hostname_map.is_empty() {
|
||||
writeln!(&mut file, "{}", begin_marker)?;
|
||||
for (ip, hostnames) in &self.hostname_map {
|
||||
writeln!(&mut file, "{} {}", ip, hostnames.join(" "))?;
|
||||
}
|
||||
writeln!(&mut file, "{}", end_marker)?;
|
||||
}
|
||||
for line in &lines[insert..] {
|
||||
writeln!(&mut file, "{}", line)?;
|
||||
}
|
||||
|
||||
// Move the file atomically to avoid a partial state.
|
||||
fs::rename(&temp_path, &hosts_file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
64
macos/install.sh
Executable file
64
macos/install.sh
Executable file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd)"
|
||||
|
||||
info() {
|
||||
TERM=${TERM:-dumb} echo -e "$(tput setaf 4)- $@$(tput sgr0)" 1>&2
|
||||
}
|
||||
|
||||
cmd() {
|
||||
echo "[#] $*" >&2
|
||||
"$@"
|
||||
}
|
||||
|
||||
set -e
|
||||
|
||||
info "building innernet binary."
|
||||
cmd cargo build --release --bin innernet
|
||||
|
||||
info "installing innernet binary."
|
||||
cmd sudo cp -f $ROOT_DIR/target/release/innernet /usr/local/bin
|
||||
cmd sudo ln -s /usr/local/bin/innernet /usr/local/bin/inn
|
||||
|
||||
if ! which wg > /dev/null; then
|
||||
info "installing wireguard."
|
||||
cmd brew install wireguard-tools
|
||||
fi
|
||||
|
||||
info "installing launch daemon for innernet daemon script."
|
||||
echo "\
|
||||
<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple Computer//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
|
||||
<plist version=\"1.0\">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>no.tonari.innernet</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/innernet</string>
|
||||
<string>fetch</string>
|
||||
<string>--daemon</string>
|
||||
<string>--interval</string>
|
||||
<string>60</string>
|
||||
</array>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>LaunchOnlyOnce</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/innernet.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/innernet.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
" | cmd sudo tee /Library/LaunchDaemons/no.tonari.innernet.plist
|
||||
cmd sudo launchctl enable system/no.tonari.innernet
|
||||
cmd sudo launchctl bootstrap system /Library/LaunchDaemons/no.tonari.innernet.plist
|
||||
|
5
release.toml
Normal file
5
release.toml
Normal file
@ -0,0 +1,5 @@
|
||||
consolidate-commits = true
|
||||
disable-publish = true
|
||||
disable-push = true
|
||||
disable-tag = true
|
||||
no-dev-version = true
|
6
rustfmt.toml
Normal file
6
rustfmt.toml
Normal file
@ -0,0 +1,6 @@
|
||||
imports_granularity = "Crate"
|
||||
indent_style = "Block"
|
||||
match_block_trailing_comma = true
|
||||
reorder_impl_items = true
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
1
server/.gitignore
vendored
Normal file
1
server/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
54
server/Cargo.toml
Normal file
54
server/Cargo.toml
Normal file
@ -0,0 +1,54 @@
|
||||
[package]
|
||||
authors = ["Jake McGinty <me@jake.su>"]
|
||||
description = "A server to coordinate innernet networks."
|
||||
edition = "2018"
|
||||
license = "MIT"
|
||||
name = "server"
|
||||
publish = false
|
||||
readme = "README.md"
|
||||
version = "1.0.0"
|
||||
|
||||
[[bin]]
|
||||
name = "innernet-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
colored = "2"
|
||||
crossbeam = "0.8"
|
||||
dashmap = "4"
|
||||
dialoguer = "0.8"
|
||||
hyper = "0.14"
|
||||
indoc = "1"
|
||||
ipnetwork = { git = "https://github.com/mcginty/ipnetwork" }
|
||||
libc = "0.2"
|
||||
libsqlite3-sys = "0.20"
|
||||
log = "0.4"
|
||||
parking_lot = "0.11"
|
||||
pretty_env_logger = "0.4"
|
||||
regex = { version = "1", default-features = false, features = ["std"] }
|
||||
rusqlite = "0.24"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared = { path = "../shared" }
|
||||
structopt = "0.3"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
toml = "0.5"
|
||||
warp = { version = "0.3", default-features = false }
|
||||
wgctrl = { path = "../wgctrl-rs" }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[package.metadata.deb]
|
||||
assets = [
|
||||
["target/release/innernet-server", "usr/bin/", "755"],
|
||||
["innernet-server@.service", "usr/lib/systemd/system/", "644"],
|
||||
["../doc/innernet-server.8.gz", "usr/share/man/man8/", "644"],
|
||||
]
|
||||
depends = "libc6, libgcc1, libsqlite3-0, zlib1g, systemd"
|
||||
maintainer = "tonari <hey@tonari.no>"
|
||||
name = "innernet-server"
|
||||
priority = "optional"
|
||||
section = "net"
|
13
server/innernet-server@.service
Normal file
13
server/innernet-server@.service
Normal file
@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=innernet server for %I
|
||||
After=network-online.target nss-lookup.target
|
||||
Wants=network-online.target nss-lookup.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment="RUST_LOG=info"
|
||||
ExecStart=/usr/bin/innernet-server serve %i
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
83
server/src/api/admin/association.rs
Normal file
83
server/src/api/admin/association.rs
Normal file
@ -0,0 +1,83 @@
|
||||
//! A table to describe which CIDRs another CIDR is allowed to peer with.
|
||||
//!
|
||||
//! A peer belongs to one parent CIDR, and can by default see all peers within that parent.
|
||||
|
||||
use crate::{db::DatabaseAssociation, form_body, with_admin_session, AdminSession, Context};
|
||||
use shared::AssociationContents;
|
||||
use warp::{http::StatusCode, Filter};
|
||||
|
||||
pub mod routes {
|
||||
use super::*;
|
||||
|
||||
pub fn all(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path("associations").and(
|
||||
list(context.clone())
|
||||
.or(create(context.clone()))
|
||||
.or(delete(context)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::list)
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(form_body())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::create)
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::delete())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::delete)
|
||||
}
|
||||
}
|
||||
|
||||
mod handlers {
|
||||
|
||||
use super::*;
|
||||
|
||||
pub async fn create(
|
||||
contents: AssociationContents,
|
||||
session: AdminSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
|
||||
DatabaseAssociation::create(&conn, contents)?;
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn list(session: AdminSession) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
let auths = DatabaseAssociation::list(&conn)?;
|
||||
|
||||
Ok(warp::reply::json(&auths))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
id: i64,
|
||||
session: AdminSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
DatabaseAssociation::delete(&conn, id)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
}
|
317
server/src/api/admin/cidr.rs
Normal file
317
server/src/api/admin/cidr.rs
Normal file
@ -0,0 +1,317 @@
|
||||
use crate::{db::DatabaseCidr, form_body, with_admin_session, AdminSession, Context};
|
||||
use shared::CidrContents;
|
||||
use warp::{
|
||||
http::{response::Response, StatusCode},
|
||||
Filter,
|
||||
};
|
||||
|
||||
pub mod routes {
|
||||
use super::*;
|
||||
|
||||
pub fn all(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path("cidrs").and(
|
||||
list(context.clone())
|
||||
.or(create(context.clone()))
|
||||
.or(delete(context)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::list)
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(form_body())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::create)
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::delete())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::delete)
|
||||
}
|
||||
}
|
||||
|
||||
mod handlers {
|
||||
use super::*;
|
||||
|
||||
pub async fn create(
|
||||
contents: CidrContents,
|
||||
session: AdminSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
|
||||
let cidr = DatabaseCidr::create(&conn, contents)?;
|
||||
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(serde_json::to_string(&cidr).unwrap())
|
||||
.unwrap();
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn list(session: AdminSession) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
let cidrs = DatabaseCidr::list(&conn)?;
|
||||
|
||||
Ok(warp::reply::json(&cidrs))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
id: i64,
|
||||
session: AdminSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
DatabaseCidr::delete(&conn, id)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{test, DatabasePeer};
|
||||
use anyhow::Result;
|
||||
use shared::Cidr;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cidr_add() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let old_cidrs = DatabaseCidr::list(&server.db().lock())?;
|
||||
|
||||
let contents = CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
};
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/cidrs")
|
||||
.body(serde_json::to_string(&contents)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), 201);
|
||||
|
||||
let cidr_res: Cidr = serde_json::from_slice(&res.body())?;
|
||||
assert_eq!(contents, cidr_res.contents);
|
||||
|
||||
let new_cidrs = DatabaseCidr::list(&server.db().lock())?;
|
||||
assert_eq!(old_cidrs.len() + 1, new_cidrs.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cidr_name_uniqueness() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let contents = CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
};
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/cidrs")
|
||||
.body(serde_json::to_string(&contents)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert!(res.status().is_success());
|
||||
let cidr_res: Cidr = serde_json::from_slice(&res.body())?;
|
||||
|
||||
let contents = CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_SUBCIDR.parse()?,
|
||||
parent: Some(cidr_res.id),
|
||||
};
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/cidrs")
|
||||
.body(serde_json::to_string(&contents)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert!(!res.status().is_success());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cidr_create_auth() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let contents = CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
};
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::USER1_PEER_IP)
|
||||
.path("/v1/admin/cidrs")
|
||||
.body(serde_json::to_string(&contents)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert!(!res.status().is_success());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cidr_bad_parent() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let contents = CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
};
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/cidrs")
|
||||
.body(serde_json::to_string(&contents)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert!(res.status().is_success());
|
||||
|
||||
let contents = CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_SUBCIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
};
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/cidrs")
|
||||
.body(serde_json::to_string(&contents)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert!(!res.status().is_success());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cidr_overlap() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let contents = CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: "10.80.1.0/21".parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
};
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/cidrs")
|
||||
.body(serde_json::to_string(&contents)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cidr_delete_fail_with_child_cidr() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let experimental_cidr = DatabaseCidr::create(
|
||||
&server.db().lock(),
|
||||
CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
},
|
||||
)?;
|
||||
let experimental_subcidr = DatabaseCidr::create(
|
||||
&server.db().lock(),
|
||||
CidrContents {
|
||||
name: "experimental subcidr".to_string(),
|
||||
cidr: test::EXPERIMENTAL_SUBCIDR.parse()?,
|
||||
parent: Some(experimental_cidr.id),
|
||||
},
|
||||
)?;
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
let res = test::request_from_ip(test::ADMIN_PEER_IP)
|
||||
.method("DELETE")
|
||||
.path(&format!("/v1/admin/cidrs/{}", experimental_cidr.id))
|
||||
.reply(&filter)
|
||||
.await;
|
||||
// Should fail because child CIDR exists.
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let res = test::request_from_ip(test::ADMIN_PEER_IP)
|
||||
.method("DELETE")
|
||||
.path(&format!("/v1/admin/cidrs/{}", experimental_subcidr.id))
|
||||
.reply(&filter)
|
||||
.await;
|
||||
// Deleting child "leaf" CIDR should fail because peer exists inside it.
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let res = test::request_from_ip(test::ADMIN_PEER_IP)
|
||||
.method("DELETE")
|
||||
.path(&format!("/v1/admin/cidrs/{}", experimental_cidr.id))
|
||||
.reply(&filter)
|
||||
.await;
|
||||
// Now deleting parent CIDR should work because child is gone.
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cidr_delete_fail_with_peer_inside() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let experimental_cidr = DatabaseCidr::create(
|
||||
&server.db().lock(),
|
||||
CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
},
|
||||
)?;
|
||||
|
||||
let _experiment_peer = DatabasePeer::create(
|
||||
&server.db().lock(),
|
||||
test::peer_contents(
|
||||
"experiment-peer",
|
||||
test::EXPERIMENT_SUBCIDR_PEER_IP,
|
||||
experimental_cidr.id,
|
||||
false,
|
||||
)?,
|
||||
)?;
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
let res = test::request_from_ip(test::ADMIN_PEER_IP)
|
||||
.method("DELETE")
|
||||
.path(&format!("/v1/admin/cidrs/{}", experimental_cidr.id))
|
||||
.reply(&filter)
|
||||
.await;
|
||||
// Deleting CIDR should fail because peer exists inside it.
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
17
server/src/api/admin/mod.rs
Normal file
17
server/src/api/admin/mod.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use warp::Filter;
|
||||
|
||||
use crate::Context;
|
||||
|
||||
pub mod association;
|
||||
pub mod cidr;
|
||||
pub mod peer;
|
||||
|
||||
pub fn routes(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path("admin").and(
|
||||
association::routes::all(context.clone())
|
||||
.or(cidr::routes::all(context.clone()))
|
||||
.or(peer::routes::all(context.clone())),
|
||||
)
|
||||
}
|
443
server/src/api/admin/peer.rs
Normal file
443
server/src/api/admin/peer.rs
Normal file
@ -0,0 +1,443 @@
|
||||
use crate::{
|
||||
api::inject_endpoints, db::DatabasePeer, with_admin_session, AdminSession, Context, ServerError,
|
||||
};
|
||||
use shared::PeerContents;
|
||||
use warp::{
|
||||
http::{response::Response, StatusCode},
|
||||
Filter,
|
||||
};
|
||||
use wgctrl::DeviceConfigBuilder;
|
||||
|
||||
pub mod routes {
|
||||
use crate::form_body;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn all(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path("peers").and(
|
||||
list(context.clone())
|
||||
.or(list(context.clone()))
|
||||
.or(create(context.clone()))
|
||||
.or(update(context.clone()))
|
||||
.or(delete(context)),
|
||||
)
|
||||
}
|
||||
|
||||
// POST /v1/admin/peers
|
||||
pub fn create(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path::end()
|
||||
.and(warp::post())
|
||||
.and(form_body())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::create)
|
||||
}
|
||||
|
||||
// PUT /v1/admin/peers/:id
|
||||
pub fn update(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::put())
|
||||
.and(form_body())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::update)
|
||||
}
|
||||
|
||||
// GET /v1/admin/peers
|
||||
pub fn list(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path::end()
|
||||
.and(warp::get())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::list)
|
||||
}
|
||||
|
||||
// DELETE /v1/admin/peers/:id
|
||||
pub fn delete(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path::param()
|
||||
.and(warp::path::end())
|
||||
.and(warp::delete())
|
||||
.and(with_admin_session(context))
|
||||
.and_then(handlers::delete)
|
||||
}
|
||||
}
|
||||
|
||||
mod handlers {
|
||||
use super::*;
|
||||
|
||||
pub async fn create(
|
||||
form: PeerContents,
|
||||
session: AdminSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
|
||||
let peer = DatabasePeer::create(&conn, form)?;
|
||||
log::info!("adding peer {}", &*peer);
|
||||
|
||||
if cfg!(not(test)) {
|
||||
// Update the current WireGuard interface with the new peers.
|
||||
DeviceConfigBuilder::new()
|
||||
.add_peer((&*peer).into())
|
||||
.apply(&session.context.interface)
|
||||
.map_err(|_| ServerError::WireGuard)?;
|
||||
log::info!("updated WireGuard interface, adding {}", &*peer);
|
||||
}
|
||||
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.body(serde_json::to_string(&*peer).unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
id: i64,
|
||||
form: PeerContents,
|
||||
session: AdminSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
let mut peer = DatabasePeer::get(&conn, id)?;
|
||||
peer.update(&conn, form)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// List all peers, including disabled ones. This is an admin-only endpoint.
|
||||
pub async fn list(session: AdminSession) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
let mut peers = DatabasePeer::list(&conn)?
|
||||
.into_iter()
|
||||
.map(|peer| peer.inner)
|
||||
.collect::<Vec<_>>();
|
||||
inject_endpoints(&session, &mut peers);
|
||||
Ok(warp::reply::json(&peers))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
id: i64,
|
||||
session: AdminSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
DatabasePeer::disable(&conn, id)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test;
|
||||
use anyhow::Result;
|
||||
use shared::Peer;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let old_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
|
||||
let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::CREATED);
|
||||
// The response contains the new peer information.
|
||||
let peer_res: Peer = serde_json::from_slice(&res.body())?;
|
||||
assert_eq!(peer, peer_res.contents);
|
||||
|
||||
// The number of peer entries in the database increased by 1.
|
||||
let new_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
assert_eq!(old_peers.len() + 1, new_peers.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer_with_invalid_name() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let peer = test::developer_peer_contents("devel oper", "10.80.64.4")?;
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer_with_duplicate_name() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let old_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
|
||||
// Try to add a peer with a name that is already taken.
|
||||
let peer = test::developer_peer_contents("developer2", "10.80.64.4")?;
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// The number of peer entries in the database should not change.
|
||||
let new_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
assert_eq!(old_peers.len(), new_peers.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer_with_duplicate_ip() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let old_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
|
||||
// Try to add a peer with an IP that is already taken.
|
||||
let peer = test::developer_peer_contents("developer3", "10.80.64.3")?;
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// The number of peer entries in the database should not change.
|
||||
let new_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
assert_eq!(old_peers.len(), new_peers.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer_with_outside_cidr_range_ip() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
let old_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
|
||||
// Try to add IP outside of the CIDR network.
|
||||
let peer = test::developer_peer_contents("developer3", "10.80.65.4")?;
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Try to use the network address as peer IP.
|
||||
let peer = test::developer_peer_contents("developer3", "10.80.64.0")?;
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// Try to use the broadcast address as peer IP.
|
||||
let peer = test::developer_peer_contents("developer3", "10.80.64.255")?;
|
||||
let res = test::post_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// The number of peer entries in the database should not change.
|
||||
let new_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
assert_eq!(old_peers.len(), new_peers.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_peer_from_non_admin() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
|
||||
|
||||
// Try to create a new developer peer from a user peer.
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::post_request_from_ip(test::USER1_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_peer_from_admin() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let old_peer = DatabasePeer::get(&server.db.lock(), test::DEVELOPER1_PEER_ID)?;
|
||||
|
||||
let change = PeerContents {
|
||||
name: "new-peer-name".to_string(),
|
||||
..old_peer.contents.clone()
|
||||
};
|
||||
|
||||
// Try to create a new developer peer from a user peer.
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::put_request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path(&format!("/v1/admin/peers/{}", test::DEVELOPER1_PEER_ID))
|
||||
.body(serde_json::to_string(&change)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let new_peer = DatabasePeer::get(&server.db.lock(), test::DEVELOPER1_PEER_ID)?;
|
||||
assert_eq!(new_peer.name, "new-peer-name");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_peer_from_non_admin() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
|
||||
|
||||
// Try to create a new developer peer from a user peer.
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::put_request_from_ip(test::USER1_PEER_IP)
|
||||
.path(&format!("/v1/admin/peers/{}", test::ADMIN_PEER_ID))
|
||||
.body(serde_json::to_string(&peer)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_peers_from_admin() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::request_from_ip(test::ADMIN_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let peers: Vec<Peer> = serde_json::from_slice(&res.body())?;
|
||||
let peer_names = peers.iter().map(|p| &p.contents.name).collect::<Vec<_>>();
|
||||
// An admin peer should see all the peers.
|
||||
assert_eq!(
|
||||
&[
|
||||
"innernet-server",
|
||||
"admin",
|
||||
"developer1",
|
||||
"developer2",
|
||||
"user1",
|
||||
"user2"
|
||||
],
|
||||
&peer_names[..]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_peers_from_non_admin() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::request_from_ip(test::DEVELOPER1_PEER_IP)
|
||||
.path("/v1/admin/peers")
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
let old_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
|
||||
let res = test::request_from_ip(test::ADMIN_PEER_IP)
|
||||
.method("DELETE")
|
||||
.path(&format!("/v1/admin/peers/{}", test::USER1_PEER_ID))
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert!(res.status().is_success());
|
||||
|
||||
// The number of peer entries in the database decreased by 1.
|
||||
let all_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
let new_peers = all_peers.iter().filter(|p| !p.is_disabled).count();
|
||||
assert_eq!(old_peers.len() - 1, new_peers);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_from_non_admin() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
let old_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
|
||||
let res = test::request_from_ip(test::DEVELOPER1_PEER_IP)
|
||||
.method("DELETE")
|
||||
.path(&format!("/v1/admin/peers/{}", test::USER1_PEER_ID))
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// The number of peer entries in the database hasn't changed.
|
||||
let new_peers = DatabasePeer::list(&server.db().lock())?;
|
||||
assert_eq!(old_peers.len(), new_peers.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_unknown_id() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
let res = test::request_from_ip(test::ADMIN_PEER_IP)
|
||||
.method("DELETE")
|
||||
.path(&format!("/v1/admin/peers/{}", test::USER1_PEER_ID + 100))
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
// Trying to delete a peer of non-existing ID will result in error.
|
||||
assert_eq!(res.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
18
server/src/api/mod.rs
Normal file
18
server/src/api/mod.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use shared::Peer;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
pub mod admin;
|
||||
pub mod user;
|
||||
|
||||
/// Inject the collected endpoints from the WG interface into a list of peers.
|
||||
/// This is essentially what adds NAT holepunching functionality.
|
||||
pub fn inject_endpoints(session: &Session, peers: &mut Vec<Peer>) {
|
||||
for mut peer in peers {
|
||||
if peer.contents.endpoint.is_none() {
|
||||
if let Some(endpoint) = session.context.endpoints.get(&peer.public_key) {
|
||||
peer.contents.endpoint = Some(endpoint.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
380
server/src/api/user.rs
Normal file
380
server/src/api/user.rs
Normal file
@ -0,0 +1,380 @@
|
||||
use crate::{
|
||||
api::inject_endpoints,
|
||||
db::{DatabaseCidr, DatabasePeer},
|
||||
form_body, with_session, with_unredeemed_session, Context, ServerError, Session,
|
||||
UnredeemedSession,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use shared::{EndpointContents, PeerContents, RedeemContents, State, REDEEM_TRANSITION_WAIT};
|
||||
use warp::Filter;
|
||||
use wgctrl::DeviceConfigBuilder;
|
||||
|
||||
pub fn routes(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path("user").and(
|
||||
routes::state(context.clone())
|
||||
.or(routes::redeem(context.clone()))
|
||||
.or(routes::override_endpoint(context.clone())),
|
||||
)
|
||||
}
|
||||
|
||||
pub mod routes {
|
||||
use super::*;
|
||||
|
||||
pub fn state(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path("state")
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.and(with_session(context))
|
||||
.and_then(handlers::state)
|
||||
}
|
||||
|
||||
pub fn redeem(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path("redeem")
|
||||
.and(warp::path::end())
|
||||
.and(warp::post())
|
||||
.and(form_body())
|
||||
.and(with_unredeemed_session(context))
|
||||
.and_then(handlers::redeem)
|
||||
}
|
||||
|
||||
pub fn override_endpoint(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
|
||||
warp::path("endpoint")
|
||||
.and(warp::path::end())
|
||||
.and(warp::put())
|
||||
.and(form_body())
|
||||
.and(with_session(context))
|
||||
.and_then(handlers::endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
mod handlers {
|
||||
use super::*;
|
||||
|
||||
/// Get the current state of the network, in the eyes of the current peer.
|
||||
///
|
||||
/// This endpoint returns the visible CIDRs and Peers, providing all the necessary
|
||||
/// information for the peer to create connections to all of them.
|
||||
pub async fn state(session: Session) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
let selected_peer = DatabasePeer::get(&conn, session.peer.id)?;
|
||||
|
||||
let cidrs: Vec<_> = DatabaseCidr::list(&conn)?;
|
||||
|
||||
let mut peers: Vec<_> = selected_peer
|
||||
.get_all_allowed_peers(&conn)?
|
||||
.into_iter()
|
||||
.map(|p| p.inner)
|
||||
.collect();
|
||||
inject_endpoints(&session, &mut peers);
|
||||
|
||||
Ok(warp::reply::json(&State { cidrs, peers }))
|
||||
}
|
||||
|
||||
/// Redeems an invitation. An invitation includes a WireGuard keypair generated by either the server
|
||||
/// or a peer with admin rights.
|
||||
///
|
||||
/// Redemption is the process of an invitee generating their own keypair and exchanging their temporary
|
||||
/// key with their permanent one.
|
||||
///
|
||||
/// Until this API endpoint is called, the invited peer will not show up to other peers, and once
|
||||
/// it is called and succeeds, it cannot be called again.
|
||||
pub async fn redeem(
|
||||
form: RedeemContents,
|
||||
session: UnredeemedSession,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
let mut selected_peer = DatabasePeer::get(&conn, session.peer.id)?;
|
||||
|
||||
let old_public_key = wgctrl::Key::from_base64(&selected_peer.public_key)
|
||||
.map_err(|_| ServerError::WireGuard)?;
|
||||
|
||||
if selected_peer.is_redeemed {
|
||||
Ok(StatusCode::GONE)
|
||||
} else {
|
||||
selected_peer.redeem(&conn, &form.public_key)?;
|
||||
|
||||
if cfg!(not(test)) {
|
||||
let interface = session.context.interface.clone();
|
||||
|
||||
// If we were to modify the WireGuard interface immediately, the HTTP response wouldn't
|
||||
// get through. Instead, we need to wait a reasonable amount for the HTTP response to
|
||||
// flush, then update the interface.
|
||||
//
|
||||
// The client is also expected to wait the same amount of time after receiving a success
|
||||
// response from /redeem.
|
||||
//
|
||||
// This might be avoidable if we were able to run code after we were certain the response
|
||||
// had flushed over the TCP socket, but that isn't easily accessible from this high-level
|
||||
// web framework.
|
||||
tokio::task::spawn(async move {
|
||||
tokio::time::sleep(*REDEEM_TRANSITION_WAIT).await;
|
||||
log::info!(
|
||||
"WireGuard: adding new peer {}, removing old pubkey {}",
|
||||
&*selected_peer,
|
||||
old_public_key.to_base64()
|
||||
);
|
||||
DeviceConfigBuilder::new()
|
||||
.remove_peer_by_key(&old_public_key)
|
||||
.add_peer((&*selected_peer).into())
|
||||
.apply(&interface)
|
||||
.map_err(|e| log::error!("{:?}", e))
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Redeems an invitation. An invitation includes a WireGuard keypair generated by either the server
|
||||
/// or a peer with admin rights.
|
||||
///
|
||||
/// Redemption is the process of an invitee generating their own keypair and exchanging their temporary
|
||||
/// key with their permanent one.
|
||||
///
|
||||
/// Until this API endpoint is called, the invited peer will not show up to other peers, and once
|
||||
/// it is called and succeeds, it cannot be called again.
|
||||
pub async fn endpoint(
|
||||
contents: EndpointContents,
|
||||
session: Session,
|
||||
) -> Result<impl warp::Reply, warp::Rejection> {
|
||||
let conn = session.context.db.lock();
|
||||
let mut selected_peer = DatabasePeer::get(&conn, session.peer.id)?;
|
||||
selected_peer.update(
|
||||
&conn,
|
||||
PeerContents {
|
||||
endpoint: contents.into(),
|
||||
..selected_peer.contents.clone()
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{db::DatabaseAssociation, test};
|
||||
use anyhow::Result;
|
||||
use shared::{AssociationContents, CidrContents, EndpointContents};
|
||||
use warp::http::StatusCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_state_from_developer1() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
let res = test::request_from_ip(test::DEVELOPER1_PEER_IP)
|
||||
.path("/v1/user/state")
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let State { peers, .. } = serde_json::from_slice(&res.body())?;
|
||||
let mut peer_names = peers.iter().map(|p| &p.contents.name).collect::<Vec<_>>();
|
||||
peer_names.sort();
|
||||
// Developers should see only peers in infra CIDR and developer CIDR.
|
||||
assert_eq!(
|
||||
&["developer1", "developer2", "innernet-server"],
|
||||
&peer_names[..]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_override_endpoint() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
assert_eq!(
|
||||
test::put_request_from_ip(test::DEVELOPER1_PEER_IP)
|
||||
.path("/v1/user/endpoint")
|
||||
.body(serde_json::to_string(&EndpointContents::Set(
|
||||
"1.1.1.1:51820".parse()?
|
||||
))?)
|
||||
.reply(&filter)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
|
||||
println!("{}", serde_json::to_string(&EndpointContents::Unset)?);
|
||||
assert_eq!(
|
||||
test::put_request_from_ip(test::DEVELOPER1_PEER_IP)
|
||||
.path("/v1/user/endpoint")
|
||||
.body(serde_json::to_string(&EndpointContents::Unset)?)
|
||||
.reply(&filter)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
test::put_request_from_ip(test::DEVELOPER1_PEER_IP)
|
||||
.path("/v1/user/endpoint")
|
||||
.body("endpoint=blah")
|
||||
.reply(&filter)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_peers_from_unknown_ip() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
// Request comes from an unknown IP.
|
||||
let res = test::request_from_ip("10.80.80.80")
|
||||
.path("/v1/user/state")
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_peers_for_developer_subcidr() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = crate::routes(server.context());
|
||||
{
|
||||
let db = server.db.lock();
|
||||
let cidr = DatabaseCidr::create(
|
||||
&db,
|
||||
CidrContents {
|
||||
name: "experiment cidr".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
},
|
||||
)?;
|
||||
let subcidr = DatabaseCidr::create(
|
||||
&db,
|
||||
CidrContents {
|
||||
name: "experiment subcidr".to_string(),
|
||||
cidr: test::EXPERIMENTAL_SUBCIDR.parse()?,
|
||||
parent: Some(cidr.id),
|
||||
},
|
||||
)?;
|
||||
DatabasePeer::create(
|
||||
&db,
|
||||
test::peer_contents(
|
||||
"experiment-peer",
|
||||
test::EXPERIMENT_SUBCIDR_PEER_IP,
|
||||
subcidr.id,
|
||||
false,
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// Add a peering between the developer's CIDR and the experimental *parent* cidr.
|
||||
DatabaseAssociation::create(
|
||||
&db,
|
||||
AssociationContents {
|
||||
cidr_id_1: test::DEVELOPER_CIDR_ID,
|
||||
cidr_id_2: cidr.id,
|
||||
},
|
||||
)?;
|
||||
DatabaseAssociation::create(
|
||||
&db,
|
||||
AssociationContents {
|
||||
cidr_id_1: test::INFRA_CIDR_ID,
|
||||
cidr_id_2: cidr.id,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
for ip in &[test::DEVELOPER1_PEER_IP, test::EXPERIMENT_SUBCIDR_PEER_IP] {
|
||||
let res = test::request_from_ip(ip)
|
||||
.path("/v1/user/state")
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let State { peers, .. } = serde_json::from_slice(&res.body())?;
|
||||
let mut peer_names = peers.iter().map(|p| &p.contents.name).collect::<Vec<_>>();
|
||||
peer_names.sort();
|
||||
// Developers should see only peers in infra CIDR and developer CIDR.
|
||||
assert_eq!(
|
||||
&[
|
||||
"developer1",
|
||||
"developer2",
|
||||
"experiment-peer",
|
||||
"innernet-server"
|
||||
],
|
||||
&peer_names[..]
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_redeem() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
|
||||
let experimental_cidr = DatabaseCidr::create(
|
||||
&server.db().lock(),
|
||||
CidrContents {
|
||||
name: "experimental".to_string(),
|
||||
cidr: test::EXPERIMENTAL_CIDR.parse()?,
|
||||
parent: Some(test::ROOT_CIDR_ID),
|
||||
},
|
||||
)?;
|
||||
|
||||
let mut peer_contents = test::peer_contents(
|
||||
"experiment-peer",
|
||||
test::EXPERIMENT_SUBCIDR_PEER_IP,
|
||||
experimental_cidr.id,
|
||||
false,
|
||||
)?;
|
||||
peer_contents.is_redeemed = false;
|
||||
let _experiment_peer = DatabasePeer::create(&server.db().lock(), peer_contents)?;
|
||||
|
||||
let filter = crate::routes(server.context());
|
||||
|
||||
// Step 1: Ensure that before redeeming, other endpoints aren't yet accessible.
|
||||
let res = test::request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
|
||||
.path("/v1/user/state")
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// Step 2: Ensure that redemption works.
|
||||
let body = RedeemContents {
|
||||
public_key: "YBVIgpfLbi/knrMCTEb0L6eVy0daiZnJJQkxBK9s+2I=".into(),
|
||||
};
|
||||
let res = test::post_request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
|
||||
.path("/v1/user/redeem")
|
||||
.body(serde_json::to_string(&body)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert!(res.status().is_success());
|
||||
|
||||
// Step 3: Ensure that a second attempt at redemption DOESN'T work.
|
||||
let res = test::post_request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
|
||||
.path("/v1/user/redeem")
|
||||
.body(serde_json::to_string(&body)?)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert!(res.status().is_client_error());
|
||||
|
||||
// Step 3: Ensure that after redemption, fetching state works.
|
||||
let res = test::request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
|
||||
.path("/v1/user/state")
|
||||
.reply(&filter)
|
||||
.await;
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
Ok(())
|
||||
}
|
||||
}
|
91
server/src/db/association.rs
Normal file
91
server/src/db/association.rs
Normal file
@ -0,0 +1,91 @@
|
||||
//! A table to describe which CIDRs another CIDR is allowed to peer with.
|
||||
//!
|
||||
//! A peer belongs to one parent CIDR, and can by default see all peers within that parent.
|
||||
|
||||
use crate::ServerError;
|
||||
use rusqlite::{params, Connection};
|
||||
use shared::{Association, AssociationContents};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
pub static CREATE_TABLE_SQL: &str = "CREATE TABLE associations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
cidr_id_1 INTEGER NOT NULL,
|
||||
cidr_id_2 INTEGER NOT NULL,
|
||||
UNIQUE(cidr_id_1, cidr_id_2),
|
||||
FOREIGN KEY (cidr_id_1)
|
||||
REFERENCES cidrs (id)
|
||||
ON UPDATE RESTRICT
|
||||
ON DELETE RESTRICT,
|
||||
FOREIGN KEY (cidr_id_2)
|
||||
REFERENCES cidrs (id)
|
||||
ON UPDATE RESTRICT
|
||||
ON DELETE RESTRICT
|
||||
)";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DatabaseAssociation {
|
||||
pub inner: Association,
|
||||
}
|
||||
|
||||
impl From<Association> for DatabaseAssociation {
|
||||
fn from(inner: Association) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DatabaseAssociation {
|
||||
type Target = Association;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for DatabaseAssociation {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseAssociation {
|
||||
pub fn create(
|
||||
conn: &Connection,
|
||||
contents: AssociationContents,
|
||||
) -> Result<Association, ServerError> {
|
||||
let AssociationContents {
|
||||
cidr_id_1,
|
||||
cidr_id_2,
|
||||
} = &contents;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO associations (cidr_id_1, cidr_id_2)
|
||||
VALUES (?1, ?2)",
|
||||
params![cidr_id_1, cidr_id_2],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(Association { id, contents })
|
||||
}
|
||||
|
||||
pub fn delete(conn: &Connection, id: i64) -> Result<(), ServerError> {
|
||||
conn.execute("DELETE FROM associations WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(conn: &Connection) -> Result<Vec<Association>, ServerError> {
|
||||
let mut stmt = conn.prepare_cached("SELECT id, cidr_id_1, cidr_id_2 FROM associations")?;
|
||||
let auth_iter = stmt.query_map(params![], |row| {
|
||||
let id = row.get(0)?;
|
||||
let cidr_id_1 = row.get(1)?;
|
||||
let cidr_id_2 = row.get(2)?;
|
||||
Ok(Association {
|
||||
id,
|
||||
contents: AssociationContents {
|
||||
cidr_id_1,
|
||||
cidr_id_2,
|
||||
},
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(auth_iter.collect::<Result<Vec<_>, rusqlite::Error>>()?)
|
||||
}
|
||||
}
|
141
server/src/db/cidr.rs
Normal file
141
server/src/db/cidr.rs
Normal file
@ -0,0 +1,141 @@
|
||||
use crate::ServerError;
|
||||
use ipnetwork::IpNetwork;
|
||||
use rusqlite::{params, Connection};
|
||||
use shared::{Cidr, CidrContents};
|
||||
use std::ops::Deref;
|
||||
|
||||
pub static CREATE_TABLE_SQL: &str = "CREATE TABLE cidrs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
ip TEXT NOT NULL,
|
||||
prefix INTEGER NOT NULL,
|
||||
parent INTEGER REFERENCES cidrs,
|
||||
UNIQUE(ip, prefix),
|
||||
FOREIGN KEY (parent)
|
||||
REFERENCES cidrs (id)
|
||||
ON UPDATE RESTRICT
|
||||
ON DELETE RESTRICT
|
||||
)";
|
||||
|
||||
pub struct DatabaseCidr {
|
||||
inner: Cidr,
|
||||
}
|
||||
|
||||
impl From<Cidr> for DatabaseCidr {
|
||||
fn from(inner: Cidr) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DatabaseCidr {
|
||||
type Target = Cidr;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseCidr {
|
||||
pub fn create(conn: &Connection, contents: CidrContents) -> Result<Cidr, ServerError> {
|
||||
let CidrContents { name, cidr, parent } = &contents;
|
||||
|
||||
log::debug!("creating {:?}", contents);
|
||||
|
||||
let attached_peers = conn.query_row(
|
||||
"SELECT COUNT(*) FROM peers WHERE cidr_id = ?1",
|
||||
params![parent],
|
||||
|row| row.get::<_, u32>(0),
|
||||
)?;
|
||||
if attached_peers > 0 {
|
||||
log::warn!("tried to add a CIDR to a parent that has peers assigned to it.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
|
||||
if let Some(parent_id) = parent {
|
||||
let cidrs = Self::list(conn)?;
|
||||
|
||||
let closest_parent = cidrs
|
||||
.iter()
|
||||
.filter(|current| cidr.is_subnet_of(current.cidr))
|
||||
.max_by_key(|current| current.cidr.prefix());
|
||||
|
||||
if let Some(closest_parent) = closest_parent {
|
||||
if closest_parent.id != *parent_id {
|
||||
log::warn!("tried to add a CIDR at the incorrect place in the tree (should be added to {}).", closest_parent.name);
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
} else {
|
||||
log::warn!("tried to add a CIDR outside of the root network range.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
|
||||
let parent_cidr = Self::get(conn, *parent_id)?.cidr;
|
||||
if !parent_cidr.contains(cidr.network()) || !parent_cidr.contains(cidr.broadcast()) {
|
||||
log::warn!("tried to add a CIDR with a network range outside of its parent.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
}
|
||||
|
||||
let overlapping_sibling = Self::list(conn)?
|
||||
.iter()
|
||||
.filter(|current| current.parent == *parent)
|
||||
.map(|sibling| sibling.cidr)
|
||||
.any(|sibling| {
|
||||
cidr.contains(sibling.network())
|
||||
|| cidr.contains(sibling.broadcast())
|
||||
|| sibling.contains(cidr.network())
|
||||
|| sibling.contains(cidr.broadcast())
|
||||
});
|
||||
|
||||
if overlapping_sibling {
|
||||
log::warn!("tried to add a CIDR that overlaps with a sibling.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO cidrs (name, ip, prefix, parent)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![name, cidr.ip().to_string(), cidr.prefix() as i32, parent],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(Cidr { id, contents })
|
||||
}
|
||||
|
||||
pub fn delete(conn: &Connection, id: i64) -> Result<(), ServerError> {
|
||||
conn.execute("DELETE FROM cidrs WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn from_row(row: &rusqlite::Row) -> Result<Cidr, rusqlite::Error> {
|
||||
let id = row.get(0)?;
|
||||
let name = row.get(1)?;
|
||||
let ip: String = row.get(2)?;
|
||||
let prefix = row.get(3)?;
|
||||
let cidr = IpNetwork::new(
|
||||
ip.parse()
|
||||
.map_err(|_| rusqlite::Error::ExecuteReturnedResults)?,
|
||||
prefix,
|
||||
)
|
||||
.map_err(|_| rusqlite::Error::ExecuteReturnedResults)?;
|
||||
let parent = row.get(4)?;
|
||||
Ok(Cidr {
|
||||
id,
|
||||
contents: CidrContents { name, cidr, parent },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(conn: &Connection, id: i64) -> Result<Cidr, ServerError> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT id, name, ip, prefix, parent FROM cidrs WHERE id = ?1",
|
||||
params![id],
|
||||
Self::from_row,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn list(conn: &Connection) -> Result<Vec<Cidr>, ServerError> {
|
||||
let mut stmt = conn.prepare_cached("SELECT id, name, ip, prefix, parent FROM cidrs")?;
|
||||
let cidr_iter = stmt.query_map(params![], Self::from_row)?;
|
||||
|
||||
Ok(cidr_iter.collect::<Result<Vec<_>, rusqlite::Error>>()?)
|
||||
}
|
||||
}
|
7
server/src/db/mod.rs
Normal file
7
server/src/db/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod association;
|
||||
pub mod cidr;
|
||||
pub mod peer;
|
||||
|
||||
pub use association::DatabaseAssociation;
|
||||
pub use cidr::DatabaseCidr;
|
||||
pub use peer::DatabasePeer;
|
283
server/src/db/peer.rs
Normal file
283
server/src/db/peer.rs
Normal file
@ -0,0 +1,283 @@
|
||||
use super::DatabaseCidr;
|
||||
use crate::ServerError;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use rusqlite::{params, Connection};
|
||||
use shared::{Peer, PeerContents, PERSISTENT_KEEPALIVE_INTERVAL_SECS};
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
use structopt::lazy_static;
|
||||
|
||||
pub static CREATE_TABLE_SQL: &str = "CREATE TABLE peers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE, /* The canonical name for the peer in canonical hostname(7) format. */
|
||||
ip TEXT NOT NULL UNIQUE, /* The WireGuard-internal IP address assigned to the peer. */
|
||||
public_key TEXT NOT NULL UNIQUE, /* The WireGuard public key of the peer. */
|
||||
endpoint TEXT, /* The optional external endpoint ([ip]:[port]) of the peer. */
|
||||
cidr_id INTEGER NOT NULL, /* The ID of the peer's parent CIDR. */
|
||||
is_admin INTEGER DEFAULT 0 NOT NULL, /* Admin capabilities are per-peer, not per-CIDR. */
|
||||
is_disabled INTEGER DEFAULT 0 NOT NULL, /* Is the peer disabled? (peers cannot be deleted) */
|
||||
is_redeemed INTEGER DEFAULT 0 NOT NULL, /* Has the peer redeemed their invite yet? */
|
||||
FOREIGN KEY (cidr_id)
|
||||
REFERENCES cidrs (id)
|
||||
ON UPDATE RESTRICT
|
||||
ON DELETE RESTRICT
|
||||
)";
|
||||
|
||||
lazy_static! {
|
||||
/// Regex to match the requirements of hostname(7), needed to have peers also be reachable hostnames.
|
||||
/// Note that the full length also must be maximum 63 characters, which this regex does not check.
|
||||
static ref PEER_NAME_REGEX: Regex = Regex::new(r"^([a-z0-9]-?)*[a-z0-9]$").unwrap();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DatabasePeer {
|
||||
pub inner: Peer,
|
||||
}
|
||||
|
||||
impl From<Peer> for DatabasePeer {
|
||||
fn from(inner: Peer) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DatabasePeer {
|
||||
type Target = Peer;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for DatabasePeer {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabasePeer {
|
||||
pub fn create(conn: &Connection, contents: PeerContents) -> Result<Self, ServerError> {
|
||||
let PeerContents {
|
||||
name,
|
||||
ip,
|
||||
cidr_id,
|
||||
public_key,
|
||||
endpoint,
|
||||
is_admin,
|
||||
is_disabled,
|
||||
is_redeemed,
|
||||
..
|
||||
} = &contents;
|
||||
log::info!("creating peer {:?}", contents);
|
||||
|
||||
if !Self::is_valid_name(&name) {
|
||||
log::warn!("peer name is invalid, must conform to hostname(7) requirements.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
|
||||
let cidr = DatabaseCidr::get(conn, *cidr_id)?;
|
||||
if !cidr.cidr.contains(*ip) {
|
||||
log::warn!("tried to add peer with IP outside of parent CIDR range.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
|
||||
if !cidr.cidr.is_assignable(*ip) {
|
||||
println!("Peer IP cannot be the network or broadcast IP of CIDRs with network prefixes under 31.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO peers (name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
name,
|
||||
ip.to_string(),
|
||||
cidr_id,
|
||||
&public_key,
|
||||
endpoint.map(|endpoint| endpoint.to_string()),
|
||||
is_admin,
|
||||
is_disabled,
|
||||
is_redeemed,
|
||||
],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(Peer { id, contents }.into())
|
||||
}
|
||||
|
||||
fn is_valid_name(name: &str) -> bool {
|
||||
name.len() < 64 && PEER_NAME_REGEX.is_match(name)
|
||||
}
|
||||
|
||||
/// Update self with new contents, validating them and updating the backend in the process.
|
||||
pub fn update(&mut self, conn: &Connection, contents: PeerContents) -> Result<(), ServerError> {
|
||||
if !Self::is_valid_name(&contents.name) {
|
||||
log::warn!("peer name is invalid, must conform to hostname(7) requirements.");
|
||||
return Err(ServerError::InvalidQuery);
|
||||
}
|
||||
|
||||
// We will only allow updates of certain fields at this point, disregarding any requests
|
||||
// for changes of IP address, public key, or parent CIDR, for security reasons.
|
||||
//
|
||||
// In the future, we may allow re-assignments of peers to new CIDRs, but it's easiest to
|
||||
// disregard that case for now to prevent possible attacks.
|
||||
let new_contents = PeerContents {
|
||||
name: contents.name,
|
||||
endpoint: contents.endpoint,
|
||||
is_admin: contents.is_admin,
|
||||
is_disabled: contents.is_disabled,
|
||||
..self.contents.clone()
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"UPDATE peers SET
|
||||
name = ?1,
|
||||
endpoint = ?2,
|
||||
is_admin = ?3,
|
||||
is_disabled = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
new_contents.name,
|
||||
new_contents.endpoint.map(|endpoint| endpoint.to_string()),
|
||||
new_contents.is_admin,
|
||||
new_contents.is_disabled,
|
||||
self.id,
|
||||
],
|
||||
)?;
|
||||
|
||||
self.contents = new_contents;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disable(conn: &Connection, id: i64) -> Result<(), ServerError> {
|
||||
match conn.execute(
|
||||
"UPDATE peers SET is_disabled = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)? {
|
||||
0 => Err(ServerError::NotFound),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redeem(&mut self, conn: &Connection, pubkey: &str) -> Result<(), ServerError> {
|
||||
match conn.execute(
|
||||
"UPDATE peers SET is_redeemed = 1, public_key = ?1 WHERE id = ?2 AND is_redeemed = 0",
|
||||
params![pubkey, self.id],
|
||||
)? {
|
||||
0 => Err(ServerError::NotFound),
|
||||
_ => {
|
||||
self.contents.public_key = pubkey.into();
|
||||
self.contents.is_redeemed = true;
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn from_row(row: &rusqlite::Row) -> Result<Self, rusqlite::Error> {
|
||||
let id = row.get(0)?;
|
||||
let name = row.get(1)?;
|
||||
let ip: IpAddr = row
|
||||
.get::<_, String>(2)?
|
||||
.parse()
|
||||
.map_err(|_| rusqlite::Error::ExecuteReturnedResults)?;
|
||||
let cidr_id = row.get(3)?;
|
||||
let public_key = row.get(4)?;
|
||||
let endpoint = row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.and_then(|endpoint| endpoint.parse().ok());
|
||||
let is_admin = row.get(6)?;
|
||||
let is_disabled = row.get(7)?;
|
||||
let is_redeemed = row.get(8)?;
|
||||
let persistent_keepalive_interval = Some(PERSISTENT_KEEPALIVE_INTERVAL_SECS);
|
||||
|
||||
Ok(Peer {
|
||||
id,
|
||||
contents: PeerContents {
|
||||
name,
|
||||
ip,
|
||||
cidr_id,
|
||||
public_key,
|
||||
endpoint,
|
||||
is_admin,
|
||||
is_disabled,
|
||||
persistent_keepalive_interval,
|
||||
is_redeemed,
|
||||
},
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
pub fn get(conn: &Connection, id: i64) -> Result<Self, ServerError> {
|
||||
let result = conn.query_row(
|
||||
"SELECT
|
||||
id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed
|
||||
FROM peers
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
Self::from_row,
|
||||
)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn get_from_ip(conn: &Connection, ip: IpAddr) -> Result<Self, ServerError> {
|
||||
let result = conn.query_row(
|
||||
"SELECT
|
||||
id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed
|
||||
FROM peers
|
||||
WHERE ip = ?1",
|
||||
params![ip.to_string()],
|
||||
Self::from_row,
|
||||
)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn get_all_allowed_peers(&self, conn: &Connection) -> Result<Vec<Self>, ServerError> {
|
||||
// This query is a handful, so an explanation of what's happening, and what each CTE does (https://sqlite.org/lang_with.html):
|
||||
//
|
||||
// 1. parent_of: Enumerate all ancestor CIDRs of the CIDR associated with peer.
|
||||
// 2. associated: Enumerate all auth associations between any of the above enumerated CIDRs.
|
||||
// 3. associated_subcidrs: For each association, list all peers by enumerating down each
|
||||
// associated CIDR's children and listing any peers belonging to them.
|
||||
//
|
||||
// NOTE that a forced association is created with the special "infra" CIDR with id 2 (1 being the root).
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"WITH
|
||||
parent_of(id, parent) AS (
|
||||
SELECT id, parent FROM cidrs WHERE id = ?1
|
||||
UNION ALL
|
||||
SELECT cidrs.id, cidrs.parent FROM cidrs JOIN parent_of ON parent_of.parent = cidrs.id
|
||||
),
|
||||
associated(cidr_id) as (
|
||||
SELECT associations.cidr_id_2 FROM associations, parent_of WHERE associations.cidr_id_1 = parent_of.id
|
||||
UNION
|
||||
SELECT associations.cidr_id_1 FROM associations, parent_of WHERE associations.cidr_id_2 = parent_of.id
|
||||
),
|
||||
associated_subcidrs(cidr_id) AS (
|
||||
VALUES(?1), (2)
|
||||
UNION
|
||||
SELECT cidr_id FROM associated
|
||||
UNION
|
||||
SELECT id FROM cidrs, associated_subcidrs WHERE cidrs.parent=associated_subcidrs.cidr_id
|
||||
)
|
||||
SELECT DISTINCT peers.id, peers.name, peers.ip, peers.cidr_id, peers.public_key, peers.endpoint, peers.is_admin, peers.is_disabled, peers.is_redeemed
|
||||
FROM peers
|
||||
JOIN associated_subcidrs ON peers.cidr_id=associated_subcidrs.cidr_id
|
||||
WHERE peers.is_disabled = 0 AND peers.is_redeemed = 1;",
|
||||
)?;
|
||||
let peers = stmt
|
||||
.query_map(params![self.cidr_id], Self::from_row)?
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
pub fn list(conn: &Connection) -> Result<Vec<Self>, ServerError> {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed FROM peers",
|
||||
)?;
|
||||
let peer_iter = stmt.query_map(params![], Self::from_row)?;
|
||||
|
||||
Ok(peer_iter.collect::<Result<_, _>>()?)
|
||||
}
|
||||
}
|
56
server/src/endpoints.rs
Normal file
56
server/src/endpoints.rs
Normal file
@ -0,0 +1,56 @@
|
||||
use crossbeam::channel::{self, select};
|
||||
use dashmap::DashMap;
|
||||
use wgctrl::DeviceInfo;
|
||||
|
||||
use std::{io, net::SocketAddr, sync::Arc, thread, time::Duration};
|
||||
|
||||
pub struct Endpoints {
|
||||
pub endpoints: Arc<DashMap<String, SocketAddr>>,
|
||||
stop_tx: channel::Sender<()>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Endpoints {
|
||||
type Target = DashMap<String, SocketAddr>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.endpoints
|
||||
}
|
||||
}
|
||||
|
||||
impl Endpoints {
|
||||
pub fn new(iface: &str) -> Result<Self, io::Error> {
|
||||
let endpoints = Arc::new(DashMap::new());
|
||||
let (stop_tx, stop_rx) = channel::bounded(1);
|
||||
|
||||
let iface = iface.to_owned();
|
||||
let thread_endpoints = endpoints.clone();
|
||||
log::info!("spawning endpoint watch thread.");
|
||||
if cfg!(not(test)) {
|
||||
thread::spawn(move || loop {
|
||||
select! {
|
||||
recv(stop_rx) -> _ => {
|
||||
break;
|
||||
},
|
||||
default => {
|
||||
if let Ok(info) = DeviceInfo::get_by_name(&iface) {
|
||||
for peer in info.peers {
|
||||
if let Some(endpoint) = peer.config.endpoint {
|
||||
thread_endpoints.insert(peer.config.public_key.to_base64(), endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Self { endpoints, stop_tx })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Endpoints {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop_tx.send(());
|
||||
}
|
||||
}
|
53
server/src/error.rs
Normal file
53
server/src/error.rs
Normal file
@ -0,0 +1,53 @@
|
||||
use thiserror::Error;
|
||||
use warp::{http::StatusCode, reject::Rejection};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ServerError {
|
||||
#[error("unauthorized access")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("object not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("invalid query")]
|
||||
InvalidQuery,
|
||||
|
||||
#[error("internal database error")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
|
||||
#[error("internal WireGuard error")]
|
||||
WireGuard,
|
||||
|
||||
#[error("internal I/O error")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl warp::reject::Reject for ServerError {}
|
||||
|
||||
pub async fn handle_rejection(err: Rejection) -> Result<StatusCode, warp::Rejection> {
|
||||
eprintln!("rejection: {:?}", err);
|
||||
if let Some(error) = err.find::<ServerError>() {
|
||||
Ok(error.into())
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a ServerError> for StatusCode {
|
||||
fn from(error: &ServerError) -> StatusCode {
|
||||
use ServerError::*;
|
||||
match error {
|
||||
Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
NotFound => StatusCode::NOT_FOUND,
|
||||
InvalidQuery => StatusCode::BAD_REQUEST,
|
||||
// Special-case the constraint violation situation.
|
||||
Database(rusqlite::Error::SqliteFailure(libsqlite3_sys::Error { code, .. }, ..))
|
||||
if *code == libsqlite3_sys::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
},
|
||||
Database(rusqlite::Error::QueryReturnedNoRows) => StatusCode::NOT_FOUND,
|
||||
WireGuard | Io(_) | Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
188
server/src/initialize.rs
Normal file
188
server/src/initialize.rs
Normal file
@ -0,0 +1,188 @@
|
||||
use crate::*;
|
||||
use db::DatabaseCidr;
|
||||
use dialoguer::{theme::ColorfulTheme, Input};
|
||||
use indoc::printdoc;
|
||||
use rusqlite::{params, Connection};
|
||||
use shared::{
|
||||
prompts::{self, hostname_validator},
|
||||
CidrContents, PeerContents, PERSISTENT_KEEPALIVE_INTERVAL_SECS,
|
||||
};
|
||||
use wgctrl::KeyPair;
|
||||
|
||||
fn create_database<P: AsRef<Path>>(
|
||||
database_path: P,
|
||||
) -> Result<Connection, Box<dyn std::error::Error>> {
|
||||
let conn = Connection::open(&database_path)?;
|
||||
conn.pragma_update(None, "foreign_keys", &1)?;
|
||||
conn.execute(db::peer::CREATE_TABLE_SQL, params![])?;
|
||||
conn.execute(db::association::CREATE_TABLE_SQL, params![])?;
|
||||
conn.execute(db::cidr::CREATE_TABLE_SQL, params![])?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
struct DbInitData {
|
||||
root_cidr_name: String,
|
||||
root_cidr: IpNetwork,
|
||||
server_cidr: IpNetwork,
|
||||
our_ip: IpAddr,
|
||||
public_key_base64: String,
|
||||
endpoint: SocketAddr,
|
||||
}
|
||||
|
||||
fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(), Error> {
|
||||
const SERVER_NAME: &str = "innernet-server";
|
||||
|
||||
let root_cidr = DatabaseCidr::create(
|
||||
&conn,
|
||||
CidrContents {
|
||||
name: db_init_data.root_cidr_name.clone(),
|
||||
cidr: db_init_data.root_cidr,
|
||||
parent: None,
|
||||
},
|
||||
)
|
||||
.map_err(|_| "failed to create root CIDR".to_string())?;
|
||||
|
||||
let server_cidr = DatabaseCidr::create(
|
||||
&conn,
|
||||
CidrContents {
|
||||
name: SERVER_NAME.into(),
|
||||
cidr: db_init_data.server_cidr,
|
||||
parent: Some(root_cidr.id),
|
||||
},
|
||||
)
|
||||
.map_err(|_| "failed to create innernet-server CIDR".to_string())?;
|
||||
|
||||
let _me = DatabasePeer::create(
|
||||
&conn,
|
||||
PeerContents {
|
||||
name: SERVER_NAME.into(),
|
||||
ip: db_init_data.our_ip,
|
||||
cidr_id: server_cidr.id,
|
||||
public_key: db_init_data.public_key_base64,
|
||||
endpoint: Some(db_init_data.endpoint),
|
||||
is_admin: true,
|
||||
is_disabled: false,
|
||||
is_redeemed: true,
|
||||
persistent_keepalive_interval: Some(PERSISTENT_KEEPALIVE_INTERVAL_SECS),
|
||||
},
|
||||
)
|
||||
.map_err(|_| "failed to create innernet peer.".to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_wizard(conf: &ServerConfig) -> Result<(), Error> {
|
||||
let theme = ColorfulTheme::default();
|
||||
|
||||
shared::ensure_dirs_exist(&[conf.config_dir(), conf.database_dir()]).map_err(|_| {
|
||||
format!(
|
||||
"Failed to create config and database directories {}",
|
||||
"(are you not running as root?)".bold()
|
||||
)
|
||||
})?;
|
||||
|
||||
let (name, root_cidr) = conf.root_cidr.clone().unwrap_or_else(|| {
|
||||
println!("Please specify a root CIDR, which will define the entire network.");
|
||||
let name: String = Input::with_theme(&theme)
|
||||
.with_prompt("Network name")
|
||||
.validate_with(hostname_validator)
|
||||
.interact()
|
||||
.map_err(|_| println!("failed to get name."))
|
||||
.unwrap();
|
||||
|
||||
let root_cidr: IpNetwork = Input::with_theme(&theme)
|
||||
.with_prompt("Network CIDR")
|
||||
.with_initial_text("10.42.0.0/16")
|
||||
.interact()
|
||||
.map_err(|_| println!("failed to get cidr."))
|
||||
.unwrap();
|
||||
|
||||
(name, root_cidr)
|
||||
});
|
||||
|
||||
let endpoint: SocketAddr = conf.endpoint.unwrap_or_else(|| {
|
||||
prompts::ask_endpoint()
|
||||
.map_err(|_| println!("failed to get endpoint."))
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let listen_port: u16 = conf.listen_port.unwrap_or_else(|| {
|
||||
Input::with_theme(&theme)
|
||||
.with_prompt("Listen port")
|
||||
.default(51820)
|
||||
.interact()
|
||||
.map_err(|_| println!("failed to get listen port."))
|
||||
.unwrap()
|
||||
});
|
||||
let our_ip = root_cidr
|
||||
.iter()
|
||||
.find(|ip| root_cidr.is_assignable(*ip))
|
||||
.unwrap();
|
||||
let server_cidr = IpNetwork::new(our_ip, root_cidr.max_prefix())?;
|
||||
let config_path = conf.config_path(&name);
|
||||
let our_keypair = KeyPair::generate();
|
||||
|
||||
let config = ConfigFile {
|
||||
private_key: our_keypair.private.to_base64(),
|
||||
listen_port,
|
||||
address: our_ip,
|
||||
network_cidr_prefix: root_cidr.prefix(),
|
||||
};
|
||||
config.write_to_path(&config_path)?;
|
||||
|
||||
let db_init_data = DbInitData {
|
||||
root_cidr_name: name.clone(),
|
||||
root_cidr,
|
||||
server_cidr,
|
||||
our_ip,
|
||||
public_key_base64: our_keypair.public.to_base64(),
|
||||
endpoint,
|
||||
};
|
||||
|
||||
// TODO(bschwind) - Clean up the config file and database
|
||||
// if any errors occur in these init calls.
|
||||
|
||||
let database_path = conf.database_path(&name);
|
||||
let conn = create_database(&database_path).map_err(|_| {
|
||||
format!(
|
||||
"failed to create database {}",
|
||||
"(are you not running as root?)".bold()
|
||||
)
|
||||
})?;
|
||||
populate_database(&conn, db_init_data)?;
|
||||
|
||||
println!(
|
||||
"{} Created database at {}\n",
|
||||
"[*]".dimmed(),
|
||||
database_path.to_string_lossy().bold()
|
||||
);
|
||||
printdoc!(
|
||||
"
|
||||
{star} Setup finished.
|
||||
|
||||
Network {interface} has been {created}!
|
||||
|
||||
Your new network starts with only one peer: this innernet server. Next,
|
||||
you'll want to create additional CIDRs and peers using the commands:
|
||||
|
||||
{wg_manage_server} {add_cidr} {interface}, and
|
||||
{wg_manage_server} {add_peer} {interface}
|
||||
|
||||
See the documentation for more detailed instruction on designing your network.
|
||||
|
||||
When you're ready to start the network, you can auto-start the server:
|
||||
|
||||
{systemctl_enable}{interface}
|
||||
|
||||
",
|
||||
star = "[*]".dimmed(),
|
||||
interface = name.yellow(),
|
||||
created = "created".green(),
|
||||
wg_manage_server = "innernet-server".yellow(),
|
||||
add_cidr = "add-cidr".yellow(),
|
||||
add_peer = "add-peer".yellow(),
|
||||
systemctl_enable = "systemctl enable --now innernet-server@".yellow(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
416
server/src/main.rs
Normal file
416
server/src/main.rs
Normal file
@ -0,0 +1,416 @@
|
||||
use colored::*;
|
||||
use error::handle_rejection;
|
||||
use indoc::printdoc;
|
||||
use ipnetwork::IpNetwork;
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use shared::IoErrorContext;
|
||||
use std::{
|
||||
env,
|
||||
fs::File,
|
||||
io::prelude::*,
|
||||
net::{IpAddr, SocketAddr},
|
||||
ops::Deref,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use warp::Filter;
|
||||
use wgctrl::{DeviceConfigBuilder, DeviceInfo, PeerConfigBuilder};
|
||||
|
||||
pub mod api;
|
||||
pub mod db;
|
||||
pub mod endpoints;
|
||||
pub mod error;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
mod initialize;
|
||||
|
||||
use db::{DatabaseCidr, DatabasePeer};
|
||||
pub use endpoints::Endpoints;
|
||||
pub use error::ServerError;
|
||||
use shared::{prompts, wg, CidrTree, Error, Interface, SERVER_CONFIG_DIR, SERVER_DATABASE_DIR};
|
||||
pub use shared::{Association, AssociationContents};
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
#[structopt(name = "innernet-server", about)]
|
||||
struct Opt {
|
||||
#[structopt(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
enum Command {
|
||||
/// Create a new network.
|
||||
#[structopt(alias = "init")]
|
||||
New,
|
||||
|
||||
/// Serve the coordinating server for an existing network.
|
||||
Serve { interface: Interface },
|
||||
|
||||
/// Add a peer to an existing network.
|
||||
AddPeer { interface: Interface },
|
||||
|
||||
/// Add a new CIDR to an existing network.
|
||||
AddCidr { interface: Interface },
|
||||
}
|
||||
|
||||
pub type Db = Arc<Mutex<Connection>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
pub db: Db,
|
||||
pub endpoints: Arc<Endpoints>,
|
||||
pub interface: String,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub context: Context,
|
||||
pub peer: DatabasePeer,
|
||||
}
|
||||
|
||||
pub struct AdminSession(Session);
|
||||
impl Deref for AdminSession {
|
||||
type Target = Session;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UnredeemedSession(Session);
|
||||
impl Deref for UnredeemedSession {
|
||||
type Target = Session;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct ConfigFile {
|
||||
/// The server's WireGuard key
|
||||
pub private_key: String,
|
||||
|
||||
/// The listen port of the server
|
||||
pub listen_port: u16,
|
||||
|
||||
/// The internal WireGuard IP address assigned to the server
|
||||
pub address: IpAddr,
|
||||
|
||||
/// The CIDR prefix of the WireGuard network
|
||||
pub network_cidr_prefix: u8,
|
||||
}
|
||||
|
||||
impl ConfigFile {
|
||||
pub fn write_to_path<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
|
||||
let mut invitation_file = File::create(&path).with_path(&path)?;
|
||||
invitation_file
|
||||
.write_all(toml::to_string(self).unwrap().as_bytes())
|
||||
.with_path(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
|
||||
Ok(toml::from_slice(&std::fs::read(&path).with_path(path)?)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerConfig {
|
||||
wg_manage_dir_override: Option<PathBuf>,
|
||||
wg_dir_override: Option<PathBuf>,
|
||||
root_cidr: Option<(String, IpNetwork)>,
|
||||
endpoint: Option<SocketAddr>,
|
||||
listen_port: Option<u16>,
|
||||
noninteractive: bool,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
fn database_dir(&self) -> &Path {
|
||||
self.wg_manage_dir_override
|
||||
.as_deref()
|
||||
.unwrap_or(*SERVER_DATABASE_DIR)
|
||||
}
|
||||
|
||||
fn database_path(&self, interface: &str) -> PathBuf {
|
||||
PathBuf::new()
|
||||
.join(self.database_dir())
|
||||
.join(interface)
|
||||
.with_extension("db")
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> &Path {
|
||||
self.wg_dir_override
|
||||
.as_deref()
|
||||
.unwrap_or(*SERVER_CONFIG_DIR)
|
||||
}
|
||||
|
||||
fn config_path(&self, interface: &str) -> PathBuf {
|
||||
PathBuf::new()
|
||||
.join(self.config_dir())
|
||||
.join(interface)
|
||||
.with_extension("conf")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if env::var_os("RUST_LOG").is_none() {
|
||||
// Set some default log settings.
|
||||
env::set_var("RUST_LOG", "warn,warp=info,wg_manage_server=info");
|
||||
}
|
||||
|
||||
pretty_env_logger::init();
|
||||
let opt = Opt::from_args();
|
||||
|
||||
if unsafe { libc::getuid() } != 0 {
|
||||
return Err("innernet-server must run as root.".into());
|
||||
}
|
||||
|
||||
let conf = ServerConfig::default();
|
||||
|
||||
match opt.command {
|
||||
Command::New => {
|
||||
if let Err(e) = initialize::init_wizard(&conf) {
|
||||
println!("{}: {}.", "creation failed".red(), e);
|
||||
}
|
||||
},
|
||||
Command::Serve { interface } => serve(&interface, &conf).await?,
|
||||
Command::AddPeer { interface } => add_peer(&interface, &conf)?,
|
||||
Command::AddCidr { interface } => add_cidr(&interface, &conf)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_database_connection(
|
||||
interface: &str,
|
||||
conf: &ServerConfig,
|
||||
) -> Result<rusqlite::Connection, Box<dyn std::error::Error>> {
|
||||
let database_path = conf.database_path(&interface);
|
||||
if !Path::new(&database_path).exists() {
|
||||
return Err(format!(
|
||||
"no database file found at {}",
|
||||
database_path.to_string_lossy()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(Connection::open(&database_path)?)
|
||||
}
|
||||
|
||||
fn add_peer(interface: &str, conf: &ServerConfig) -> Result<(), Error> {
|
||||
let config = ConfigFile::from_file(conf.config_path(&interface))?;
|
||||
let conn = open_database_connection(interface, conf)?;
|
||||
let peers = DatabasePeer::list(&conn)?
|
||||
.into_iter()
|
||||
.map(|dp| dp.inner)
|
||||
.collect::<Vec<_>>();
|
||||
let cidrs = DatabaseCidr::list(&conn)?;
|
||||
let cidr_tree = CidrTree::new(&cidrs[..]);
|
||||
|
||||
if let Some((peer_request, keypair)) = shared::prompts::add_peer(&peers, &cidr_tree)? {
|
||||
let peer = DatabasePeer::create(&conn, peer_request)?;
|
||||
if cfg!(not(test)) && DeviceInfo::get_by_name(interface).is_ok() {
|
||||
// Update the current WireGuard interface with the new peers.
|
||||
DeviceConfigBuilder::new()
|
||||
.add_peer((&*peer).into())
|
||||
.apply(interface)
|
||||
.map_err(|_| ServerError::WireGuard)?;
|
||||
|
||||
println!("adding to WireGuard interface: {}", &*peer);
|
||||
}
|
||||
|
||||
let server_peer = DatabasePeer::get(&conn, 1)?;
|
||||
prompts::save_peer_invitation(
|
||||
interface,
|
||||
&peer,
|
||||
&*server_peer,
|
||||
&cidr_tree,
|
||||
keypair,
|
||||
&SocketAddr::new(config.address, config.listen_port),
|
||||
)?;
|
||||
} else {
|
||||
println!("exited without creating peer.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_cidr(interface: &str, conf: &ServerConfig) -> Result<(), Error> {
|
||||
let conn = open_database_connection(interface, conf)?;
|
||||
let cidrs = DatabaseCidr::list(&conn)?;
|
||||
if let Some(cidr_request) = shared::prompts::add_cidr(&cidrs)? {
|
||||
let cidr = DatabaseCidr::create(&conn, cidr_request)?;
|
||||
printdoc!(
|
||||
"
|
||||
CIDR \"{cidr_name}\" added.
|
||||
|
||||
Right now, peers within {cidr_name} can only see peers in the same CIDR, and in
|
||||
the special \"innernet-server\" CIDR that includes the innernet server peer.
|
||||
|
||||
You'll need to add more associations for peers in diffent CIDRs to communicate.
|
||||
",
|
||||
cidr_name = cidr.name.bold()
|
||||
);
|
||||
} else {
|
||||
println!("exited without creating CIDR.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve(interface: &str, conf: &ServerConfig) -> Result<(), Error> {
|
||||
let config = ConfigFile::from_file(conf.config_path(&interface))?;
|
||||
let conn = open_database_connection(&interface, conf)?;
|
||||
// Foreign key constraints aren't on in SQLite by default. Enable.
|
||||
conn.pragma_update(None, "foreign_keys", &1)?;
|
||||
|
||||
let peers = DatabasePeer::list(&conn)?;
|
||||
let peer_configs = peers
|
||||
.iter()
|
||||
.map(|peer| peer.deref().into())
|
||||
.collect::<Vec<PeerConfigBuilder>>();
|
||||
|
||||
log::info!("bringing up interface.");
|
||||
wg::up(
|
||||
&interface,
|
||||
&config.private_key,
|
||||
IpNetwork::new(config.address, config.network_cidr_prefix)?,
|
||||
Some(config.listen_port),
|
||||
None,
|
||||
)?;
|
||||
|
||||
DeviceConfigBuilder::new()
|
||||
.add_peers(&peer_configs)
|
||||
.apply(&interface)?;
|
||||
|
||||
let endpoints = Arc::new(Endpoints::new(&interface)?);
|
||||
|
||||
log::info!("{} peers added to wireguard interface.", peers.len());
|
||||
|
||||
let db = Arc::new(Mutex::new(conn));
|
||||
let context = Context {
|
||||
db,
|
||||
interface: interface.to_string(),
|
||||
endpoints,
|
||||
};
|
||||
|
||||
log::info!("innernet-server {} starting.", VERSION);
|
||||
let routes = routes(context.clone()).with(warp::log("warp")).boxed();
|
||||
warp::serve(routes)
|
||||
.run((config.address, config.listen_port))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn routes(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path("v1")
|
||||
.and(api::admin::routes(context.clone()).or(api::user::routes(context)))
|
||||
.recover(handle_rejection)
|
||||
}
|
||||
|
||||
pub fn with_unredeemed_session(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (UnredeemedSession,), Error = warp::Rejection> + Clone {
|
||||
warp::filters::addr::remote()
|
||||
.and_then(move |addr: Option<SocketAddr>| {
|
||||
get_session(context.clone(), addr.map(|addr| addr.ip()), false, false)
|
||||
})
|
||||
.map(|session| UnredeemedSession(session))
|
||||
}
|
||||
|
||||
pub fn with_session(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (Session,), Error = warp::Rejection> + Clone {
|
||||
warp::filters::addr::remote().and_then(move |addr: Option<SocketAddr>| {
|
||||
get_session(context.clone(), addr.map(|addr| addr.ip()), false, true)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_admin_session(
|
||||
context: Context,
|
||||
) -> impl Filter<Extract = (AdminSession,), Error = warp::Rejection> + Clone {
|
||||
warp::filters::addr::remote()
|
||||
.and_then(move |addr: Option<SocketAddr>| {
|
||||
get_session(context.clone(), addr.map(|addr| addr.ip()), true, true)
|
||||
})
|
||||
.map(|session| AdminSession(session))
|
||||
}
|
||||
|
||||
pub fn form_body<T>() -> impl Filter<Extract = (T,), Error = warp::Rejection> + Clone
|
||||
where
|
||||
T: DeserializeOwned + Send,
|
||||
{
|
||||
warp::body::content_length_limit(1024 * 16).and(warp::body::json())
|
||||
}
|
||||
|
||||
async fn get_session(
|
||||
context: Context,
|
||||
addr: Option<IpAddr>,
|
||||
admin_only: bool,
|
||||
redeemed_only: bool,
|
||||
) -> Result<Session, warp::Rejection> {
|
||||
addr.map(|addr| -> Result<Session, ServerError> {
|
||||
let peer = DatabasePeer::get_from_ip(&context.db.lock(), addr)?;
|
||||
|
||||
if !peer.is_disabled && (!admin_only || peer.is_admin) && (!redeemed_only || peer.is_redeemed) {
|
||||
Ok(Session { context, peer })
|
||||
} else {
|
||||
Err(ServerError::Unauthorized)
|
||||
}
|
||||
})
|
||||
.map(|session| session.ok())
|
||||
.flatten() // If no IP address is found, reject.
|
||||
.ok_or_else(|| { warp::reject::custom(ServerError::Unauthorized)})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test;
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use warp::http::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn test_init_wizard() -> Result<()> {
|
||||
// This runs init_wizard().
|
||||
let server = test::Server::new()?;
|
||||
|
||||
assert!(Path::new(&server.wg_conf_path()).exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_session_disguised_with_headers() -> Result<()> {
|
||||
let server = test::Server::new()?;
|
||||
let filter = routes(server.context());
|
||||
|
||||
// Request from an unknown IP, trying to disguise as an admin using HTTP headers.
|
||||
let res = test::request_from_ip("10.80.80.80")
|
||||
.path("/v1/admin/peers")
|
||||
.header("Forwarded", format!("for={}", test::ADMIN_PEER_IP))
|
||||
.header("X-Forwarded-For", test::ADMIN_PEER_IP)
|
||||
.header("X-Real-IP", test::ADMIN_PEER_IP)
|
||||
.reply(&filter)
|
||||
.await;
|
||||
|
||||
// addr::remote() filter only look at remote_addr from TCP socket.
|
||||
// HTTP headers are not considered. This also means that innernet
|
||||
// server would not function behind an HTTP proxy.
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
208
server/src/test.rs
Normal file
208
server/src/test.rs
Normal file
@ -0,0 +1,208 @@
|
||||
#![allow(dead_code)]
|
||||
use crate::{
|
||||
db::{DatabaseCidr, DatabasePeer},
|
||||
endpoints::Endpoints,
|
||||
initialize::init_wizard,
|
||||
Context, ServerConfig,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
use shared::{Cidr, CidrContents, PeerContents};
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use tempfile::TempDir;
|
||||
use warp::test::RequestBuilder;
|
||||
use wgctrl::KeyPair;
|
||||
|
||||
pub const ROOT_CIDR: &str = "10.80.0.0/15";
|
||||
pub const SERVER_CIDR: &str = "10.80.0.1/32";
|
||||
pub const ADMIN_CIDR: &str = "10.80.1.0/24";
|
||||
pub const DEVELOPER_CIDR: &str = "10.80.64.0/24";
|
||||
pub const USER_CIDR: &str = "10.80.128.0/17";
|
||||
pub const EXPERIMENTAL_CIDR: &str = "10.81.0.0/16";
|
||||
pub const EXPERIMENTAL_SUBCIDR: &str = "10.81.0.0/17";
|
||||
|
||||
pub const ADMIN_PEER_IP: &str = "10.80.1.1";
|
||||
pub const WG_MANAGE_PEER_IP: &str = "10.80.1.1";
|
||||
pub const DEVELOPER1_PEER_IP: &str = "10.80.64.2";
|
||||
pub const DEVELOPER2_PEER_IP: &str = "10.80.64.3";
|
||||
pub const USER1_PEER_IP: &str = "10.80.128.2";
|
||||
pub const USER2_PEER_IP: &str = "10.80.129.2";
|
||||
pub const EXPERIMENT_SUBCIDR_PEER_IP: &str = "10.81.0.1";
|
||||
|
||||
pub const ROOT_CIDR_ID: i64 = 1;
|
||||
pub const INFRA_CIDR_ID: i64 = 2;
|
||||
pub const ADMIN_CIDR_ID: i64 = 3;
|
||||
pub const DEVELOPER_CIDR_ID: i64 = 4;
|
||||
pub const USER_CIDR_ID: i64 = 5;
|
||||
|
||||
pub const ADMIN_PEER_ID: i64 = 2;
|
||||
pub const DEVELOPER1_PEER_ID: i64 = 3;
|
||||
pub const DEVELOPER2_PEER_ID: i64 = 4;
|
||||
pub const USER1_PEER_ID: i64 = 5;
|
||||
pub const USER2_PEER_ID: i64 = 6;
|
||||
|
||||
pub struct Server {
|
||||
pub db: Arc<Mutex<Connection>>,
|
||||
endpoints: Arc<Endpoints>,
|
||||
interface: String,
|
||||
conf: ServerConfig,
|
||||
// The directory will be removed during destruction.
|
||||
_test_dir: TempDir,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new() -> Result<Self> {
|
||||
let test_dir = tempfile::tempdir()?;
|
||||
let test_dir_path = test_dir.path();
|
||||
|
||||
// Run the init wizard to initialize the database and create basic
|
||||
// cidrs and peers.
|
||||
let interface = "test".to_string();
|
||||
let conf = ServerConfig {
|
||||
wg_manage_dir_override: Some(test_dir_path.to_path_buf()),
|
||||
wg_dir_override: Some(test_dir_path.to_path_buf()),
|
||||
root_cidr: Some((interface.clone(), ROOT_CIDR.parse()?)),
|
||||
endpoint: Some("155.155.155.155:54321".parse()?),
|
||||
listen_port: Some(54321),
|
||||
noninteractive: true,
|
||||
};
|
||||
init_wizard(&conf).map_err(|_| anyhow!("init_wizard failed"))?;
|
||||
|
||||
// Add developer CIDR and user CIDR and some peers for testing.
|
||||
let db = Connection::open(&conf.database_path(&interface))?;
|
||||
db.pragma_update(None, "foreign_keys", &1)?;
|
||||
assert_eq!(ADMIN_CIDR_ID, create_cidr(&db, "admin", ADMIN_CIDR)?.id);
|
||||
assert_eq!(
|
||||
ADMIN_PEER_ID,
|
||||
DatabasePeer::create(&db, admin_peer_contents("admin", ADMIN_PEER_IP)?)?.id
|
||||
);
|
||||
assert_eq!(
|
||||
DEVELOPER_CIDR_ID,
|
||||
create_cidr(&db, "developer", DEVELOPER_CIDR)?.id
|
||||
);
|
||||
assert_eq!(
|
||||
DEVELOPER1_PEER_ID,
|
||||
DatabasePeer::create(
|
||||
&db,
|
||||
developer_peer_contents("developer1", DEVELOPER1_PEER_IP)?
|
||||
)?
|
||||
.id
|
||||
);
|
||||
assert_eq!(
|
||||
DEVELOPER2_PEER_ID,
|
||||
DatabasePeer::create(
|
||||
&db,
|
||||
developer_peer_contents("developer2", DEVELOPER2_PEER_IP)?
|
||||
)?
|
||||
.id
|
||||
);
|
||||
assert_eq!(USER_CIDR_ID, create_cidr(&db, "user", USER_CIDR)?.id);
|
||||
assert_eq!(
|
||||
USER1_PEER_ID,
|
||||
DatabasePeer::create(&db, user_peer_contents("user1", USER1_PEER_IP)?)?.id
|
||||
);
|
||||
assert_eq!(
|
||||
USER2_PEER_ID,
|
||||
DatabasePeer::create(&db, user_peer_contents("user2", USER2_PEER_IP)?)?.id
|
||||
);
|
||||
|
||||
let db = Arc::new(Mutex::new(db));
|
||||
let endpoints = Arc::new(Endpoints::new(&interface)?);
|
||||
|
||||
Ok(Self {
|
||||
conf,
|
||||
db,
|
||||
endpoints,
|
||||
interface,
|
||||
_test_dir: test_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn db(&self) -> Arc<Mutex<Connection>> {
|
||||
self.db.clone()
|
||||
}
|
||||
|
||||
pub fn context(&self) -> Context {
|
||||
Context {
|
||||
db: self.db.clone(),
|
||||
interface: self.interface.clone(),
|
||||
endpoints: self.endpoints.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wg_conf_path(&self) -> PathBuf {
|
||||
self.conf.config_path(&self.interface)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_cidr(db: &Connection, name: &str, cidr_str: &str) -> Result<Cidr> {
|
||||
let cidr = DatabaseCidr::create(
|
||||
db,
|
||||
CidrContents {
|
||||
name: name.to_string(),
|
||||
cidr: cidr_str.parse()?,
|
||||
parent: Some(ROOT_CIDR_ID),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(cidr)
|
||||
}
|
||||
|
||||
//
|
||||
// Below are helper functions for writing tests.
|
||||
//
|
||||
|
||||
pub fn request_from_ip(ip_str: &str) -> RequestBuilder {
|
||||
let port = 54321u16;
|
||||
warp::test::request().remote_addr(SocketAddr::new(ip_str.parse().unwrap(), port))
|
||||
}
|
||||
|
||||
pub fn post_request_from_ip(ip_str: &str) -> RequestBuilder {
|
||||
request_from_ip(ip_str)
|
||||
.method("POST")
|
||||
.header("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
pub fn put_request_from_ip(ip_str: &str) -> RequestBuilder {
|
||||
request_from_ip(ip_str)
|
||||
.method("PUT")
|
||||
.header("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
pub fn peer_contents(
|
||||
name: &str,
|
||||
ip_str: &str,
|
||||
cidr_id: i64,
|
||||
is_admin: bool,
|
||||
) -> Result<PeerContents> {
|
||||
let public_key = KeyPair::generate().public;
|
||||
|
||||
Ok(PeerContents {
|
||||
name: name.to_string(),
|
||||
ip: ip_str.parse()?,
|
||||
cidr_id,
|
||||
public_key: public_key.to_base64(),
|
||||
is_admin,
|
||||
endpoint: None,
|
||||
persistent_keepalive_interval: None,
|
||||
is_disabled: false,
|
||||
is_redeemed: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn admin_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
|
||||
peer_contents(name, ip_str, ADMIN_CIDR_ID, true)
|
||||
}
|
||||
|
||||
pub fn infra_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
|
||||
peer_contents(name, ip_str, INFRA_CIDR_ID, false)
|
||||
}
|
||||
|
||||
pub fn developer_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
|
||||
peer_contents(name, ip_str, DEVELOPER_CIDR_ID, false)
|
||||
}
|
||||
|
||||
pub fn user_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
|
||||
peer_contents(name, ip_str, USER_CIDR_ID, false)
|
||||
}
|
19
shared/Cargo.toml
Normal file
19
shared/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
authors = ["Jake McGinty <me@jake.su>"]
|
||||
edition = "2018"
|
||||
license = "MIT"
|
||||
name = "shared"
|
||||
publish = false
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
colored = "2.0"
|
||||
dialoguer = "0.8"
|
||||
indoc = "1"
|
||||
ipnetwork = { git = "https://github.com/mcginty/ipnetwork" }
|
||||
lazy_static = "1"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.5"
|
||||
ureq = { version = "2", default-features = false }
|
||||
wgctrl = { path = "../wgctrl-rs" }
|
123
shared/src/interface_config.rs
Normal file
123
shared/src/interface_config.rs
Normal file
@ -0,0 +1,123 @@
|
||||
use crate::{ensure_dirs_exist, Error, IoErrorContext, CLIENT_CONFIG_PATH};
|
||||
use indoc::writedoc;
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::Write,
|
||||
net::SocketAddr,
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct InterfaceConfig {
|
||||
/// The information to bring up the interface.
|
||||
pub interface: InterfaceInfo,
|
||||
|
||||
/// The necessary contact information for the server.
|
||||
pub server: ServerInfo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct InterfaceInfo {
|
||||
/// The interface name (i.e. "tonari")
|
||||
pub network_name: String,
|
||||
|
||||
/// The invited peer's internal IP address that's been allocated to it, inside
|
||||
/// the entire network's CIDR prefix.
|
||||
pub address: IpNetwork,
|
||||
|
||||
/// WireGuard private key (base64)
|
||||
pub private_key: String,
|
||||
|
||||
/// The local listen port. A random port will be used if `None`.
|
||||
pub listen_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct ServerInfo {
|
||||
/// The server's WireGuard public key
|
||||
pub public_key: String,
|
||||
|
||||
/// The external internet endpoint to reach the server.
|
||||
pub external_endpoint: SocketAddr,
|
||||
|
||||
/// An internal endpoint in the WireGuard network that hosts the coordination API.
|
||||
pub internal_endpoint: SocketAddr,
|
||||
}
|
||||
|
||||
impl InterfaceConfig {
|
||||
pub fn write_to_path<P: AsRef<Path>>(
|
||||
&self,
|
||||
path: P,
|
||||
comments: bool,
|
||||
mode: Option<u32>,
|
||||
) -> Result<(), Error> {
|
||||
let mut target_file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.with_path(&path)?;
|
||||
if let Some(val) = mode {
|
||||
let metadata = target_file.metadata()?;
|
||||
let mut permissions = metadata.permissions();
|
||||
permissions.set_mode(val);
|
||||
}
|
||||
|
||||
if comments {
|
||||
writedoc!(
|
||||
target_file,
|
||||
r"
|
||||
# This is an invitation file to an innernet network.
|
||||
#
|
||||
# To join, you must install innernet.
|
||||
# See https://github.com/tonarino/innernet for instructions.
|
||||
#
|
||||
# If you have innernet, just run:
|
||||
#
|
||||
# innernet install <this file>
|
||||
#
|
||||
# Don't edit the contents below unless you love chaos and dysfunction.
|
||||
"
|
||||
)?;
|
||||
}
|
||||
target_file
|
||||
.write_all(toml::to_string(self).unwrap().as_bytes())
|
||||
.with_path(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Overwrites the config file if it already exists.
|
||||
pub fn write_to_interface(&self, interface: &str) -> Result<PathBuf, Error> {
|
||||
let path = Self::build_config_file_path(interface)?;
|
||||
File::create(&path)
|
||||
.with_path(&path)?
|
||||
.write_all(toml::to_string(self).unwrap().as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
|
||||
Ok(toml::from_slice(&std::fs::read(&path).with_path(path)?)?)
|
||||
}
|
||||
|
||||
pub fn from_interface(interface: &str) -> Result<Self, Error> {
|
||||
Self::from_file(Self::build_config_file_path(interface)?)
|
||||
}
|
||||
|
||||
fn build_config_file_path(interface: &str) -> Result<PathBuf, Error> {
|
||||
ensure_dirs_exist(&[*CLIENT_CONFIG_PATH])?;
|
||||
Ok(CLIENT_CONFIG_PATH.join(interface).with_extension("conf"))
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceInfo {
|
||||
pub fn public_key(&self) -> Result<String, Error> {
|
||||
Ok(wgctrl::Key::from_base64(&self.private_key)?
|
||||
.generate_public()
|
||||
.to_base64())
|
||||
}
|
||||
}
|
435
shared/src/lib.rs
Normal file
435
shared/src/lib.rs
Normal file
@ -0,0 +1,435 @@
|
||||
use ipnetwork::IpNetwork;
|
||||
use lazy_static::lazy_static;
|
||||
use prompts::hostname_validator;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt::{Display, Formatter},
|
||||
fs::{self, File},
|
||||
io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
ops::Deref,
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::Path,
|
||||
str::FromStr,
|
||||
time::Duration,
|
||||
};
|
||||
use wgctrl::{Key, PeerConfig, PeerConfigBuilder};
|
||||
|
||||
pub mod interface_config;
|
||||
pub mod prompts;
|
||||
pub mod wg;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CLIENT_CONFIG_PATH: &'static Path = Path::new("/etc/innernet");
|
||||
pub static ref CLIENT_DATA_PATH: &'static Path = Path::new("/var/lib/innernet");
|
||||
pub static ref SERVER_CONFIG_DIR: &'static Path = Path::new("/etc/innernet-server");
|
||||
pub static ref SERVER_DATABASE_DIR: &'static Path = Path::new("/var/lib/innernet-server");
|
||||
pub static ref REDEEM_TRANSITION_WAIT: Duration = Duration::from_secs(5);
|
||||
}
|
||||
|
||||
pub static PERSISTENT_KEEPALIVE_INTERVAL_SECS: u16 = 25;
|
||||
|
||||
pub type Error = Box<dyn std::error::Error>;
|
||||
|
||||
pub trait IoErrorContext<T> {
|
||||
fn with_path<P: AsRef<Path>>(self, path: P) -> Result<T, WrappedIoError>;
|
||||
fn with_str<S: Into<String>>(self, context: S) -> Result<T, WrappedIoError>;
|
||||
}
|
||||
|
||||
impl<T> IoErrorContext<T> for Result<T, std::io::Error> {
|
||||
fn with_path<P: AsRef<Path>>(self, path: P) -> Result<T, WrappedIoError> {
|
||||
self.with_str(path.as_ref().to_string_lossy())
|
||||
}
|
||||
|
||||
fn with_str<S: Into<String>>(self, context: S) -> Result<T, WrappedIoError> {
|
||||
self.map_err(|e| WrappedIoError {
|
||||
io_error: e,
|
||||
context: context.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WrappedIoError {
|
||||
io_error: std::io::Error,
|
||||
context: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WrappedIoError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
|
||||
write!(f, "{} - {}", self.context, self.io_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WrappedIoError {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Interface {
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl FromStr for Interface {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(name: &str) -> Result<Self, Self::Err> {
|
||||
let s = name.to_string();
|
||||
hostname_validator(&s)?;
|
||||
Ok(Self {
|
||||
name: name.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Interface {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(tag = "option", content = "content")]
|
||||
pub enum EndpointContents {
|
||||
Set(SocketAddr),
|
||||
Unset,
|
||||
}
|
||||
|
||||
impl Into<Option<SocketAddr>> for EndpointContents {
|
||||
fn into(self) -> Option<SocketAddr> {
|
||||
match self {
|
||||
Self::Set(addr) => Some(addr),
|
||||
Self::Unset => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<SocketAddr>> for EndpointContents {
|
||||
fn from(option: Option<SocketAddr>) -> Self {
|
||||
match option {
|
||||
Some(addr) => Self::Set(addr),
|
||||
None => Self::Unset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct AssociationContents {
|
||||
pub cidr_id_1: i64,
|
||||
pub cidr_id_2: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Association {
|
||||
pub id: i64,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub contents: AssociationContents,
|
||||
}
|
||||
|
||||
impl Deref for Association {
|
||||
type Target = AssociationContents;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.contents
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct CidrContents {
|
||||
pub name: String,
|
||||
pub cidr: IpNetwork,
|
||||
pub parent: Option<i64>,
|
||||
}
|
||||
|
||||
impl Deref for CidrContents {
|
||||
type Target = IpNetwork;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.cidr
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Cidr {
|
||||
pub id: i64,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub contents: CidrContents,
|
||||
}
|
||||
|
||||
impl Deref for Cidr {
|
||||
type Target = CidrContents;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.contents
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CidrTree<'a> {
|
||||
cidrs: &'a [Cidr],
|
||||
contents: &'a Cidr,
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for CidrTree<'a> {
|
||||
type Target = Cidr;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.contents
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> CidrTree<'a> {
|
||||
pub fn new(cidrs: &'a [Cidr]) -> Self {
|
||||
let root = cidrs
|
||||
.iter()
|
||||
.min_by_key(|c| c.cidr.prefix())
|
||||
.expect("failed to find root CIDR");
|
||||
Self {
|
||||
cidrs,
|
||||
contents: root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn children(&self) -> impl Iterator<Item = CidrTree> {
|
||||
self.cidrs
|
||||
.iter()
|
||||
.filter(move |c| c.parent == Some(self.contents.id))
|
||||
.map(move |c| Self {
|
||||
cidrs: self.cidrs,
|
||||
contents: c,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn leaves(&self) -> Vec<Cidr> {
|
||||
let mut leaves = vec![];
|
||||
for cidr in self.cidrs {
|
||||
if !self.cidrs.iter().any(|c| c.parent == Some(cidr.id)) {
|
||||
leaves.push(cidr.clone());
|
||||
}
|
||||
}
|
||||
leaves
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct RedeemContents {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct PeerContents {
|
||||
pub name: String,
|
||||
pub ip: IpAddr,
|
||||
pub cidr_id: i64,
|
||||
pub public_key: String,
|
||||
pub endpoint: Option<SocketAddr>,
|
||||
pub persistent_keepalive_interval: Option<u16>,
|
||||
pub is_admin: bool,
|
||||
pub is_disabled: bool,
|
||||
pub is_redeemed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Peer {
|
||||
pub id: i64,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub contents: PeerContents,
|
||||
}
|
||||
|
||||
impl Deref for Peer {
|
||||
type Target = PeerContents;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.contents
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Peer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} ({})", &self.name, &self.public_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct PeerDiff {
|
||||
pub public_key: String,
|
||||
pub endpoint: Option<SocketAddr>,
|
||||
pub persistent_keepalive_interval: Option<u16>,
|
||||
pub is_disabled: bool,
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
pub fn diff(&self, peer: &PeerConfig) -> Option<PeerDiff> {
|
||||
assert_eq!(self.public_key, peer.public_key.to_base64());
|
||||
|
||||
let endpoint_diff = if peer.endpoint != self.endpoint {
|
||||
self.endpoint
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let keepalive_diff =
|
||||
if peer.persistent_keepalive_interval != self.persistent_keepalive_interval {
|
||||
self.persistent_keepalive_interval
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if endpoint_diff.is_none() && keepalive_diff.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(PeerDiff {
|
||||
public_key: self.public_key.clone(),
|
||||
endpoint: endpoint_diff,
|
||||
persistent_keepalive_interval: keepalive_diff,
|
||||
is_disabled: self.is_disabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Peer> for PeerConfigBuilder {
|
||||
fn from(peer: &Peer) -> Self {
|
||||
let builder = PeerConfigBuilder::new(&Key::from_base64(&peer.public_key).unwrap())
|
||||
.replace_allowed_ips()
|
||||
.add_allowed_ip(peer.ip, if peer.ip.is_ipv4() { 32 } else { 128 });
|
||||
|
||||
let builder = if peer.is_disabled {
|
||||
builder.remove()
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
|
||||
let builder = if let Some(interval) = peer.persistent_keepalive_interval {
|
||||
builder.set_persistent_keepalive_interval(interval)
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
|
||||
if let Some(endpoint) = peer.endpoint {
|
||||
builder.set_endpoint(endpoint)
|
||||
} else {
|
||||
builder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a PeerDiff> for PeerConfigBuilder {
|
||||
fn from(peer: &PeerDiff) -> Self {
|
||||
let builder = PeerConfigBuilder::new(&Key::from_base64(&peer.public_key).unwrap());
|
||||
|
||||
let builder = if peer.is_disabled {
|
||||
builder.remove()
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
|
||||
let builder = if let Some(interval) = peer.persistent_keepalive_interval {
|
||||
builder.set_persistent_keepalive_interval(interval)
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
|
||||
if let Some(endpoint) = peer.endpoint {
|
||||
builder.set_endpoint(endpoint)
|
||||
} else {
|
||||
builder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This model is sent as a response to the /state endpoint, and is meant
|
||||
/// to include all the data a client needs to update its WireGuard interface.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct State {
|
||||
/// This list will be only the peers visible to the user requesting this
|
||||
/// information, not including disabled peers or peers from other CIDRs
|
||||
/// that the user's CIDR is not authorized to communicate with.
|
||||
pub peers: Vec<Peer>,
|
||||
|
||||
/// At the moment, this is all CIDRs, regardless of whether the peer is
|
||||
/// eligible to communicate with them or not.
|
||||
pub cidrs: Vec<Cidr>,
|
||||
}
|
||||
|
||||
pub static WG_MANAGE_DIR: &str = "/etc/innernet";
|
||||
pub static WG_DIR: &str = "/etc/wireguard";
|
||||
|
||||
pub fn ensure_dirs_exist(dirs: &[&Path]) -> Result<(), Error> {
|
||||
for dir in dirs {
|
||||
match fs::create_dir(dir) {
|
||||
Ok(()) => {
|
||||
let target_file = File::open(dir).with_path(dir)?;
|
||||
let metadata = target_file.metadata().with_path(dir)?;
|
||||
let mut permissions = metadata.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
},
|
||||
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => {
|
||||
return Err(e.into());
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_peer_no_diff() {
|
||||
const PUBKEY: &str = "4CNZorWVtohO64n6AAaH/JyFjIIgBFrfJK2SGtKjzEE=";
|
||||
let ip: IpAddr = "10.0.0.1".parse().unwrap();
|
||||
let peer = Peer {
|
||||
id: 1,
|
||||
contents: PeerContents {
|
||||
name: "peer1".to_owned(),
|
||||
ip,
|
||||
cidr_id: 1,
|
||||
public_key: PUBKEY.to_owned(),
|
||||
endpoint: None,
|
||||
persistent_keepalive_interval: None,
|
||||
is_admin: false,
|
||||
is_disabled: false,
|
||||
is_redeemed: true,
|
||||
},
|
||||
};
|
||||
let builder =
|
||||
PeerConfigBuilder::new(&Key::from_base64(PUBKEY).unwrap()).add_allowed_ip(ip, 32);
|
||||
|
||||
let config = builder.into_peer_config();
|
||||
|
||||
assert_eq!(peer.diff(&config), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_diff() {
|
||||
const PUBKEY: &str = "4CNZorWVtohO64n6AAaH/JyFjIIgBFrfJK2SGtKjzEE=";
|
||||
let ip: IpAddr = "10.0.0.1".parse().unwrap();
|
||||
let peer = Peer {
|
||||
id: 1,
|
||||
contents: PeerContents {
|
||||
name: "peer1".to_owned(),
|
||||
ip,
|
||||
cidr_id: 1,
|
||||
public_key: PUBKEY.to_owned(),
|
||||
endpoint: None,
|
||||
persistent_keepalive_interval: Some(15),
|
||||
is_admin: false,
|
||||
is_disabled: false,
|
||||
is_redeemed: true,
|
||||
},
|
||||
};
|
||||
let builder =
|
||||
PeerConfigBuilder::new(&Key::from_base64(PUBKEY).unwrap()).add_allowed_ip(ip, 32);
|
||||
|
||||
let config = builder.into_peer_config();
|
||||
|
||||
println!("{:?}", peer);
|
||||
println!("{:?}", config);
|
||||
assert!(matches!(peer.diff(&config), Some(_)));
|
||||
}
|
||||
}
|
361
shared/src/prompts.rs
Normal file
361
shared/src/prompts.rs
Normal file
@ -0,0 +1,361 @@
|
||||
use crate::{
|
||||
interface_config::{InterfaceConfig, InterfaceInfo, ServerInfo},
|
||||
Association, Cidr, CidrContents, CidrTree, Error, Peer, PeerContents,
|
||||
PERSISTENT_KEEPALIVE_INTERVAL_SECS,
|
||||
};
|
||||
use colored::*;
|
||||
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
|
||||
use ipnetwork::IpNetwork;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use wgctrl::KeyPair;
|
||||
|
||||
lazy_static! {
|
||||
static ref THEME: ColorfulTheme = ColorfulTheme::default();
|
||||
|
||||
/// Regex to match the requirements of hostname(7), needed to have peers also be reachable hostnames.
|
||||
/// Note that the full length also must be maximum 63 characters, which this regex does not check.
|
||||
static ref PEER_NAME_REGEX: Regex = Regex::new(r"^([a-z0-9]-?)*[a-z0-9]$").unwrap();
|
||||
}
|
||||
|
||||
pub fn is_valid_hostname(name: &str) -> bool {
|
||||
name.len() < 64 && PEER_NAME_REGEX.is_match(name)
|
||||
}
|
||||
|
||||
pub fn hostname_validator(name: &String) -> Result<(), &'static str> {
|
||||
if is_valid_hostname(name) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("not a valid hostname")
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring up a prompt to create a new CIDR. Returns the peer request.
|
||||
pub fn add_cidr(cidrs: &[Cidr]) -> Result<Option<CidrContents>, Error> {
|
||||
let parent_cidr = choose_cidr(cidrs, "Parent CIDR")?;
|
||||
let name: String = Input::with_theme(&*THEME).with_prompt("Name").interact()?;
|
||||
let cidr: IpNetwork = Input::with_theme(&*THEME).with_prompt("CIDR").interact()?;
|
||||
|
||||
let cidr_request = CidrContents {
|
||||
name,
|
||||
cidr,
|
||||
parent: Some(parent_cidr.id),
|
||||
};
|
||||
|
||||
Ok(
|
||||
if Confirm::with_theme(&*THEME)
|
||||
.with_prompt(&format!("Create CIDR \"{}\"?", cidr_request.name))
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
Some(cidr_request)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choose_cidr<'a>(cidrs: &'a [Cidr], text: &'static str) -> Result<&'a Cidr, Error> {
|
||||
let cidr_names: Vec<_> = cidrs
|
||||
.iter()
|
||||
.map(|cidr| format!("{} ({})", &cidr.name, &cidr.cidr))
|
||||
.collect();
|
||||
let cidr_index = Select::with_theme(&*THEME)
|
||||
.with_prompt(text)
|
||||
.items(&cidr_names)
|
||||
.interact()?;
|
||||
Ok(&cidrs[cidr_index])
|
||||
}
|
||||
|
||||
pub fn choose_association<'a>(
|
||||
associations: &'a [Association],
|
||||
cidrs: &'a [Cidr],
|
||||
) -> Result<&'a Association, Error> {
|
||||
let names: Vec<_> = associations
|
||||
.iter()
|
||||
.map(|association| {
|
||||
format!(
|
||||
"{}: {} <=> {}",
|
||||
association.id,
|
||||
&cidrs
|
||||
.iter()
|
||||
.find(|c| c.id == association.cidr_id_1)
|
||||
.unwrap()
|
||||
.name,
|
||||
&cidrs
|
||||
.iter()
|
||||
.find(|c| c.id == association.cidr_id_2)
|
||||
.unwrap()
|
||||
.name
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let index = Select::with_theme(&*THEME)
|
||||
.with_prompt("Association")
|
||||
.items(&names)
|
||||
.interact()?;
|
||||
|
||||
Ok(&associations[index])
|
||||
}
|
||||
|
||||
pub fn add_association(cidrs: &[Cidr]) -> Result<Option<(&Cidr, &Cidr)>, Error> {
|
||||
let cidr1 = choose_cidr(&cidrs[..], "First CIDR")?;
|
||||
let cidr2 = choose_cidr(&cidrs[..], "Second CIDR")?;
|
||||
|
||||
Ok(
|
||||
if Confirm::with_theme(&*THEME)
|
||||
.with_prompt(&format!(
|
||||
"Add association: {} <=> {}?",
|
||||
cidr1.name.yellow().bold(),
|
||||
cidr2.name.yellow().bold()
|
||||
))
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
Some((cidr1, cidr2))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_association<'a>(
|
||||
associations: &'a [Association],
|
||||
cidrs: &'a [Cidr],
|
||||
) -> Result<Option<&'a Association>, Error> {
|
||||
let association = choose_association(associations, cidrs)?;
|
||||
|
||||
Ok(
|
||||
if Confirm::with_theme(&*THEME)
|
||||
.with_prompt(&format!("Delete association #{}?", association.id))
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
Some(association)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Bring up a prompt to create a new peer. Returns the peer request.
|
||||
pub fn add_peer(
|
||||
peers: &[Peer],
|
||||
cidr_tree: &CidrTree,
|
||||
) -> Result<Option<(PeerContents, KeyPair)>, Error> {
|
||||
let leaves = cidr_tree.leaves();
|
||||
|
||||
let cidr = choose_cidr(&leaves[..], "Eligible CIDRs for peer")?;
|
||||
|
||||
let mut available_ip = None;
|
||||
let candidate_ips = cidr.iter().filter(|ip| cidr.is_assignable(*ip));
|
||||
for ip in candidate_ips {
|
||||
if peers.iter().find(|peer| peer.ip == ip).is_none() {
|
||||
available_ip = Some(ip);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let available_ip = available_ip.expect("No IPs in this CIDR are avavilable");
|
||||
|
||||
let ip = Input::with_theme(&*THEME)
|
||||
.with_prompt("IP")
|
||||
.default(available_ip)
|
||||
.interact()?;
|
||||
|
||||
let name: String = Input::with_theme(&*THEME)
|
||||
.with_prompt("Name")
|
||||
.validate_with(hostname_validator)
|
||||
.interact()?;
|
||||
|
||||
let is_admin = Confirm::with_theme(&*THEME)
|
||||
.with_prompt(&format!("Make {} an admin?", name))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
let default_keypair = KeyPair::generate();
|
||||
let peer_request = PeerContents {
|
||||
name,
|
||||
ip,
|
||||
cidr_id: cidr.id,
|
||||
public_key: default_keypair.public.to_base64(),
|
||||
endpoint: None,
|
||||
is_admin,
|
||||
is_disabled: false,
|
||||
is_redeemed: false,
|
||||
persistent_keepalive_interval: Some(PERSISTENT_KEEPALIVE_INTERVAL_SECS),
|
||||
};
|
||||
|
||||
Ok(
|
||||
if Confirm::with_theme(&*THEME)
|
||||
.with_prompt(&format!("Create peer {}?", peer_request.name.yellow()))
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
Some((peer_request, default_keypair))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Presents a selection and confirmation of eligible peers for either disabling or enabling,
|
||||
/// and returns back the ID of the selected peer.
|
||||
pub fn enable_or_disable_peer(peers: &[Peer], enable: bool) -> Result<Option<Peer>, Error> {
|
||||
let enabled_peers: Vec<_> = peers
|
||||
.iter()
|
||||
.filter(|peer| enable && peer.is_disabled || !enable && !peer.is_disabled)
|
||||
.collect();
|
||||
|
||||
let peer_selection: Vec<_> = enabled_peers
|
||||
.iter()
|
||||
.map(|peer| format!("{} ({})", &peer.name, &peer.ip))
|
||||
.collect();
|
||||
let index = Select::with_theme(&*THEME)
|
||||
.with_prompt(&format!(
|
||||
"Peer to {}able",
|
||||
if enable { "en" } else { "dis" }
|
||||
))
|
||||
.items(&peer_selection)
|
||||
.interact()?;
|
||||
let peer = enabled_peers[index];
|
||||
|
||||
Ok(
|
||||
if Confirm::with_theme(&*THEME)
|
||||
.with_prompt(&format!(
|
||||
"{}able peer {}?",
|
||||
if enable { "En" } else { "Dis" },
|
||||
peer.name.yellow()
|
||||
))
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
Some(peer.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Confirm and write a innernet invitation file after a peer has been created.
|
||||
pub fn save_peer_invitation(
|
||||
network_name: &str,
|
||||
peer: &Peer,
|
||||
server_peer: &Peer,
|
||||
root_cidr: &Cidr,
|
||||
keypair: KeyPair,
|
||||
server_api_addr: &SocketAddr,
|
||||
) -> Result<(), Error> {
|
||||
let peer_invitation = InterfaceConfig {
|
||||
interface: InterfaceInfo {
|
||||
network_name: network_name.to_string(),
|
||||
private_key: keypair.private.to_base64(),
|
||||
address: IpNetwork::new(peer.ip, root_cidr.prefix())?,
|
||||
listen_port: None,
|
||||
},
|
||||
server: ServerInfo {
|
||||
external_endpoint: server_peer
|
||||
.endpoint
|
||||
.expect("The innernet server should have a WireGuard endpoint"),
|
||||
internal_endpoint: *server_api_addr,
|
||||
public_key: server_peer.public_key.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let invitation_save_path = Input::with_theme(&*THEME)
|
||||
.with_prompt("Save peer invitation file as")
|
||||
.default(format!("{}.toml", peer.name))
|
||||
.interact()?;
|
||||
|
||||
peer_invitation.write_to_path(&invitation_save_path, true, None)?;
|
||||
|
||||
println!(
|
||||
"\nPeer \"{}\" added\n\
|
||||
Peer invitation file written to {}\n\
|
||||
Please send it to them securely (eg. via magic-wormhole) \
|
||||
to bootstrap them onto the network.",
|
||||
peer.name.bold(),
|
||||
invitation_save_path.bold()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_listen_port(
|
||||
interface: &InterfaceInfo,
|
||||
unset: bool,
|
||||
) -> Result<Option<Option<u16>>, Error> {
|
||||
let listen_port = (!unset)
|
||||
.then(|| {
|
||||
Input::with_theme(&*THEME)
|
||||
.with_prompt("Listen port")
|
||||
.default(interface.listen_port.unwrap_or(51820))
|
||||
.interact()
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let mut confirmation = Confirm::with_theme(&*THEME);
|
||||
confirmation
|
||||
.with_prompt(
|
||||
&(if let Some(port) = &listen_port {
|
||||
format!("Set listen port to {}?", port)
|
||||
} else {
|
||||
"Unset and randomize listen port?".to_string()
|
||||
}),
|
||||
)
|
||||
.default(false);
|
||||
|
||||
if listen_port == interface.listen_port {
|
||||
println!("No change necessary - interface already has this setting.");
|
||||
Ok(None)
|
||||
} else if confirmation.interact()? {
|
||||
Ok(Some(listen_port))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ask_endpoint() -> Result<SocketAddr, Error> {
|
||||
println!("getting external IP address.");
|
||||
|
||||
let external_ip: Option<IpAddr> = ureq::get("http://4.icanhazip.com")
|
||||
.call()
|
||||
.ok()
|
||||
.map(|res| res.into_string().ok())
|
||||
.flatten()
|
||||
.map(|body| body.trim().to_string())
|
||||
.and_then(|body| body.parse().ok());
|
||||
|
||||
let mut endpoint_builder = Input::with_theme(&*THEME);
|
||||
if let Some(ip) = external_ip {
|
||||
endpoint_builder.default(SocketAddr::new(ip, 51820));
|
||||
} else {
|
||||
println!("failed to get external IP.");
|
||||
}
|
||||
endpoint_builder
|
||||
.with_prompt("External endpoint")
|
||||
.interact()
|
||||
.map_err(|e| Error::from(e))
|
||||
}
|
||||
|
||||
pub fn override_endpoint(unset: bool) -> Result<Option<Option<SocketAddr>>, Error> {
|
||||
let endpoint = if !unset { Some(ask_endpoint()?) } else { None };
|
||||
|
||||
Ok(
|
||||
if Confirm::with_theme(&*THEME)
|
||||
.with_prompt(
|
||||
&(if let Some(endpoint) = &endpoint {
|
||||
format!("Set external endpoint to {}?", endpoint)
|
||||
} else {
|
||||
"Unset external endpoint to enable automatic endpoint discovery?".to_string()
|
||||
}),
|
||||
)
|
||||
.default(false)
|
||||
.interact()?
|
||||
{
|
||||
Some(endpoint)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
140
shared/src/wg.rs
Normal file
140
shared/src/wg.rs
Normal file
@ -0,0 +1,140 @@
|
||||
use crate::{Error, IoErrorContext};
|
||||
use ipnetwork::IpNetwork;
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
process::{self, Command},
|
||||
};
|
||||
use wgctrl::{DeviceConfigBuilder, PeerConfigBuilder};
|
||||
|
||||
fn cmd(bin: &str, args: &[&str]) -> Result<process::Output, Error> {
|
||||
let output = Command::new(bin).args(args).output()?;
|
||||
if output.status.success() {
|
||||
Ok(output)
|
||||
} else {
|
||||
Err(format!(
|
||||
"failed to run {} {} command: {}",
|
||||
bin,
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn set_addr(interface: &str, addr: IpNetwork) -> Result<(), Error> {
|
||||
let real_interface = wgctrl::backends::userspace::resolve_tun(interface).with_str(interface)?;
|
||||
cmd(
|
||||
"ifconfig",
|
||||
&[
|
||||
&real_interface,
|
||||
"inet",
|
||||
&addr.to_string(),
|
||||
&addr.ip().to_string(),
|
||||
"alias",
|
||||
],
|
||||
)?;
|
||||
cmd("ifconfig", &[&real_interface, "mtu", "1420"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_addr(interface: &str, addr: IpNetwork) -> Result<(), Error> {
|
||||
cmd(
|
||||
"ip",
|
||||
&["address", "replace", &addr.to_string(), "dev", interface],
|
||||
)?;
|
||||
let _ = cmd(
|
||||
"ip",
|
||||
&["link", "set", "mtu", "1420", "up", "dev", interface],
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn up(
|
||||
interface: &str,
|
||||
private_key: &str,
|
||||
address: IpNetwork,
|
||||
listen_port: Option<u16>,
|
||||
peer: Option<(&str, IpAddr, SocketAddr)>,
|
||||
) -> Result<(), Error> {
|
||||
let mut device = DeviceConfigBuilder::new();
|
||||
if let Some((public_key, address, endpoint)) = peer {
|
||||
let prefix = if address.is_ipv4() { 32 } else { 128 };
|
||||
let peer_config = PeerConfigBuilder::new(&wgctrl::Key::from_base64(&public_key)?)
|
||||
.add_allowed_ip(address, prefix)
|
||||
.set_endpoint(endpoint);
|
||||
device = device.add_peer(peer_config);
|
||||
}
|
||||
if let Some(listen_port) = listen_port {
|
||||
device = device.set_listen_port(listen_port);
|
||||
}
|
||||
device
|
||||
.set_private_key(wgctrl::Key::from_base64(&private_key).unwrap())
|
||||
.apply(interface)?;
|
||||
set_addr(interface, address)?;
|
||||
add_route(interface, address)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_listen_port(interface: &str, listen_port: Option<u16>) -> Result<(), Error> {
|
||||
let mut device = DeviceConfigBuilder::new();
|
||||
if let Some(listen_port) = listen_port {
|
||||
device = device.set_listen_port(listen_port);
|
||||
} else {
|
||||
device = device.randomize_listen_port();
|
||||
}
|
||||
device.apply(interface)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn down(interface: &str) -> Result<(), Error> {
|
||||
Ok(wgctrl::delete_interface(interface).with_str(interface)?)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn down(interface: &str) -> Result<(), Error> {
|
||||
wgctrl::backends::userspace::delete_interface(interface)
|
||||
.with_str(interface)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
/// Add a route in the OS's routing table to get traffic flowing through this interface.
|
||||
/// Returns an error if the process doesn't exit successfully, otherwise returns
|
||||
/// true if the route was changed, false if the route already exists.
|
||||
pub fn add_route(interface: &str, cidr: IpNetwork) -> Result<bool, Error> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let real_interface =
|
||||
wgctrl::backends::userspace::resolve_tun(interface).with_str(interface)?;
|
||||
let output = cmd(
|
||||
"route",
|
||||
&[
|
||||
"-n",
|
||||
"add",
|
||||
"-inet",
|
||||
&cidr.to_string(),
|
||||
"-interface",
|
||||
&real_interface,
|
||||
],
|
||||
)?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
Err(format!(
|
||||
"failed to add route for device {} ({}): {}",
|
||||
&interface, real_interface, stderr
|
||||
)
|
||||
.into())
|
||||
} else {
|
||||
Ok(!stderr.contains("File exists"))
|
||||
}
|
||||
} else {
|
||||
// TODO(mcginty): use the netlink interface on linux to modify routing table.
|
||||
let _ = cmd(
|
||||
"ip",
|
||||
&["route", "add", &cidr.to_string(), "dev", &interface],
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
2
taplo.toml
Normal file
2
taplo.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[formatting]
|
||||
reorder_keys = true
|
3
wgctrl-rs/.gitignore
vendored
Normal file
3
wgctrl-rs/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
23
wgctrl-rs/Cargo.toml
Normal file
23
wgctrl-rs/Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
authors = ["K900 <me@0upti.me>"]
|
||||
categories = ["os::unix-apis"]
|
||||
description = "High level bindings to the WireGuard embeddable C library"
|
||||
edition = "2018"
|
||||
license = "LGPL-2.1-or-later"
|
||||
name = "wgctrl"
|
||||
publish = false
|
||||
readme = "../README.md"
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.13"
|
||||
hex = "0.4"
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
wgctrl-sys = { path = "../wgctrl-sys" }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
rand_core = "0.6"
|
||||
x25519-dalek = { git = "https://github.com/mcginty/x25519-dalek", branch = "master" }
|
||||
subtle = "2"
|
527
wgctrl-rs/src/backends/kernel.rs
Normal file
527
wgctrl-rs/src/backends/kernel.rs
Normal file
@ -0,0 +1,527 @@
|
||||
use crate::{
|
||||
device::AllowedIp, DeviceConfigBuilder, DeviceInfo, InvalidKey, PeerConfig, PeerConfigBuilder,
|
||||
PeerInfo, PeerStats,
|
||||
};
|
||||
use wgctrl_sys::{timespec64, wg_device_flags as wgdf, wg_peer_flags as wgpf};
|
||||
|
||||
use std::{
|
||||
ffi::{CStr, CString},
|
||||
io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
os::raw::c_char,
|
||||
path::Path,
|
||||
ptr, str,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
impl<'a> From<&'a wgctrl_sys::wg_allowedip> for AllowedIp {
|
||||
fn from(raw: &wgctrl_sys::wg_allowedip) -> AllowedIp {
|
||||
let addr = match i32::from(raw.family) {
|
||||
libc::AF_INET => IpAddr::V4(unsafe { raw.__bindgen_anon_1.ip4.s_addr }.to_be().into()),
|
||||
libc::AF_INET6 => {
|
||||
IpAddr::V6(unsafe { raw.__bindgen_anon_1.ip6.__in6_u.__u6_addr8 }.into())
|
||||
},
|
||||
_ => unreachable!(format!("Unsupported socket family {}!", raw.family)),
|
||||
};
|
||||
|
||||
AllowedIp {
|
||||
address: addr,
|
||||
cidr: raw.cidr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a wgctrl_sys::wg_peer> for PeerInfo {
|
||||
fn from(raw: &wgctrl_sys::wg_peer) -> PeerInfo {
|
||||
PeerInfo {
|
||||
config: PeerConfig {
|
||||
public_key: Key::from_raw(raw.public_key),
|
||||
preshared_key: if (raw.flags & wgpf::WGPEER_HAS_PRESHARED_KEY).0 > 0 {
|
||||
Some(Key::from_raw(raw.preshared_key))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
endpoint: parse_endpoint(&raw.endpoint),
|
||||
persistent_keepalive_interval: match raw.persistent_keepalive_interval {
|
||||
0 => None,
|
||||
x => Some(x),
|
||||
},
|
||||
allowed_ips: parse_allowed_ips(raw),
|
||||
__cant_construct_me: (),
|
||||
},
|
||||
stats: PeerStats {
|
||||
last_handshake_time: match (
|
||||
raw.last_handshake_time.tv_sec,
|
||||
raw.last_handshake_time.tv_nsec,
|
||||
) {
|
||||
(0, 0) => None,
|
||||
(s, ns) => Some(SystemTime::UNIX_EPOCH + Duration::new(s as u64, ns as u32)),
|
||||
},
|
||||
rx_bytes: raw.rx_bytes,
|
||||
tx_bytes: raw.tx_bytes,
|
||||
__cant_construct_me: (),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a wgctrl_sys::wg_device> for DeviceInfo {
|
||||
fn from(raw: &wgctrl_sys::wg_device) -> DeviceInfo {
|
||||
DeviceInfo {
|
||||
name: parse_device_name(raw.name),
|
||||
public_key: if (raw.flags & wgdf::WGDEVICE_HAS_PUBLIC_KEY).0 > 0 {
|
||||
Some(Key::from_raw(raw.public_key))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
private_key: if (raw.flags & wgdf::WGDEVICE_HAS_PRIVATE_KEY).0 > 0 {
|
||||
Some(Key::from_raw(raw.private_key))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
fwmark: match raw.fwmark {
|
||||
0 => None,
|
||||
x => Some(x),
|
||||
},
|
||||
listen_port: match raw.listen_port {
|
||||
0 => None,
|
||||
x => Some(x),
|
||||
},
|
||||
peers: parse_peers(&raw),
|
||||
linked_name: None,
|
||||
__cant_construct_me: (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_device_name(name: [c_char; 16]) -> String {
|
||||
let name: &[u8; 16] = unsafe { &*((&name) as *const _ as *const [u8; 16]) };
|
||||
let idx: usize = name
|
||||
.iter()
|
||||
.position(|x| *x == 0)
|
||||
.expect("Interface name too long?");
|
||||
unsafe { str::from_utf8_unchecked(&name[..idx]) }.to_owned()
|
||||
}
|
||||
|
||||
fn parse_peers(dev: &wgctrl_sys::wg_device) -> Vec<PeerInfo> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let mut current_peer = dev.first_peer;
|
||||
|
||||
if current_peer.is_null() {
|
||||
return result;
|
||||
}
|
||||
|
||||
loop {
|
||||
let peer = unsafe { &*current_peer };
|
||||
|
||||
result.push(PeerInfo::from(peer));
|
||||
|
||||
if current_peer == dev.last_peer {
|
||||
break;
|
||||
}
|
||||
current_peer = peer.next_peer;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_allowed_ips(peer: &wgctrl_sys::wg_peer) -> Vec<AllowedIp> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let mut current_ip: *mut wgctrl_sys::wg_allowedip = peer.first_allowedip;
|
||||
|
||||
if current_ip.is_null() {
|
||||
return result;
|
||||
}
|
||||
|
||||
loop {
|
||||
let ip = unsafe { &*current_ip };
|
||||
|
||||
result.push(AllowedIp::from(ip));
|
||||
|
||||
if current_ip == peer.last_allowedip {
|
||||
break;
|
||||
}
|
||||
current_ip = ip.next_allowedip;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_endpoint(endpoint: &wgctrl_sys::wg_peer__bindgen_ty_1) -> Option<SocketAddr> {
|
||||
let addr = unsafe { endpoint.addr };
|
||||
match i32::from(addr.sa_family) {
|
||||
libc::AF_INET => {
|
||||
let addr4 = unsafe { endpoint.addr4 };
|
||||
Some(SocketAddr::new(
|
||||
IpAddr::V4(u32::from_be(addr4.sin_addr.s_addr).into()),
|
||||
u16::from_be(addr4.sin_port),
|
||||
))
|
||||
},
|
||||
libc::AF_INET6 => {
|
||||
let addr6 = unsafe { endpoint.addr6 };
|
||||
let bytes = unsafe { addr6.sin6_addr.__in6_u.__u6_addr8 };
|
||||
Some(SocketAddr::new(
|
||||
IpAddr::V6(bytes.into()),
|
||||
u16::from_be(addr6.sin6_port),
|
||||
))
|
||||
},
|
||||
0 => None,
|
||||
_ => unreachable!(format!("Unsupported socket family: {}!", addr.sa_family)),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_allowedips(
|
||||
allowed_ips: &[AllowedIp],
|
||||
) -> (*mut wgctrl_sys::wg_allowedip, *mut wgctrl_sys::wg_allowedip) {
|
||||
if allowed_ips.is_empty() {
|
||||
return (ptr::null_mut(), ptr::null_mut());
|
||||
}
|
||||
|
||||
let mut first_ip = ptr::null_mut();
|
||||
let mut last_ip: *mut wgctrl_sys::wg_allowedip = ptr::null_mut();
|
||||
|
||||
for ip in allowed_ips {
|
||||
let mut wg_allowedip = Box::new(wgctrl_sys::wg_allowedip {
|
||||
family: 0,
|
||||
__bindgen_anon_1: Default::default(),
|
||||
cidr: ip.cidr,
|
||||
next_allowedip: first_ip,
|
||||
});
|
||||
|
||||
match ip.address {
|
||||
IpAddr::V4(a) => {
|
||||
wg_allowedip.family = libc::AF_INET as u16;
|
||||
wg_allowedip.__bindgen_anon_1.ip4.s_addr = u32::to_be(a.into());
|
||||
},
|
||||
IpAddr::V6(a) => {
|
||||
wg_allowedip.family = libc::AF_INET6 as u16;
|
||||
wg_allowedip.__bindgen_anon_1.ip6.__in6_u.__u6_addr8 = a.octets();
|
||||
},
|
||||
}
|
||||
|
||||
first_ip = Box::into_raw(wg_allowedip);
|
||||
if last_ip.is_null() {
|
||||
last_ip = first_ip;
|
||||
}
|
||||
}
|
||||
|
||||
(first_ip, last_ip)
|
||||
}
|
||||
|
||||
fn encode_endpoint(endpoint: Option<SocketAddr>) -> wgctrl_sys::wg_peer__bindgen_ty_1 {
|
||||
match endpoint {
|
||||
Some(SocketAddr::V4(s)) => {
|
||||
let mut peer = wgctrl_sys::wg_peer__bindgen_ty_1::default();
|
||||
peer.addr4 = wgctrl_sys::sockaddr_in {
|
||||
sin_family: libc::AF_INET as u16,
|
||||
sin_addr: wgctrl_sys::in_addr {
|
||||
s_addr: u32::from_be(s.ip().clone().into()),
|
||||
},
|
||||
sin_port: u16::to_be(s.port()),
|
||||
sin_zero: [0; 8],
|
||||
};
|
||||
peer
|
||||
},
|
||||
Some(SocketAddr::V6(s)) => {
|
||||
let mut peer = wgctrl_sys::wg_peer__bindgen_ty_1::default();
|
||||
peer.addr6 = wgctrl_sys::sockaddr_in6 {
|
||||
sin6_family: libc::AF_INET6 as u16,
|
||||
sin6_addr: Default::default(),
|
||||
sin6_port: u16::to_be(s.port()),
|
||||
sin6_flowinfo: 0,
|
||||
sin6_scope_id: 0,
|
||||
};
|
||||
unsafe { peer.addr6 }.sin6_addr.__in6_u.__u6_addr8 = s.ip().octets();
|
||||
peer
|
||||
},
|
||||
None => wgctrl_sys::wg_peer__bindgen_ty_1::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_peers(
|
||||
peers: Vec<PeerConfigBuilder>,
|
||||
) -> (*mut wgctrl_sys::wg_peer, *mut wgctrl_sys::wg_peer) {
|
||||
let mut first_peer = ptr::null_mut();
|
||||
let mut last_peer: *mut wgctrl_sys::wg_peer = ptr::null_mut();
|
||||
|
||||
for peer in peers {
|
||||
let (first_allowedip, last_allowedip) = encode_allowedips(&peer.allowed_ips);
|
||||
|
||||
let mut wg_peer = Box::new(wgctrl_sys::wg_peer {
|
||||
public_key: peer.public_key.0,
|
||||
preshared_key: wgctrl_sys::wg_key::default(),
|
||||
endpoint: encode_endpoint(peer.endpoint),
|
||||
last_handshake_time: timespec64 {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
},
|
||||
tx_bytes: 0,
|
||||
rx_bytes: 0,
|
||||
persistent_keepalive_interval: 0,
|
||||
first_allowedip,
|
||||
last_allowedip,
|
||||
next_peer: first_peer,
|
||||
flags: wgpf::WGPEER_HAS_PUBLIC_KEY,
|
||||
});
|
||||
|
||||
if let Some(Key(k)) = peer.preshared_key {
|
||||
wg_peer.flags |= wgpf::WGPEER_HAS_PRESHARED_KEY;
|
||||
wg_peer.preshared_key = k;
|
||||
}
|
||||
|
||||
if let Some(n) = peer.persistent_keepalive_interval {
|
||||
wg_peer.persistent_keepalive_interval = n;
|
||||
wg_peer.flags |= wgpf::WGPEER_HAS_PERSISTENT_KEEPALIVE_INTERVAL;
|
||||
}
|
||||
|
||||
if peer.replace_allowed_ips {
|
||||
wg_peer.flags |= wgpf::WGPEER_REPLACE_ALLOWEDIPS;
|
||||
}
|
||||
|
||||
if peer.remove_me {
|
||||
wg_peer.flags |= wgpf::WGPEER_REMOVE_ME;
|
||||
}
|
||||
|
||||
first_peer = Box::into_raw(wg_peer);
|
||||
if last_peer.is_null() {
|
||||
last_peer = first_peer;
|
||||
}
|
||||
}
|
||||
|
||||
(first_peer, last_peer)
|
||||
}
|
||||
|
||||
fn encode_name(name: &str) -> [c_char; 16] {
|
||||
let slice = unsafe { &*(name.as_bytes() as *const _ as *const [c_char]) };
|
||||
|
||||
let mut result = [c_char::default(); 16];
|
||||
result[..slice.len()].copy_from_slice(slice);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn exists() -> bool {
|
||||
Path::new("/sys/module/wireguard").is_dir()
|
||||
}
|
||||
|
||||
pub fn enumerate() -> Result<Vec<String>, io::Error> {
|
||||
let base = unsafe { wgctrl_sys::wg_list_device_names() };
|
||||
|
||||
if base.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let mut current = base;
|
||||
let mut result = Vec::new();
|
||||
|
||||
loop {
|
||||
let next_dev = unsafe { CStr::from_ptr(current).to_bytes() };
|
||||
|
||||
let len = next_dev.len();
|
||||
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
current = unsafe { current.add(len + 1) };
|
||||
result.push(unsafe { str::from_utf8_unchecked(next_dev) }.to_owned());
|
||||
}
|
||||
|
||||
unsafe { libc::free(base as *mut libc::c_void) };
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn apply(builder: DeviceConfigBuilder, iface: &str) -> io::Result<()> {
|
||||
let (first_peer, last_peer) = encode_peers(builder.peers);
|
||||
|
||||
let iface_str = CString::new(iface)?;
|
||||
let result = unsafe { wgctrl_sys::wg_add_device(iface_str.as_ptr()) };
|
||||
match result {
|
||||
0 | -17 => {},
|
||||
_ => return Err(io::Error::last_os_error()),
|
||||
};
|
||||
|
||||
let mut wg_device = Box::new(wgctrl_sys::wg_device {
|
||||
name: encode_name(iface),
|
||||
ifindex: 0,
|
||||
public_key: wgctrl_sys::wg_key::default(),
|
||||
private_key: wgctrl_sys::wg_key::default(),
|
||||
fwmark: 0,
|
||||
listen_port: 0,
|
||||
first_peer,
|
||||
last_peer,
|
||||
flags: wgdf(0),
|
||||
});
|
||||
|
||||
if let Some(Key(k)) = builder.public_key {
|
||||
wg_device.public_key = k;
|
||||
wg_device.flags |= wgdf::WGDEVICE_HAS_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
if let Some(Key(k)) = builder.private_key {
|
||||
wg_device.private_key = k;
|
||||
wg_device.flags |= wgdf::WGDEVICE_HAS_PRIVATE_KEY;
|
||||
}
|
||||
|
||||
if let Some(f) = builder.fwmark {
|
||||
wg_device.fwmark = f;
|
||||
wg_device.flags |= wgdf::WGDEVICE_HAS_FWMARK;
|
||||
}
|
||||
|
||||
if let Some(f) = builder.listen_port {
|
||||
wg_device.listen_port = f;
|
||||
wg_device.flags |= wgdf::WGDEVICE_HAS_LISTEN_PORT;
|
||||
}
|
||||
|
||||
if builder.replace_peers {
|
||||
wg_device.flags |= wgdf::WGDEVICE_REPLACE_PEERS;
|
||||
}
|
||||
|
||||
let ptr = Box::into_raw(wg_device);
|
||||
let result = unsafe { wgctrl_sys::wg_set_device(ptr) };
|
||||
|
||||
unsafe { wgctrl_sys::wg_free_device(ptr) };
|
||||
|
||||
if result == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_by_name(name: &str) -> Result<DeviceInfo, io::Error> {
|
||||
let mut device: *mut wgctrl_sys::wg_device = ptr::null_mut();
|
||||
|
||||
let cs = CString::new(name)?;
|
||||
|
||||
let result = unsafe {
|
||||
wgctrl_sys::wg_get_device(
|
||||
(&mut device) as *mut _ as *mut *mut wgctrl_sys::wg_device,
|
||||
cs.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
let result = if result == 0 {
|
||||
Ok(DeviceInfo::from(unsafe { &*device }))
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
};
|
||||
|
||||
unsafe { wgctrl_sys::wg_free_device(device) };
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn delete_interface(iface: &str) -> io::Result<()> {
|
||||
let iface_str = CString::new(iface)?;
|
||||
let result = unsafe { wgctrl_sys::wg_del_device(iface_str.as_ptr()) };
|
||||
|
||||
if result == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a WireGuard encryption key.
|
||||
///
|
||||
/// WireGuard makes no meaningful distinction between public,
|
||||
/// private and preshared keys - any sequence of 32 bytes
|
||||
/// can be used as either of those.
|
||||
///
|
||||
/// This means that you need to be careful when working with
|
||||
/// `Key`s, especially ones created from external data.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(PartialEq, Eq, Clone)]
|
||||
pub struct Key(pub wgctrl_sys::wg_key);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Key {
|
||||
/// Creates a new `Key` from raw bytes.
|
||||
pub fn from_raw(key: wgctrl_sys::wg_key) -> Self {
|
||||
Self(key)
|
||||
}
|
||||
|
||||
/// Generates and returns a new private key.
|
||||
pub fn generate_private() -> Self {
|
||||
let mut private_key = wgctrl_sys::wg_key::default();
|
||||
|
||||
unsafe {
|
||||
wgctrl_sys::wg_generate_private_key(private_key.as_mut_ptr());
|
||||
}
|
||||
|
||||
Self(private_key)
|
||||
}
|
||||
|
||||
/// Generates and returns a new preshared key.
|
||||
pub fn generate_preshared() -> Self {
|
||||
let mut preshared_key = wgctrl_sys::wg_key::default();
|
||||
|
||||
unsafe {
|
||||
wgctrl_sys::wg_generate_preshared_key(preshared_key.as_mut_ptr());
|
||||
}
|
||||
|
||||
Self(preshared_key)
|
||||
}
|
||||
|
||||
/// Generates a public key for this private key.
|
||||
pub fn generate_public(&self) -> Self {
|
||||
let mut public_key = wgctrl_sys::wg_key::default();
|
||||
|
||||
unsafe {
|
||||
wgctrl_sys::wg_generate_public_key(
|
||||
public_key.as_mut_ptr(),
|
||||
&self.0 as *const u8 as *mut u8,
|
||||
);
|
||||
}
|
||||
|
||||
Self(public_key)
|
||||
}
|
||||
|
||||
/// Generates an all-zero key.
|
||||
pub fn zero() -> Self {
|
||||
Self(wgctrl_sys::wg_key::default())
|
||||
}
|
||||
|
||||
/// Checks if this key is all-zero.
|
||||
pub fn is_zero(&self) -> bool {
|
||||
unsafe { wgctrl_sys::wg_key_is_zero(&self.0 as *const u8 as *mut u8) }
|
||||
}
|
||||
|
||||
/// Converts the key to a standardized base64 representation, as used by the `wg` utility and `wg-quick`.
|
||||
pub fn to_base64(&self) -> String {
|
||||
let mut key_b64: wgctrl_sys::wg_key_b64_string = [0; 45];
|
||||
unsafe {
|
||||
wgctrl_sys::wg_key_to_base64(key_b64.as_mut_ptr(), &self.0 as *const u8 as *mut u8);
|
||||
|
||||
str::from_utf8_unchecked(&*(&key_b64[..44] as *const [c_char] as *const [u8])).into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a base64 representation of the key to the raw bytes.
|
||||
///
|
||||
/// This can fail, as not all text input is valid base64 - in this case
|
||||
/// `Err(InvalidKey)` is returned.
|
||||
pub fn from_base64(key: &str) -> Result<Self, InvalidKey> {
|
||||
let mut decoded = wgctrl_sys::wg_key::default();
|
||||
|
||||
let key_str = CString::new(key)?;
|
||||
let result = unsafe {
|
||||
wgctrl_sys::wg_key_from_base64(decoded.as_mut_ptr(), key_str.as_ptr() as *mut _)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
Ok(Self { 0: decoded })
|
||||
} else {
|
||||
Err(InvalidKey)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_hex(hex_str: &str) -> Result<Self, InvalidKey> {
|
||||
let bytes = hex::decode(hex_str).map_err(|_| InvalidKey)?;
|
||||
Self::from_base64(&base64::encode(&bytes))
|
||||
}
|
||||
}
|
4
wgctrl-rs/src/backends/mod.rs
Normal file
4
wgctrl-rs/src/backends/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod kernel;
|
||||
|
||||
pub mod userspace;
|
427
wgctrl-rs/src/backends/userspace.rs
Normal file
427
wgctrl-rs/src/backends/userspace.rs
Normal file
@ -0,0 +1,427 @@
|
||||
use crate::{DeviceConfigBuilder, DeviceInfo, PeerConfig, PeerInfo, PeerStats};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::Key;
|
||||
|
||||
use std::{
|
||||
fs, io,
|
||||
io::{prelude::*, BufReader},
|
||||
os::unix::net::UnixStream,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
static VAR_RUN_PATH: &str = "/var/run/wireguard";
|
||||
static RUN_PATH: &str = "/run/wireguard";
|
||||
|
||||
fn get_base_folder() -> io::Result<PathBuf> {
|
||||
if Path::new(VAR_RUN_PATH).exists() {
|
||||
Ok(Path::new(VAR_RUN_PATH).to_path_buf())
|
||||
} else if Path::new(RUN_PATH).exists() {
|
||||
Ok(Path::new(RUN_PATH).to_path_buf())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"WireGuard socket directory not found.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_namefile(name: &str) -> io::Result<PathBuf> {
|
||||
Ok(get_base_folder()?.join(&format!("{}.name", name)))
|
||||
}
|
||||
|
||||
fn get_socketfile(name: &str) -> io::Result<PathBuf> {
|
||||
Ok(get_base_folder()?.join(&format!("{}.sock", resolve_tun(name)?)))
|
||||
}
|
||||
|
||||
fn open_socket(name: &str) -> io::Result<UnixStream> {
|
||||
UnixStream::connect(get_socketfile(name)?)
|
||||
}
|
||||
|
||||
pub fn resolve_tun(name: &str) -> io::Result<String> {
|
||||
let namefile = get_namefile(name)?;
|
||||
Ok(fs::read_to_string(namefile)?.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn delete_interface(name: &str) -> io::Result<()> {
|
||||
fs::remove_file(get_socketfile(name)?).ok();
|
||||
fs::remove_file(get_namefile(name)?).ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enumerate() -> Result<Vec<String>, io::Error> {
|
||||
use std::ffi::OsStr;
|
||||
|
||||
let mut interfaces = vec![];
|
||||
for entry in fs::read_dir(get_base_folder()?)? {
|
||||
let path = entry?.path();
|
||||
if path.extension() == Some(OsStr::new("name")) {
|
||||
let stem = path.file_stem().map(|stem| stem.to_str()).flatten();
|
||||
if let Some(name) = stem {
|
||||
interfaces.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(interfaces)
|
||||
}
|
||||
|
||||
fn new_peer_info(public_key: Key) -> PeerInfo {
|
||||
PeerInfo {
|
||||
config: PeerConfig {
|
||||
public_key,
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
persistent_keepalive_interval: None,
|
||||
allowed_ips: vec![],
|
||||
__cant_construct_me: (),
|
||||
},
|
||||
stats: PeerStats {
|
||||
last_handshake_time: None,
|
||||
rx_bytes: 0,
|
||||
tx_bytes: 0,
|
||||
__cant_construct_me: (),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigParser {
|
||||
device_info: DeviceInfo,
|
||||
current_peer: Option<PeerInfo>,
|
||||
}
|
||||
|
||||
impl From<ConfigParser> for DeviceInfo {
|
||||
fn from(parser: ConfigParser) -> Self {
|
||||
parser.device_info
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigParser {
|
||||
fn new(name: &str) -> Self {
|
||||
let device_info = DeviceInfo {
|
||||
name: name.to_string(),
|
||||
public_key: None,
|
||||
private_key: None,
|
||||
fwmark: None,
|
||||
listen_port: None,
|
||||
peers: vec![],
|
||||
linked_name: resolve_tun(name).ok(),
|
||||
__cant_construct_me: (),
|
||||
};
|
||||
|
||||
Self {
|
||||
device_info,
|
||||
current_peer: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_line(&mut self, line: &str) -> Result<(), std::io::Error> {
|
||||
use io::ErrorKind::InvalidData;
|
||||
|
||||
let split: Vec<&str> = line.splitn(2, '=').collect();
|
||||
match &split[..] {
|
||||
[key, value] => self.add_pair(key, value),
|
||||
_ => Err(InvalidData.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_pair(&mut self, key: &str, value: &str) -> Result<(), std::io::Error> {
|
||||
use io::ErrorKind::InvalidData;
|
||||
|
||||
match key {
|
||||
"private_key" => {
|
||||
self.device_info.private_key = Some(Key::from_hex(value).map_err(|_| InvalidData)?);
|
||||
self.device_info.public_key = self
|
||||
.device_info
|
||||
.private_key
|
||||
.as_ref()
|
||||
.map(|k| k.generate_public());
|
||||
},
|
||||
"listen_port" => {
|
||||
self.device_info.listen_port = Some(value.parse().map_err(|_| InvalidData)?)
|
||||
},
|
||||
"fwmark" => self.device_info.fwmark = Some(value.parse().map_err(|_| InvalidData)?),
|
||||
"public_key" => {
|
||||
let new_peer = new_peer_info(Key::from_hex(value).map_err(|_| InvalidData)?);
|
||||
|
||||
if let Some(finished_peer) = self.current_peer.replace(new_peer) {
|
||||
self.device_info.peers.push(finished_peer);
|
||||
}
|
||||
},
|
||||
"preshared_key" => {
|
||||
self.current_peer
|
||||
.as_mut()
|
||||
.ok_or(InvalidData)?
|
||||
.config
|
||||
.preshared_key = Some(Key::from_hex(value).map_err(|_| InvalidData)?);
|
||||
},
|
||||
"tx_bytes" => {
|
||||
self.current_peer
|
||||
.as_mut()
|
||||
.ok_or(InvalidData)?
|
||||
.stats
|
||||
.tx_bytes = value.parse().map_err(|_| InvalidData)?
|
||||
},
|
||||
"rx_bytes" => {
|
||||
self.current_peer
|
||||
.as_mut()
|
||||
.ok_or(InvalidData)?
|
||||
.stats
|
||||
.rx_bytes = value.parse().map_err(|_| InvalidData)?
|
||||
},
|
||||
"last_handshake_time_sec" => {
|
||||
let handshake_seconds: u64 = value.parse().map_err(|_| InvalidData)?;
|
||||
|
||||
if handshake_seconds > 0 {
|
||||
self.current_peer
|
||||
.as_mut()
|
||||
.ok_or(InvalidData)?
|
||||
.stats
|
||||
.last_handshake_time =
|
||||
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(handshake_seconds));
|
||||
}
|
||||
},
|
||||
"allowed_ip" => {
|
||||
self.current_peer
|
||||
.as_mut()
|
||||
.ok_or(InvalidData)?
|
||||
.config
|
||||
.allowed_ips
|
||||
.push(value.parse().map_err(|_| InvalidData)?);
|
||||
},
|
||||
"persistent_keepalive_interval" => {
|
||||
self.current_peer
|
||||
.as_mut()
|
||||
.ok_or(InvalidData)?
|
||||
.config
|
||||
.persistent_keepalive_interval = Some(value.parse().map_err(|_| InvalidData)?);
|
||||
},
|
||||
"endpoint" => {
|
||||
self.current_peer
|
||||
.as_mut()
|
||||
.ok_or(InvalidData)?
|
||||
.config
|
||||
.endpoint = Some(value.parse().map_err(|_| InvalidData)?);
|
||||
},
|
||||
"errno" => {
|
||||
// "errno" indicates an end of the stream, along with the error return code.
|
||||
if value != "0" {
|
||||
return Err(std::io::Error::from_raw_os_error(
|
||||
value
|
||||
.parse()
|
||||
.expect("Unable to parse userspace wg errno return code"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(finished_peer) = self.current_peer.take() {
|
||||
self.device_info.peers.push(finished_peer);
|
||||
}
|
||||
},
|
||||
"protocol_version" | "last_handshake_time_nsec" => {},
|
||||
_ => println!("got unsupported info: {}={}", key, value),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_by_name(name: &str) -> Result<DeviceInfo, io::Error> {
|
||||
let mut sock = open_socket(name)?;
|
||||
sock.write_all(b"get=1\n\n")?;
|
||||
let mut reader = BufReader::new(sock);
|
||||
let mut buf = String::new();
|
||||
|
||||
let mut parser = ConfigParser::new(name);
|
||||
loop {
|
||||
match reader.read_line(&mut buf)? {
|
||||
0 | 1 if buf == "\n" => break,
|
||||
_ => {
|
||||
parser.add_line(&buf.trim_end())?;
|
||||
buf.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Ok(parser.into())
|
||||
}
|
||||
|
||||
pub fn apply(builder: DeviceConfigBuilder, iface: &str) -> io::Result<()> {
|
||||
// If we can't open a configuration socket to an existing interface, try starting it.
|
||||
let mut sock = match open_socket(iface) {
|
||||
Err(_) => {
|
||||
// TODO(jake): allow other userspace wireguard implementations
|
||||
let output = Command::new("wireguard-go")
|
||||
.env(
|
||||
"WG_TUN_NAME_FILE",
|
||||
&format!("{}/{}.name", VAR_RUN_PATH, iface),
|
||||
)
|
||||
.args(&["utun"])
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::ErrorKind::AddrNotAvailable.into());
|
||||
}
|
||||
open_socket(iface)?
|
||||
},
|
||||
Ok(sock) => sock,
|
||||
};
|
||||
|
||||
let mut request = String::from("set=1\n");
|
||||
|
||||
if let Some(Key(k)) = builder.private_key {
|
||||
request.push_str(&format!("private_key={}\n", hex::encode(k)));
|
||||
}
|
||||
|
||||
if let Some(f) = builder.fwmark {
|
||||
request.push_str(&format!("fwmark={}\n", f));
|
||||
}
|
||||
|
||||
if let Some(f) = builder.listen_port {
|
||||
request.push_str(&format!("listen_port={}\n", f));
|
||||
}
|
||||
|
||||
if builder.replace_peers {
|
||||
request.push_str("replace_peers=true\n");
|
||||
}
|
||||
|
||||
for peer in builder.peers {
|
||||
request.push_str(&format!("public_key={}\n", hex::encode(peer.public_key.0)));
|
||||
|
||||
if peer.replace_allowed_ips {
|
||||
request.push_str("replace_allowed_ips=true\n");
|
||||
}
|
||||
|
||||
if peer.remove_me {
|
||||
request.push_str("remove=true\n");
|
||||
}
|
||||
|
||||
if let Some(Key(preshared_key)) = peer.preshared_key {
|
||||
request.push_str(&format!("preshared_key={}\n", hex::encode(preshared_key)));
|
||||
}
|
||||
|
||||
if let Some(endpoint) = peer.endpoint {
|
||||
request.push_str(&format!("endpoint={}\n", endpoint));
|
||||
}
|
||||
|
||||
if let Some(keepalive_interval) = peer.persistent_keepalive_interval {
|
||||
request.push_str(&format!(
|
||||
"persistent_keepalive_interval={}\n",
|
||||
keepalive_interval
|
||||
));
|
||||
}
|
||||
|
||||
for allowed_ip in peer.allowed_ips {
|
||||
request.push_str(&format!(
|
||||
"allowed_ip={}/{}\n",
|
||||
allowed_ip.address, allowed_ip.cidr
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
request.push('\n');
|
||||
|
||||
sock.write_all(request.as_bytes())?;
|
||||
|
||||
let mut reader = BufReader::new(sock);
|
||||
let mut line = String::new();
|
||||
|
||||
reader.read_line(&mut line)?;
|
||||
let split: Vec<&str> = line.trim_end().splitn(2, '=').collect();
|
||||
match &split[..] {
|
||||
["errno", "0"] => Ok(()),
|
||||
["errno", val] => {
|
||||
println!("ERROR {}", val);
|
||||
Err(io::ErrorKind::InvalidInput.into())
|
||||
},
|
||||
_ => Err(io::ErrorKind::Other.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a WireGuard encryption key.
|
||||
///
|
||||
/// WireGuard makes no meaningful distinction between public,
|
||||
/// private and preshared keys - any sequence of 32 bytes
|
||||
/// can be used as either of those.
|
||||
///
|
||||
/// This means that you need to be careful when working with
|
||||
/// `Key`s, especially ones created from external data.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[derive(PartialEq, Eq, Clone)]
|
||||
pub struct Key([u8; 32]);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl Key {
|
||||
/// Generates and returns a new private key.
|
||||
pub fn generate_private() -> Self {
|
||||
use rand_core::OsRng;
|
||||
use x25519_dalek::StaticSecret;
|
||||
|
||||
let key = StaticSecret::new(OsRng);
|
||||
Self(key.to_bytes())
|
||||
}
|
||||
|
||||
/// Generates and returns a new preshared key.
|
||||
pub fn generate_preshared() -> Self {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
Self(key)
|
||||
}
|
||||
|
||||
/// Generates a public key for this private key.
|
||||
pub fn generate_public(&self) -> Self {
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
let mut public_bytes = [0u8; 32];
|
||||
let private_key = StaticSecret::from(self.0);
|
||||
let public_key = PublicKey::from(&private_key);
|
||||
public_bytes.copy_from_slice(public_key.as_bytes());
|
||||
Self(public_bytes)
|
||||
}
|
||||
|
||||
/// Generates an all-zero key.
|
||||
pub fn zero() -> Self {
|
||||
Self([0u8; 32])
|
||||
}
|
||||
|
||||
/// Checks if this key is all-zero.
|
||||
pub fn is_zero(&self) -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
self.0.ct_eq(&[0u8; 32]).into()
|
||||
}
|
||||
|
||||
/// Converts the key to a standardized base64 representation, as used by the `wg` utility and `wg-quick`.
|
||||
pub fn to_base64(&self) -> String {
|
||||
base64::encode(&self.0)
|
||||
}
|
||||
|
||||
/// Converts a base64 representation of the key to the raw bytes.
|
||||
///
|
||||
/// This can fail, as not all text input is valid base64 - in this case
|
||||
/// `Err(InvalidKey)` is returned.
|
||||
pub fn from_base64(key: &str) -> Result<Self, crate::InvalidKey> {
|
||||
use crate::InvalidKey;
|
||||
|
||||
let mut key_bytes = [0u8; 32];
|
||||
let decoded_bytes = base64::decode(key).map_err(|_| InvalidKey)?;
|
||||
|
||||
if decoded_bytes.len() != 32 {
|
||||
return Err(InvalidKey);
|
||||
}
|
||||
|
||||
key_bytes.copy_from_slice(&decoded_bytes[..]);
|
||||
Ok(Self(key_bytes))
|
||||
}
|
||||
|
||||
pub fn from_hex(hex_str: &str) -> Result<Self, crate::InvalidKey> {
|
||||
use crate::InvalidKey;
|
||||
|
||||
let mut sized_bytes = [0u8; 32];
|
||||
hex::decode_to_slice(hex_str, &mut sized_bytes).map_err(|_| InvalidKey)?;
|
||||
Ok(Self(sized_bytes))
|
||||
}
|
||||
}
|
358
wgctrl-rs/src/config.rs
Normal file
358
wgctrl-rs/src/config.rs
Normal file
@ -0,0 +1,358 @@
|
||||
use crate::{
|
||||
backends,
|
||||
device::{AllowedIp, PeerConfig},
|
||||
key::{Key, KeyPair},
|
||||
};
|
||||
|
||||
use std::{
|
||||
io,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
};
|
||||
|
||||
/// Builds and represents a configuration that can be applied to a WireGuard interface.
|
||||
///
|
||||
/// This is the primary way of changing the settings of an interface.
|
||||
///
|
||||
/// Note that if an interface exists, the configuration is applied _on top_ of the existing
|
||||
/// settings, and missing parts are not overwritten or set to defaults.
|
||||
///
|
||||
/// If this is not what you want, use [`delete_interface`](delete_interface)
|
||||
/// to remove the interface entirely before applying the new configuration.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// # use wgctrl::*;
|
||||
/// # use std::net::AddrParseError;
|
||||
/// # fn try_main() -> Result<(), AddrParseError> {
|
||||
/// let our_keypair = KeyPair::generate();
|
||||
/// let peer_keypair = KeyPair::generate();
|
||||
/// let server_addr = "192.168.1.1:51820".parse()?;
|
||||
///
|
||||
/// DeviceConfigBuilder::new()
|
||||
/// .set_keypair(our_keypair)
|
||||
/// .replace_peers()
|
||||
/// .add_peer_with(&peer_keypair.public, |peer| {
|
||||
/// peer.set_endpoint(server_addr)
|
||||
/// .replace_allowed_ips()
|
||||
/// .allow_all_ips()
|
||||
/// }).apply("wg-example");
|
||||
///
|
||||
/// println!("Send these keys to your peer: {:#?}", peer_keypair);
|
||||
///
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// # fn main() { try_main(); }
|
||||
/// ```
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct DeviceConfigBuilder {
|
||||
pub(crate) public_key: Option<Key>,
|
||||
pub(crate) private_key: Option<Key>,
|
||||
pub(crate) fwmark: Option<u32>,
|
||||
pub(crate) listen_port: Option<u16>,
|
||||
pub(crate) peers: Vec<PeerConfigBuilder>,
|
||||
pub(crate) replace_peers: bool,
|
||||
}
|
||||
|
||||
impl DeviceConfigBuilder {
|
||||
/// Creates a new `DeviceConfigBuilder` that does nothing when applied.
|
||||
pub fn new() -> Self {
|
||||
DeviceConfigBuilder {
|
||||
public_key: None,
|
||||
private_key: None,
|
||||
fwmark: None,
|
||||
listen_port: None,
|
||||
peers: vec![],
|
||||
replace_peers: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a new keypair to be applied to the interface.
|
||||
///
|
||||
/// This is a convenience method that simply wraps
|
||||
/// [`set_public_key`](DeviceConfigBuilder::set_public_key)
|
||||
/// and [`set_private_key`](DeviceConfigBuilder::set_private_key).
|
||||
pub fn set_keypair(self, keypair: KeyPair) -> Self {
|
||||
self.set_public_key(keypair.public)
|
||||
.set_private_key(keypair.private)
|
||||
}
|
||||
|
||||
/// Specifies a new public key to be applied to the interface.
|
||||
pub fn set_public_key(mut self, key: Key) -> Self {
|
||||
self.public_key = Some(key);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that the public key for this interface should be unset.
|
||||
pub fn unset_public_key(self) -> Self {
|
||||
self.set_public_key(Key::zero())
|
||||
}
|
||||
|
||||
/// Sets a new private key to be applied to the interface.
|
||||
pub fn set_private_key(mut self, key: Key) -> Self {
|
||||
self.private_key = Some(key);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that the private key for this interface should be unset.
|
||||
pub fn unset_private_key(self) -> Self {
|
||||
self.set_private_key(Key::zero())
|
||||
}
|
||||
|
||||
/// Specifies the fwmark value that should be applied to packets coming from the interface.
|
||||
pub fn set_fwmark(mut self, fwmark: u32) -> Self {
|
||||
self.fwmark = Some(fwmark);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that fwmark should not be set on packets from the interface.
|
||||
pub fn unset_fwmark(self) -> Self {
|
||||
self.set_fwmark(0)
|
||||
}
|
||||
|
||||
/// Specifies the port to listen for incoming packets on.
|
||||
///
|
||||
/// This is useful for a server configuration that listens on a fixed endpoint.
|
||||
pub fn set_listen_port(mut self, port: u16) -> Self {
|
||||
self.listen_port = Some(port);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that a random port should be used for incoming packets.
|
||||
///
|
||||
/// This is probably what you want in client configurations.
|
||||
pub fn randomize_listen_port(self) -> Self {
|
||||
self.set_listen_port(0)
|
||||
}
|
||||
|
||||
/// Specifies a new peer configuration to be added to the interface.
|
||||
///
|
||||
/// See [`PeerConfigBuilder`](PeerConfigBuilder) for details on building
|
||||
/// peer configurations. This method can be called more than once, and all
|
||||
/// peers will be added to the configuration.
|
||||
pub fn add_peer(mut self, peer: PeerConfigBuilder) -> Self {
|
||||
self.peers.push(peer);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies a new peer configuration using a builder function.
|
||||
///
|
||||
/// This is simply a convenience method to make adding peers more fluent.
|
||||
/// This method can be called more than once, and all peers will be added
|
||||
/// to the configuration.
|
||||
pub fn add_peer_with(
|
||||
self,
|
||||
pubkey: &Key,
|
||||
builder: impl Fn(PeerConfigBuilder) -> PeerConfigBuilder,
|
||||
) -> Self {
|
||||
self.add_peer(builder(PeerConfigBuilder::new(pubkey)))
|
||||
}
|
||||
|
||||
/// Specifies multiple peer configurations to be added to the interface.
|
||||
pub fn add_peers(mut self, peers: &[PeerConfigBuilder]) -> Self {
|
||||
self.peers.extend_from_slice(peers);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that the peer configurations in this `DeviceConfigBuilder` should
|
||||
/// replace the existing configurations on the interface, not modify or append to them.
|
||||
pub fn replace_peers(mut self) -> Self {
|
||||
self.replace_peers = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that the peer with this public key should be removed from the interface.
|
||||
pub fn remove_peer_by_key(self, public_key: &Key) -> Self {
|
||||
let mut peer = PeerConfigBuilder::new(public_key);
|
||||
peer.remove_me = true;
|
||||
self.add_peer(peer)
|
||||
}
|
||||
|
||||
/// Build and apply the configuration to a WireGuard interface by name.
|
||||
///
|
||||
/// An interface with the provided name will be created if one does not exist already.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply(self, iface: &str) -> io::Result<()> {
|
||||
if backends::kernel::exists() {
|
||||
backends::kernel::apply(self, iface)
|
||||
} else {
|
||||
backends::userspace::apply(self, iface)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn apply(self, iface: &str) -> io::Result<()> {
|
||||
backends::userspace::apply(self, iface)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DeviceConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds and represents a single peer in a WireGuard interface configuration.
|
||||
///
|
||||
/// Note that if a peer with that public key already exists on the interface,
|
||||
/// the settings specified here will be applied _on top_ of the existing settings,
|
||||
/// similarly to interface-wide settings.
|
||||
///
|
||||
/// If this is not what you want, use [`DeviceConfigBuilder::replace_peers`](DeviceConfigBuilder::replace_peers)
|
||||
/// to replace all peer settings on the interface, or use
|
||||
/// [`DeviceConfigBuilder::remove_peer_by_key`](DeviceConfigBuilder::remove_peer_by_key) first
|
||||
/// to remove the peer from the interface, and then apply a second configuration to re-add it.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// # use wgctrl::*;
|
||||
/// # use std::net::AddrParseError;
|
||||
/// # fn try_main() -> Result<(), AddrParseError> {
|
||||
/// let peer_keypair = KeyPair::generate();
|
||||
///
|
||||
/// // create a new peer and allow it to connect from 192.168.1.2
|
||||
/// let peer = PeerConfigBuilder::new(&peer_keypair.public)
|
||||
/// .replace_allowed_ips()
|
||||
/// .add_allowed_ip("192.168.1.2".parse()?, 32);
|
||||
///
|
||||
/// // update our existing configuration with the new peer
|
||||
/// DeviceConfigBuilder::new().add_peer(peer).apply("wg-example");
|
||||
///
|
||||
/// println!("Send these keys to your peer: {:#?}", peer_keypair);
|
||||
///
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// # fn main() { try_main(); }
|
||||
/// ```
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct PeerConfigBuilder {
|
||||
pub(crate) public_key: Key,
|
||||
pub(crate) preshared_key: Option<Key>,
|
||||
pub(crate) endpoint: Option<SocketAddr>,
|
||||
pub(crate) persistent_keepalive_interval: Option<u16>,
|
||||
pub(crate) allowed_ips: Vec<AllowedIp>,
|
||||
pub(crate) replace_allowed_ips: bool,
|
||||
pub(crate) remove_me: bool,
|
||||
}
|
||||
|
||||
impl PeerConfigBuilder {
|
||||
/// Creates a new `PeerConfigBuilder` that does nothing when applied.
|
||||
pub fn new(public_key: &Key) -> Self {
|
||||
PeerConfigBuilder {
|
||||
public_key: public_key.clone(),
|
||||
preshared_key: None,
|
||||
endpoint: None,
|
||||
persistent_keepalive_interval: None,
|
||||
allowed_ips: vec![],
|
||||
replace_allowed_ips: false,
|
||||
remove_me: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_peer_config(self) -> PeerConfig {
|
||||
PeerConfig {
|
||||
public_key: self.public_key,
|
||||
preshared_key: self.preshared_key,
|
||||
endpoint: self.endpoint,
|
||||
persistent_keepalive_interval: self.persistent_keepalive_interval,
|
||||
allowed_ips: self.allowed_ips,
|
||||
__cant_construct_me: (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `PeerConfigBuilder` from a [`PeerConfig`](PeerConfig).
|
||||
///
|
||||
/// This is mostly a convenience method for cases when you want to copy
|
||||
/// some or most of the existing peer configuration to a new configuration.
|
||||
///
|
||||
/// This returns a `PeerConfigBuilder`, so you can still call any methods
|
||||
/// you need to override the imported settings.
|
||||
pub fn from_peer_config(config: PeerConfig) -> Self {
|
||||
let mut builder = Self::new(&config.public_key);
|
||||
if let Some(k) = config.preshared_key {
|
||||
builder = builder.set_preshared_key(k);
|
||||
}
|
||||
if let Some(e) = config.endpoint {
|
||||
builder = builder.set_endpoint(e);
|
||||
}
|
||||
if let Some(k) = config.persistent_keepalive_interval {
|
||||
builder = builder.set_persistent_keepalive_interval(k);
|
||||
}
|
||||
builder
|
||||
.replace_allowed_ips()
|
||||
.add_allowed_ips(&config.allowed_ips)
|
||||
}
|
||||
|
||||
/// Specifies a preshared key to be set for this peer.
|
||||
pub fn set_preshared_key(mut self, key: Key) -> Self {
|
||||
self.preshared_key = Some(key);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that this peer's preshared key should be unset.
|
||||
pub fn unset_preshared_key(self) -> Self {
|
||||
self.set_preshared_key(Key::zero())
|
||||
}
|
||||
|
||||
/// Specifies an exact endpoint that this peer should be allowed to connect from.
|
||||
pub fn set_endpoint(mut self, address: SocketAddr) -> Self {
|
||||
self.endpoint = Some(address);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies the interval between keepalive packets to be sent to this peer.
|
||||
pub fn set_persistent_keepalive_interval(mut self, interval: u16) -> Self {
|
||||
self.persistent_keepalive_interval = Some(interval);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies that this peer does not require keepalive packets.
|
||||
pub fn disable_persistent_keepalive(self) -> Self {
|
||||
self.set_persistent_keepalive_interval(0)
|
||||
}
|
||||
|
||||
/// Specifies an IP address this peer will be allowed to connect from/to.
|
||||
///
|
||||
/// See [`AllowedIp`](AllowedIp) for details. This method can be called
|
||||
/// more than once, and all IP addresses will be added to the configuration.
|
||||
pub fn add_allowed_ip(mut self, address: IpAddr, cidr: u8) -> Self {
|
||||
self.allowed_ips.push(AllowedIp { address, cidr });
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies multiple IP addresses this peer will be allowed to connect from/to.
|
||||
///
|
||||
/// See [`AllowedIp`](AllowedIp) for details. This method can be called
|
||||
/// more than once, and all IP addresses will be added to the configuration.
|
||||
pub fn add_allowed_ips(mut self, ips: &[AllowedIp]) -> Self {
|
||||
self.allowed_ips.extend_from_slice(ips);
|
||||
self
|
||||
}
|
||||
|
||||
/// Specifies this peer should be allowed to connect to all IP addresses.
|
||||
///
|
||||
/// This is a convenience method for cases when you want to connect to a server
|
||||
/// that all traffic should be routed through.
|
||||
pub fn allow_all_ips(self) -> Self {
|
||||
self.add_allowed_ip(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0)
|
||||
.add_allowed_ip(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)), 0)
|
||||
}
|
||||
|
||||
/// Specifies that the allowed IP addresses in this configuration should replace
|
||||
/// the existing configuration of the interface, not be appended to it.
|
||||
pub fn replace_allowed_ips(mut self) -> Self {
|
||||
self.replace_allowed_ips = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Mark peer for removal from interface.
|
||||
pub fn remove(mut self) -> Self {
|
||||
self.remove_me = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes an existing WireGuard interface by name.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn delete_interface(iface: &str) -> io::Result<()> {
|
||||
backends::kernel::delete_interface(iface)
|
||||
}
|
182
wgctrl-rs/src/device.rs
Normal file
182
wgctrl-rs/src/device.rs
Normal file
@ -0,0 +1,182 @@
|
||||
use crate::{backends, key::Key};
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
/// Represents an IP address a peer is allowed to have, in CIDR notation.
|
||||
///
|
||||
/// This may have unexpected semantics - refer to the
|
||||
/// [WireGuard documentation](https://www.wireguard.com/#cryptokey-routing)
|
||||
/// for more information on how routing is implemented.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct AllowedIp {
|
||||
/// The IP address.
|
||||
pub address: IpAddr,
|
||||
/// The CIDR subnet mask.
|
||||
pub cidr: u8,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for AllowedIp {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let parts: Vec<_> = s.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(AllowedIp {
|
||||
address: parts[0].parse().map_err(|_| ())?,
|
||||
cidr: parts[1].parse().map_err(|_| ())?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a single peer's configuration (i.e. persistent attributes).
|
||||
///
|
||||
/// These are the attributes that don't change over time and are part of the configuration.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct PeerConfig {
|
||||
/// The public key of the peer.
|
||||
pub public_key: Key,
|
||||
/// The preshared key available to both peers (`None` means no PSK is used).
|
||||
pub preshared_key: Option<Key>,
|
||||
/// The endpoint this peer listens for connections on (`None` means any).
|
||||
pub endpoint: Option<SocketAddr>,
|
||||
/// The interval for sending keepalive packets (`None` means disabled).
|
||||
pub persistent_keepalive_interval: Option<u16>,
|
||||
/// The IP addresses this peer is allowed to have.
|
||||
pub allowed_ips: Vec<AllowedIp>,
|
||||
pub(crate) __cant_construct_me: (),
|
||||
}
|
||||
|
||||
/// Represents a single peer's current statistics (i.e. the data from the current session).
|
||||
///
|
||||
/// These are the attributes that will change over time; to update them,
|
||||
/// re-read the information from the interface.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct PeerStats {
|
||||
/// Time of the last handshake/rekey with this peer.
|
||||
pub last_handshake_time: Option<SystemTime>,
|
||||
/// Number of bytes received from this peer.
|
||||
pub rx_bytes: u64,
|
||||
/// Number of bytes transmitted to this peer.
|
||||
pub tx_bytes: u64,
|
||||
pub(crate) __cant_construct_me: (),
|
||||
}
|
||||
|
||||
/// Represents the complete status of a peer.
|
||||
///
|
||||
/// This struct simply combines [`PeerInfo`](PeerInfo) and [`PeerStats`](PeerStats)
|
||||
/// to represent all available information about a peer.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct PeerInfo {
|
||||
pub config: PeerConfig,
|
||||
pub stats: PeerStats,
|
||||
}
|
||||
|
||||
/// Represents all available information about a WireGuard device (interface).
|
||||
///
|
||||
/// This struct contains the current configuration of the device
|
||||
/// and the current configuration _and_ state of all of its peers.
|
||||
/// The peer statistics are retrieved once at construction time,
|
||||
/// and need to be updated manually by calling [`get_by_name`](DeviceInfo::get_by_name).
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct DeviceInfo {
|
||||
/// The interface name of this device
|
||||
pub name: String,
|
||||
/// The public encryption key of this interface (if present)
|
||||
pub public_key: Option<Key>,
|
||||
/// The private encryption key of this interface (if present)
|
||||
pub private_key: Option<Key>,
|
||||
/// The [fwmark](https://www.linux.org/docs/man8/tc-fw.html) of this interface
|
||||
pub fwmark: Option<u32>,
|
||||
/// The port to listen for incoming connections on
|
||||
pub listen_port: Option<u16>,
|
||||
/// The list of all registered peers and their information
|
||||
pub peers: Vec<PeerInfo>,
|
||||
/// The associated "real name" of the interface (ex. "utun8" on macOS).
|
||||
pub linked_name: Option<String>,
|
||||
|
||||
pub(crate) __cant_construct_me: (),
|
||||
}
|
||||
|
||||
impl DeviceInfo {
|
||||
/// Enumerates all WireGuard interfaces currently present in the system
|
||||
/// and returns their names.
|
||||
///
|
||||
/// You can use [`get_by_name`](DeviceInfo::get_by_name) to retrieve more
|
||||
/// detailed information on each interface.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn enumerate() -> Result<Vec<String>, std::io::Error> {
|
||||
if backends::kernel::exists() {
|
||||
backends::kernel::enumerate()
|
||||
} else {
|
||||
backends::userspace::enumerate()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn enumerate() -> Result<Vec<String>, std::io::Error> {
|
||||
crate::backends::userspace::enumerate()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn get_by_name(name: &str) -> Result<Self, std::io::Error> {
|
||||
if backends::kernel::exists() {
|
||||
backends::kernel::get_by_name(name)
|
||||
} else {
|
||||
backends::userspace::get_by_name(name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn get_by_name(name: &str) -> Result<Self, std::io::Error> {
|
||||
backends::userspace::get_by_name(name)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn delete(self) -> Result<(), std::io::Error> {
|
||||
backends::kernel::delete_interface(&self.name)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn delete(self) -> Result<(), std::io::Error> {
|
||||
backends::userspace::delete_interface(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{DeviceConfigBuilder, KeyPair, PeerConfigBuilder};
|
||||
|
||||
const TEST_INTERFACE: &str = "wgctrl-test";
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_add_peers() {
|
||||
if unsafe { libc::getuid() } != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let keypairs: Vec<_> = (0..10).map(|_| KeyPair::generate()).collect();
|
||||
let mut builder = DeviceConfigBuilder::new();
|
||||
for keypair in &keypairs {
|
||||
builder = builder.add_peer(PeerConfigBuilder::new(&keypair.public))
|
||||
}
|
||||
builder.apply(TEST_INTERFACE).unwrap();
|
||||
|
||||
let device = DeviceInfo::get_by_name(TEST_INTERFACE).unwrap();
|
||||
|
||||
for keypair in &keypairs {
|
||||
assert!(device
|
||||
.peers
|
||||
.iter()
|
||||
.any(|p| p.config.public_key == keypair.public));
|
||||
}
|
||||
|
||||
device.delete().unwrap();
|
||||
}
|
||||
}
|
124
wgctrl-rs/src/key.rs
Normal file
124
wgctrl-rs/src/key.rs
Normal file
@ -0,0 +1,124 @@
|
||||
use crate::backends;
|
||||
use std::{ffi::NulError, fmt};
|
||||
|
||||
/// Represents an error in base64 key parsing.
|
||||
#[derive(Eq, PartialEq, Debug, Clone)]
|
||||
pub struct InvalidKey;
|
||||
|
||||
impl std::error::Error for InvalidKey {}
|
||||
|
||||
impl fmt::Display for InvalidKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "Invalid key format")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NulError> for InvalidKey {
|
||||
fn from(_: NulError) -> Self {
|
||||
InvalidKey {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a WireGuard encryption key.
|
||||
///
|
||||
/// WireGuard makes no meaningful distinction between public,
|
||||
/// private and preshared keys - any sequence of 32 bytes
|
||||
/// can be used as either of those.
|
||||
///
|
||||
/// This means that you need to be careful when working with
|
||||
/// `Key`s, especially ones created from external data.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub use backends::userspace::Key;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use backends::kernel::Key;
|
||||
|
||||
/// Represents a pair of private and public keys.
|
||||
///
|
||||
/// This struct is here for convenience of generating
|
||||
/// a complete keypair, e.g. for a new peer.
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct KeyPair {
|
||||
/// The private key.
|
||||
pub private: Key,
|
||||
/// The public key.
|
||||
pub public: Key,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "Key(\"{}\")", self.to_base64())
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyPair {
|
||||
pub fn generate() -> Self {
|
||||
let private = Key::generate_private();
|
||||
let public = private.generate_public();
|
||||
KeyPair { private, public }
|
||||
}
|
||||
|
||||
pub fn from_private(key: Key) -> Self {
|
||||
let public = key.generate_public();
|
||||
KeyPair {
|
||||
private: key,
|
||||
public,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_key_zero() {
|
||||
use crate::key::Key;
|
||||
|
||||
let key = Key::zero();
|
||||
assert!(key.is_zero());
|
||||
|
||||
let key = Key::generate_preshared();
|
||||
assert!(!key.is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_base64() {
|
||||
use crate::key::Key;
|
||||
|
||||
let key = Key::generate_preshared();
|
||||
let key_b64 = key.to_base64();
|
||||
let key_new = Key::from_base64(&key_b64).unwrap();
|
||||
|
||||
assert_eq!(key, key_new);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_key() {
|
||||
use crate::key::{InvalidKey, Key};
|
||||
|
||||
let key_b64: String = Key::generate_preshared()
|
||||
.to_base64()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect();
|
||||
|
||||
assert_eq!(Key::from_base64(&key_b64), Err(InvalidKey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_keypair_basic() {
|
||||
use crate::key::Key;
|
||||
|
||||
let privkey = Key::generate_private();
|
||||
let pubkey = privkey.generate_public();
|
||||
|
||||
assert_ne!(privkey, pubkey);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_keypair_helper() {
|
||||
use crate::key::KeyPair;
|
||||
let pair = KeyPair::generate();
|
||||
|
||||
assert_ne!(pair.private, pair.public);
|
||||
}
|
||||
}
|
6
wgctrl-rs/src/lib.rs
Normal file
6
wgctrl-rs/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod backends;
|
||||
mod config;
|
||||
mod device;
|
||||
mod key;
|
||||
|
||||
pub use crate::{config::*, device::*, key::*};
|
3
wgctrl-sys/.gitignore
vendored
Normal file
3
wgctrl-sys/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
17
wgctrl-sys/Cargo.toml
Normal file
17
wgctrl-sys/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
authors = ["K900 <me@0upti.me>", "Jake McGinty <jake@tonari.no>"]
|
||||
categories = ["external-ffi-bindings", "os::unix-apis"]
|
||||
description = "Raw bindings to the WireGuard embeddable C library"
|
||||
license = "LGPL-2.1-or-later"
|
||||
name = "wgctrl-sys"
|
||||
publish = false
|
||||
readme = "../README.md"
|
||||
repository = "https://gitlab.com/K900/wgctrl-rs"
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
bindgen = { version = "0.57", default-features = false }
|
||||
cc = "1.0"
|
41
wgctrl-sys/build.rs
Normal file
41
wgctrl-sys/build.rs
Normal file
@ -0,0 +1,41 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
pub fn build_bindings() {
|
||||
let bindings = bindgen::Builder::default()
|
||||
.rust_target(bindgen::RustTarget::Stable_1_40)
|
||||
.derive_default(true)
|
||||
.header("c/wireguard.h")
|
||||
.impl_debug(true)
|
||||
.whitelist_function("wg_.*")
|
||||
.bitfield_enum("wg_peer_flags")
|
||||
.bitfield_enum("wg_device_flags");
|
||||
|
||||
let bindings = bindings.generate().expect("Unable to generate bindings");
|
||||
|
||||
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
bindings
|
||||
.write_to_file(out_path.join("bindings.rs"))
|
||||
.expect("Couldn't write bindings!");
|
||||
}
|
||||
|
||||
pub fn build_library() {
|
||||
cc::Build::new()
|
||||
.file("c/wireguard.c")
|
||||
.warnings(true)
|
||||
.extra_warnings(true)
|
||||
.warnings_into_errors(true)
|
||||
.flag_if_supported("-Wno-unused-parameter")
|
||||
.compile("wireguard");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
linux::build_bindings();
|
||||
linux::build_library();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {}
|
1755
wgctrl-sys/c/wireguard.c
Normal file
1755
wgctrl-sys/c/wireguard.c
Normal file
File diff suppressed because it is too large
Load Diff
103
wgctrl-sys/c/wireguard.h
Normal file
103
wgctrl-sys/c/wireguard.h
Normal file
@ -0,0 +1,103 @@
|
||||
/* SPDX-License-Identifier: LGPL-2.1+ */
|
||||
/*
|
||||
* Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef WIREGUARD_H
|
||||
#define WIREGUARD_H
|
||||
|
||||
#include <net/if.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <time.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef uint8_t wg_key[32];
|
||||
typedef char wg_key_b64_string[((sizeof(wg_key) + 2) / 3) * 4 + 1];
|
||||
|
||||
/* Cross platform __kernel_timespec */
|
||||
struct timespec64 {
|
||||
int64_t tv_sec;
|
||||
int64_t tv_nsec;
|
||||
};
|
||||
|
||||
typedef struct wg_allowedip {
|
||||
uint16_t family;
|
||||
union {
|
||||
struct in_addr ip4;
|
||||
struct in6_addr ip6;
|
||||
};
|
||||
uint8_t cidr;
|
||||
struct wg_allowedip *next_allowedip;
|
||||
} wg_allowedip;
|
||||
|
||||
enum wg_peer_flags {
|
||||
WGPEER_REMOVE_ME = 1U << 0,
|
||||
WGPEER_REPLACE_ALLOWEDIPS = 1U << 1,
|
||||
WGPEER_HAS_PUBLIC_KEY = 1U << 2,
|
||||
WGPEER_HAS_PRESHARED_KEY = 1U << 3,
|
||||
WGPEER_HAS_PERSISTENT_KEEPALIVE_INTERVAL = 1U << 4
|
||||
};
|
||||
|
||||
typedef struct wg_peer {
|
||||
enum wg_peer_flags flags;
|
||||
|
||||
wg_key public_key;
|
||||
wg_key preshared_key;
|
||||
|
||||
union {
|
||||
struct sockaddr addr;
|
||||
struct sockaddr_in addr4;
|
||||
struct sockaddr_in6 addr6;
|
||||
} endpoint;
|
||||
|
||||
struct timespec64 last_handshake_time;
|
||||
uint64_t rx_bytes, tx_bytes;
|
||||
uint16_t persistent_keepalive_interval;
|
||||
|
||||
struct wg_allowedip *first_allowedip, *last_allowedip;
|
||||
struct wg_peer *next_peer;
|
||||
} wg_peer;
|
||||
|
||||
enum wg_device_flags {
|
||||
WGDEVICE_REPLACE_PEERS = 1U << 0,
|
||||
WGDEVICE_HAS_PRIVATE_KEY = 1U << 1,
|
||||
WGDEVICE_HAS_PUBLIC_KEY = 1U << 2,
|
||||
WGDEVICE_HAS_LISTEN_PORT = 1U << 3,
|
||||
WGDEVICE_HAS_FWMARK = 1U << 4
|
||||
};
|
||||
|
||||
typedef struct wg_device {
|
||||
char name[IFNAMSIZ];
|
||||
uint32_t ifindex;
|
||||
|
||||
enum wg_device_flags flags;
|
||||
|
||||
wg_key public_key;
|
||||
wg_key private_key;
|
||||
|
||||
uint32_t fwmark;
|
||||
uint16_t listen_port;
|
||||
|
||||
struct wg_peer *first_peer, *last_peer;
|
||||
} wg_device;
|
||||
|
||||
#define wg_for_each_device_name(__names, __name, __len) for ((__name) = (__names), (__len) = 0; ((__len) = strlen(__name)); (__name) += (__len) + 1)
|
||||
#define wg_for_each_peer(__dev, __peer) for ((__peer) = (__dev)->first_peer; (__peer); (__peer) = (__peer)->next_peer)
|
||||
#define wg_for_each_allowedip(__peer, __allowedip) for ((__allowedip) = (__peer)->first_allowedip; (__allowedip); (__allowedip) = (__allowedip)->next_allowedip)
|
||||
|
||||
int wg_set_device(wg_device *dev);
|
||||
int wg_get_device(wg_device **dev, const char *device_name);
|
||||
int wg_add_device(const char *device_name);
|
||||
int wg_del_device(const char *device_name);
|
||||
void wg_free_device(wg_device *dev);
|
||||
char *wg_list_device_names(void); /* first\0second\0third\0forth\0last\0\0 */
|
||||
void wg_key_to_base64(wg_key_b64_string base64, const wg_key key);
|
||||
int wg_key_from_base64(wg_key key, const wg_key_b64_string base64);
|
||||
bool wg_key_is_zero(const wg_key key);
|
||||
void wg_generate_public_key(wg_key public_key, const wg_key private_key);
|
||||
void wg_generate_private_key(wg_key private_key);
|
||||
void wg_generate_preshared_key(wg_key preshared_key);
|
||||
|
||||
#endif
|
6
wgctrl-sys/src/lib.rs
Normal file
6
wgctrl-sys/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
#![allow(non_upper_case_globals)]
|
||||
#![allow(non_camel_case_types)]
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
|
Loading…
Reference in New Issue
Block a user