Compare commits
148 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
18f5a3338e | |
|
|
0c4327dd71 | |
|
|
28bb29df4a | |
|
|
d079057ecf | |
|
|
237aec1df5 | |
|
|
cdc2a508aa | |
|
|
54b419fd18 | |
|
|
c709387d71 | |
|
|
08200b7a09 | |
|
|
5d6b4b9d0c | |
|
|
2d351db2df | |
|
|
4461df5b7b | |
|
|
f4a444fb7d | |
|
|
80f6813049 | |
|
|
14a3ee2ebc | |
|
|
943bd1eb85 | |
|
|
df84a30016 | |
|
|
bbff9996ab | |
|
|
dd55a20de0 | |
|
|
012a93834a | |
|
|
d806af1f9d | |
|
|
d992da7023 | |
|
|
2e74102ee4 | |
|
|
35ae0608db | |
|
|
54f40f914b | |
|
|
088011e714 | |
|
|
3cd8732f1c | |
|
|
ca15d50f8d | |
|
|
928dc3c333 | |
|
|
62d9326d26 | |
|
|
173f7832b3 | |
|
|
be2c706b00 | |
|
|
b1f5678f18 | |
|
|
c1ddb2afc6 | |
|
|
401dea6612 | |
|
|
1dcb0755df | |
|
|
1706062004 | |
|
|
dfc04a49e4 | |
|
|
7dc5992b55 | |
|
|
58d4b32258 | |
|
|
431ae9bbc9 | |
|
|
98988e5c73 | |
|
|
b122b49b3c | |
|
|
2650e78eb0 | |
|
|
0c59910018 | |
|
|
7e622716f7 | |
|
|
80baf28ead | |
|
|
7c75e2294e | |
|
|
78e373d992 | |
|
|
e42ef5150a | |
|
|
580d84f0ca | |
|
|
a7da95bd5b | |
|
|
1c52f32688 | |
|
|
a8d5ff65d6 | |
|
|
b1459e9188 | |
|
|
80745a6720 | |
|
|
0117fcbdb2 | |
|
|
31a1296e64 | |
|
|
d4f66bfb5c | |
|
|
b54679d6e4 | |
|
|
560e909b28 | |
|
|
e6c9f98162 | |
|
|
ddc699e076 | |
|
|
4fa4fb1bda | |
|
|
11213d0f46 | |
|
|
ba114b3c86 | |
|
|
e37f52971d | |
|
|
7ba7849a80 | |
|
|
6d0702f884 | |
|
|
54e02845ac | |
|
|
50044e9cef | |
|
|
36a7e994c0 | |
|
|
4e70021b2b | |
|
|
37f3c2f8ea | |
|
|
c41a37a40f | |
|
|
570f02e8d5 | |
|
|
179ef6c282 | |
|
|
8692eb9de6 | |
|
|
86cc71e711 | |
|
|
4e648fade1 | |
|
|
47faa8f577 | |
|
|
224ae136f6 | |
|
|
6253053799 | |
|
|
3b8ddff4d3 | |
|
|
0eac5c49a9 | |
|
|
2cfe616b1e | |
|
|
06c1b809b6 | |
|
|
4ef98589c4 | |
|
|
82cdc24556 | |
|
|
267466d572 | |
|
|
c05bd23f4a | |
|
|
3b83823a09 | |
|
|
cf24be0992 | |
|
|
14fe582a4d | |
|
|
539cff564d | |
|
|
8519fa2f86 | |
|
|
f3acef87a7 | |
|
|
a35e5ae92a | |
|
|
1f5aeb29f3 | |
|
|
9a62801cd6 | |
|
|
694f2e138a | |
|
|
c2ff5761cb | |
|
|
762868a8eb | |
|
|
95a3358f59 | |
|
|
ebc7cf8973 | |
|
|
86f082ac77 | |
|
|
b3df49889a | |
|
|
82be9577b3 | |
|
|
04c6019437 | |
|
|
7374ceb239 | |
|
|
cc7cc0e740 | |
|
|
e53beeeb79 | |
|
|
05cba5d771 | |
|
|
cf97e3878d | |
|
|
7f5293dd5e | |
|
|
9a2e59b844 | |
|
|
603c8b4889 | |
|
|
24641d5325 | |
|
|
ed64c139cf | |
|
|
8359992a08 | |
|
|
147c051b0e | |
|
|
6456ed7228 | |
|
|
56808c591f | |
|
|
eba21ed33e | |
|
|
867679446e | |
|
|
4a13c5922b | |
|
|
a92f4e0c99 | |
|
|
0a92c290be | |
|
|
f5a1b1cdbd | |
|
|
817dbb6e32 | |
|
|
5f3b371e93 | |
|
|
cd5f1d2f4f | |
|
|
a775a92772 | |
|
|
f38d65f98f | |
|
|
4ba480ff4f | |
|
|
d7699e95a9 | |
|
|
535e03c97f | |
|
|
d635751948 | |
|
|
e71b32a8dd | |
|
|
59cd9098e0 | |
|
|
4ab3cd7e5c | |
|
|
cd78047d79 | |
|
|
a1108b1b7f | |
|
|
11910d8540 | |
|
|
22c92688e5 | |
|
|
e2403f0ea9 | |
|
|
717e6b0f89 | |
|
|
71465f8bff |
|
|
@ -0,0 +1,2 @@
|
|||
[target.aarch64-apple-darwin]
|
||||
runner = 'sudo -E'
|
||||
27
.cirrus.yml
27
.cirrus.yml
|
|
@ -1,27 +0,0 @@
|
|||
env:
|
||||
PATH: "$PATH:$HOME/.cargo/bin"
|
||||
|
||||
task:
|
||||
name: Build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
||||
install_rust_script:
|
||||
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
build_script:
|
||||
- cargo build
|
||||
|
||||
task:
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
||||
env:
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
install_rust_script:
|
||||
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
install_goreleaser_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
build_script:
|
||||
- cargo build --release
|
||||
release_script: goreleaser
|
||||
|
|
@ -0,0 +1 @@
|
|||
* @edigaryev @fkorotkov
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
all-updates:
|
||||
patterns:
|
||||
- "*"
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Install lint components
|
||||
run: rustup component add clippy rustfmt
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
- name: Run Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test on macOS 26
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Run tests
|
||||
run: cargo test
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||
runs-on: macos-26
|
||||
environment: publish
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Install release targets
|
||||
run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
- name: Create release app token for this repo
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
- name: Create release app token for homebrew-tools
|
||||
id: tap-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
owner: openai
|
||||
repositories: homebrew-tools
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
- name: Release
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
|
||||
snapshot:
|
||||
name: Release (Dry Run)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Install release targets
|
||||
run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
- name: Release dry run
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v2"
|
||||
args: release --skip=publish --snapshot --clean
|
||||
- name: Upload dry-run artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: softnet-snapshot
|
||||
path: dist/**
|
||||
|
|
@ -1 +1,3 @@
|
|||
/.idea
|
||||
/dist
|
||||
/target
|
||||
|
|
|
|||
|
|
@ -1,32 +1,40 @@
|
|||
---
|
||||
version: 2
|
||||
project_name: softnet
|
||||
|
||||
builds:
|
||||
- builder: prebuilt
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
prebuilt:
|
||||
path: target/release/softnet
|
||||
- builder: rust
|
||||
command: build
|
||||
targets:
|
||||
- aarch64-apple-darwin
|
||||
- x86_64-apple-darwin
|
||||
|
||||
universal_binaries:
|
||||
- replace: true
|
||||
|
||||
archives:
|
||||
- id: binary
|
||||
format: binary
|
||||
name_template: "{{ .ProjectName }}"
|
||||
- id: regular
|
||||
name_template: "{{ .ProjectName }}"
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
formats:
|
||||
- tar.gz
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
|
||||
brews:
|
||||
- name: softnet
|
||||
ids:
|
||||
- regular
|
||||
tap:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
- name: "{{ .ProjectName }}"
|
||||
repository:
|
||||
owner: openai
|
||||
name: homebrew-tools
|
||||
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
branch: "softnet-{{ .Version }}"
|
||||
pull_request:
|
||||
enabled: true
|
||||
directory: Formula
|
||||
caveats: See the Github repository for more information
|
||||
homepage: https://github.com/cirruslabs/softnet
|
||||
homepage: https://github.com/openai/softnet
|
||||
description: Software networking with isolation for Tart
|
||||
skip_upload: auto
|
||||
custom_block: |
|
||||
on_macos do
|
||||
depends_on :macos => :sequoia
|
||||
end
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
47
Cargo.toml
47
Cargo.toml
|
|
@ -2,22 +2,41 @@
|
|||
name = "softnet"
|
||||
version = "0.1.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
path = "lib/mod.rs"
|
||||
|
||||
[profile.release-with-debug]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
|
||||
[dependencies]
|
||||
smoltcp = "0.8.1"
|
||||
libc = "0.2.126"
|
||||
polling = "2.2.0"
|
||||
dhcproto = "0.7.0"
|
||||
vmnet = "0.1.1"
|
||||
clap = { version = "3.1.18", features = ["derive"] }
|
||||
mac_address = "1.1.3"
|
||||
privdrop = "0.5.2"
|
||||
thiserror = "1.0.31"
|
||||
ip_network = "0.4.1"
|
||||
users = "0.11.0"
|
||||
system-configuration = "0.5.0"
|
||||
num_enum = "0.5.7"
|
||||
smoltcp = "0"
|
||||
libc = "0"
|
||||
polling = "3"
|
||||
dhcproto = { git = "https://github.com/bluecatengineering/dhcproto.git", branch = "master" }
|
||||
vmnet = "0.5.1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
mac_address = "1"
|
||||
privdrop = "0"
|
||||
anyhow = { version = "1", features = ["backtrace"] }
|
||||
ip_network = "0"
|
||||
uzers = "0"
|
||||
system-configuration = "0"
|
||||
num_enum = "0"
|
||||
sentry = { version = "0", features = ["debug-images"] }
|
||||
sentry-anyhow = { version = "0", features = ["backtrace"] }
|
||||
nix = { version = "0", features = ["signal", "socket"] }
|
||||
prefix-trie = "0"
|
||||
ipnet = "2"
|
||||
oslog = "0.2.0"
|
||||
log = "0.4.29"
|
||||
serial_test = "4"
|
||||
coarsetime = "0.1.37"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
jsonrpsee-types = "0.26"
|
||||
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
|
|
|||
766
LICENSE
766
LICENSE
|
|
@ -1,661 +1,105 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
# Functional Source License, Version 1.1, ALv2 Future License
|
||||
|
||||
## Abbreviation
|
||||
|
||||
FSL-1.1-ALv2
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2022-2026 OpenAI
|
||||
|
||||
## Terms and Conditions
|
||||
|
||||
### Licensor ("We")
|
||||
|
||||
The party offering the Software under these Terms and Conditions.
|
||||
|
||||
### The Software
|
||||
|
||||
The "Software" is each version of the software that we make available under
|
||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
||||
Conditions with the Software.
|
||||
|
||||
### License Grant
|
||||
|
||||
Subject to your compliance with this License Grant and the Patents,
|
||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
||||
and redistribute the Software for any Permitted Purpose identified below.
|
||||
|
||||
### Permitted Purpose
|
||||
|
||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
||||
means making the Software available to others in a commercial product or
|
||||
service that:
|
||||
|
||||
1. substitutes for the Software;
|
||||
|
||||
2. substitutes for any other product or service we offer using the Software
|
||||
that exists as of the date we make the Software available; or
|
||||
|
||||
3. offers the same or substantially similar functionality as the Software.
|
||||
|
||||
Permitted Purposes specifically include using the Software:
|
||||
|
||||
1. for your internal use and access;
|
||||
|
||||
2. for non-commercial education;
|
||||
|
||||
3. for non-commercial research; and
|
||||
|
||||
4. in connection with professional services that you provide to a licensee
|
||||
using the Software in accordance with these Terms and Conditions.
|
||||
|
||||
### Patents
|
||||
|
||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
||||
patents, the license grant above includes a license under our patents. If you
|
||||
make a claim against any party that the Software infringes or contributes to
|
||||
the infringement of any patent, then your patent license to the Software ends
|
||||
immediately.
|
||||
|
||||
### Redistribution
|
||||
|
||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
||||
the Software.
|
||||
|
||||
If you redistribute any copies, modifications or derivatives of the Software,
|
||||
you must include a copy of or a link to these Terms and Conditions and not
|
||||
remove any copyright notices provided in or with the Software.
|
||||
|
||||
### Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
||||
|
||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
||||
|
||||
### Trademarks
|
||||
|
||||
Except for displaying the License Details and identifying us as the origin of
|
||||
the Software, you have no right under these Terms and Conditions to use our
|
||||
trademarks, trade names, service marks or product names.
|
||||
|
||||
## Grant of Future License
|
||||
|
||||
We hereby irrevocably grant you an additional license to use the Software under
|
||||
the Apache License, Version 2.0 that is effective on the second anniversary of
|
||||
the date we make the Software available. On or after that date, you may use the
|
||||
Software under the Apache License, Version 2.0, in which case the following
|
||||
will apply:
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
this file except in compliance with the License.
|
||||
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -2,14 +2,26 @@
|
|||
|
||||
Softnet is a software networking for [Tart](https://github.com/cirruslabs/tart) which provides better network isolation and alleviates DHCP shortage on production systems.
|
||||
|
||||
It is essentially a userspace packet filter which restricts the VM networking and prevents a class of security issues, such as ARP spoofing. By default, the VM will only be able to:
|
||||
|
||||
* send traffic from its own MAC-address
|
||||
* send traffic from the IP-address assigned to it by the DHCP
|
||||
* send traffic to globally routable IPv4 addresses
|
||||
* send traffic to gateway IP of the vmnet bridge (this would normally be \"bridge100\" interface)
|
||||
* receive any incoming traffic
|
||||
|
||||
In addition, Softnet tunes macOS built-in DHCP server to decrease its lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes). This is especially important when you use Tart to clone and run a lot of ephemeral VMs over a period of one day.
|
||||
|
||||
Please check out [this blog post](https://cirrus-ci.org/blog/2022/07/07/isolating-network-between-tarts-macos-virtual-machines/) for backstory.
|
||||
|
||||
## Working model
|
||||
|
||||
Softnet solves two problems:
|
||||
|
||||
1. VM network isolation
|
||||
* [`VZNATNetworkDeviceAttachment`](https://developer.apple.com/documentation/virtualization/vznatnetworkdeviceattachment) (the default networking in Tart) enables [vmnet's bridge isolation](https://developer.apple.com/documentation/vmnet/vmnet_enable_isolation_key) by default and prevents cross-VM traffic, however it's still possible for any VM to spoof the host's ARP-table and capture other VMs traffic, for example
|
||||
* [`VZNATNetworkDeviceAttachment`](https://developer.apple.com/documentation/virtualization/vznatnetworkdeviceattachment) (the default networking in Tart) enables [vmnet's bridge isolation](https://developer.apple.com/documentation/vmnet/vmnet_enable_isolation_key) by default and prevents cross-VM traffic, however it's still possible for any VM to spoof the host's ARP-table and capture other VMs traffic by using tools that enable conducting the [ARP spoofing attacks](https://en.wikipedia.org/wiki/ARP_spoofing) (e.g. [arpspoof](https://www.monkey.org/~dugsong/dsniff/), [arpoison](http://www.arpoison.net/) and so on)
|
||||
2. DHCP exhaustion
|
||||
* macOS built-in DHCP-server allocates a `/24` subnet with 86400 seconds lease time by default, which only allows for ~253 VMs a day (or 1 VM every ~6 minutes) to be spawned without causing a denial-of-service, which is pretty limiting for CI services like Cirrus CI
|
||||
* macOS built-in DHCP-server allocates a `/24` subnet with 86400 seconds lease time by default, which only allows for ~253 VMs a day (or 1 VM every ~6 minutes) to be spawned without causing a denial-of-service, which is pretty limiting for CI services like Cirrus CI
|
||||
|
||||
And assumes that:
|
||||
|
||||
|
|
@ -18,16 +30,60 @@ And assumes that:
|
|||
|
||||
...otherwise it's possible for two VMs to receive an identical IP-address from the macOS built-in DHCP-server (even in the presence of Softnet's packet filtering) and thus bypass the protections offered by Softnet.
|
||||
|
||||
### Stateful flow authorization
|
||||
|
||||
Stateful `in`/`out` rules use a bounded authorization cache that records the
|
||||
direction and exact transport tuple of policy-approved flows, allowing matching
|
||||
return traffic without treating it as a new flow and thus requiring a separate
|
||||
policy entry. It is not a complete TCP connection tracker: endpoint transport
|
||||
stacks remain responsible for validating sequence numbers, receive windows,
|
||||
resets, and application-level traffic.
|
||||
|
||||
This cache deliberately favors security, bounded resource use, and a simple
|
||||
implementation over availability. Only policy-authorized initiator traffic
|
||||
renews an entry; return traffic does not. Softnet does not maintain fairness
|
||||
quotas, eviction heuristics, or complete TCP lifecycle state.
|
||||
|
||||
A packet admitted by a stateful rule is denied when Softnet cannot represent its
|
||||
flow, including when the cache is full. High flow churn, long idle connections,
|
||||
or ambiguous retransmissions may therefore interrupt networking and require the
|
||||
affected VM to reconnect.
|
||||
|
||||
For TCP, a bare TCP SYN on an existing tuple is deliberately returned to policy
|
||||
because Softnet cannot distinguish a retransmission from tuple reuse without
|
||||
tracking TCP sequence state. If authorized, it may replace the tuple's previous
|
||||
cache lifetime; this can reduce availability but cannot grant traffic that
|
||||
policy did not permit.
|
||||
|
||||
For ICMP, stateful flow authorization supports only echo requests and replies.
|
||||
|
||||
## Installing
|
||||
|
||||
For proper functioning Softnet binary requires two things:
|
||||
For proper functioning, Softnet binary requires two things:
|
||||
|
||||
* a [SUID-bit](https://en.wikipedia.org/wiki/Setuid#SUID) is set on the binary or [passwordless sudo](https://serverfault.com/questions/160581/how-to-setup-passwordless-sudo-on-linux) is enabled, which effectively gives the binary `root` privileges
|
||||
* this is needed in order to create [`vmnet.framework`](https://developer.apple.com/documentation/vmnet) interface and perform DHCP-related tweaks
|
||||
* a [SUID-bit](https://en.wikipedia.org/wiki/Setuid#SUID) to be set on the binary or a [passwordless sudo](https://serverfault.com/questions/160581/how-to-setup-passwordless-sudo-on-linux) to be configured, which effectively gives the binary `root` privileges
|
||||
* these privileges are needed to create [`vmnet.framework`](https://developer.apple.com/documentation/vmnet) interface and perform DHCP-related system tweaks
|
||||
* the privileges will be dropped automatically to that of the calling user (or those represented by the `--user` and `--group` command-line arguments) once all of the initialization is completed
|
||||
* the binary is available in `PATH`
|
||||
* the binary to be available in `PATH`
|
||||
* so that the Tart will be able to find it
|
||||
|
||||
## Running
|
||||
|
||||
Softnet is started and managed automatically by Tart if `--with-softnet` flag is present when calling `tart run`.
|
||||
Softnet is started and managed automatically by Tart if `--net-softnet` flag is provided when calling `tart run`.
|
||||
|
||||
### Dynamic network policy
|
||||
|
||||
Softnet can update the running VM's IPv4 policy without restarting the VM. Pass a connected Unix stream socket as `--control-fd` to enable a newline-delimited [JSON-RPC 2.0](https://www.jsonrpc.org/specification) control channel. The socket is duplex and must be separate from `--vm-fd`, which carries VM packets.
|
||||
|
||||
The supported methods are `softnet.policy.get` and `softnet.policy.set`. A complete policy update looks like this (each request and response occupies one line):
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":"42","method":"softnet.policy.set","params":{"allow":["@host","10.0.0.0/8"],"block":["0.0.0.0/0"]}}
|
||||
{"jsonrpc":"2.0","id":"42","result":{"allow":["10.0.0.0/8","@host"],"block":["0.0.0.0/0"],"ruleCount":3}}
|
||||
```
|
||||
|
||||
Every request must include a non-null string (at most 256 bytes) or non-negative integer `id`; notifications are rejected so policy changes always have an acknowledgment. Policy updates are atomic: all rules are parsed and a new prefix map is built before the active policy changes. Longest-prefix matching and block precedence for identical rules are preserved. Rules are normalized and deduplicated. A policy update may contain at most 4096 combined allow/block rules, and a request frame may not exceed 1 MiB.
|
||||
|
||||
When the normalized allow or block policy changes, Softnet clears the flow table so the new policy applies to established flows immediately. This may interrupt active connections. Repeating the same normalized policy is a no-op and preserves the flow table.
|
||||
|
||||
Use `block=["0.0.0.0/0"]` with specific allow rules for a default-deny egress policy. Closing the control socket leaves the last accepted policy active.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
use dhcproto::v4::{DhcpOption, MessageType, OptionCode};
|
||||
use dhcproto::Decodable;
|
||||
use dhcproto::v4::{DhcpOption, HType, Message, MessageType, Opcode, OptionCode};
|
||||
use smoltcp::wire::Ipv4Address;
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DhcpSnooper {
|
||||
vm_mac_address: [u8; 6],
|
||||
vm_lease: Option<Lease>,
|
||||
uncertainty_duration: Duration,
|
||||
}
|
||||
|
||||
impl DhcpSnooper {
|
||||
pub fn new(uncertainty_duration: Duration, vm_mac_address: [u8; 6]) -> Self {
|
||||
DhcpSnooper {
|
||||
vm_mac_address,
|
||||
uncertainty_duration,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_dhcp_reply(&mut self, dhcp_packet: &[u8]) {
|
||||
let mut decoder = dhcproto::v4::Decoder::new(dhcp_packet);
|
||||
|
||||
|
|
@ -18,6 +28,14 @@ impl DhcpSnooper {
|
|||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Decoded DHCP replies may be broadcast[1], so additionally validate the BOOTP client
|
||||
// hardware address to avoid acting on another VM's lease transition
|
||||
//
|
||||
// [1]: https://datatracker.ietf.org/doc/html/rfc2131#section-4.1
|
||||
if !message_matches_bootp_client(&message, Opcode::BootReply, self.vm_mac_address) {
|
||||
return;
|
||||
}
|
||||
|
||||
match message.opts().msg_type() {
|
||||
Some(MessageType::Ack) => {
|
||||
let lease_time = match message.opts().get(OptionCode::AddressLeaseTime) {
|
||||
|
|
@ -26,17 +44,18 @@ impl DhcpSnooper {
|
|||
};
|
||||
|
||||
let dns_ips = match message.opts().get(OptionCode::DomainNameServer) {
|
||||
Some(DhcpOption::DomainNameServer(dns_ips)) => HashSet::from_iter(
|
||||
dns_ips.iter().map(|dns_ip| Ipv4Address(dns_ip.octets())),
|
||||
),
|
||||
Some(DhcpOption::DomainNameServer(dns_ips)) => {
|
||||
HashSet::from_iter(dns_ips.iter().cloned())
|
||||
}
|
||||
_ => HashSet::new(),
|
||||
};
|
||||
|
||||
self.vm_lease = Some(Lease::new(
|
||||
message.yiaddr().into(),
|
||||
Duration::from_secs(*lease_time as u64),
|
||||
dns_ips,
|
||||
))
|
||||
let mut lease_duration = Duration::from_secs(*lease_time as u64);
|
||||
|
||||
// Adjust for uncertainty caused by using a coarse clock
|
||||
lease_duration = lease_duration.saturating_sub(self.uncertainty_duration);
|
||||
|
||||
self.vm_lease = Some(Lease::new(message.yiaddr(), lease_duration, dns_ips))
|
||||
}
|
||||
Some(MessageType::Nak) => {
|
||||
self.vm_lease = None;
|
||||
|
|
@ -45,10 +64,20 @@ impl DhcpSnooper {
|
|||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_lease(&mut self, vm_lease: Option<Lease>) {
|
||||
self.vm_lease = vm_lease
|
||||
}
|
||||
|
||||
pub fn lease(&self) -> &Option<Lease> {
|
||||
&self.vm_lease
|
||||
}
|
||||
|
||||
pub(crate) fn address_and_dns_ips(&self) -> Option<(Ipv4Address, HashSet<Ipv4Address>)> {
|
||||
let lease = self.vm_lease.as_ref().filter(|lease| lease.valid())?;
|
||||
Some((lease.address(), lease.dns_ips.clone()))
|
||||
}
|
||||
|
||||
pub fn valid_dns_target(&self, addr: &Ipv4Address) -> bool {
|
||||
if let Some(lease) = &self.vm_lease {
|
||||
return lease.dns_ips.contains(addr);
|
||||
|
|
@ -58,22 +87,96 @@ impl DhcpSnooper {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Lease {
|
||||
address: Ipv4Address,
|
||||
valid_until: Instant,
|
||||
valid_until: coarsetime::Instant,
|
||||
dns_ips: HashSet<Ipv4Address>,
|
||||
}
|
||||
|
||||
impl Lease {
|
||||
fn new(address: Ipv4Address, lease_time: Duration, dns_ips: HashSet<Ipv4Address>) -> Lease {
|
||||
pub fn new(address: Ipv4Address, lease_time: Duration, dns_ips: HashSet<Ipv4Address>) -> Lease {
|
||||
Lease {
|
||||
address,
|
||||
valid_until: Instant::now() + lease_time,
|
||||
valid_until: coarsetime::Instant::recent() + lease_time.into(),
|
||||
dns_ips,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn valid_ip_source(&self, address: Ipv4Address) -> bool {
|
||||
self.address == address && Instant::now() < self.valid_until
|
||||
pub fn address(&self) -> Ipv4Address {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn valid(&self) -> bool {
|
||||
coarsetime::Instant::recent() < self.valid_until
|
||||
}
|
||||
|
||||
pub fn is_valid_for(&self, address: Ipv4Address) -> bool {
|
||||
self.address == address && self.valid()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn message_matches_bootp_client(
|
||||
message: &Message,
|
||||
opcode: Opcode,
|
||||
mac: [u8; 6],
|
||||
) -> bool {
|
||||
message.opcode() == opcode
|
||||
&& message.htype() == HType::Eth
|
||||
&& message.hlen() == mac.len() as u8
|
||||
&& message.chaddr() == mac
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DhcpSnooper, Lease};
|
||||
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode};
|
||||
use dhcproto::{Encodable, Encoder};
|
||||
use smoltcp::wire::Ipv4Address;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
const VM_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01];
|
||||
const OTHER_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02];
|
||||
const OLD_ADDRESS: Ipv4Address = Ipv4Address::new(192, 168, 64, 2);
|
||||
|
||||
#[test]
|
||||
fn processes_replies_only_for_matching_client() {
|
||||
// Start with an active lease
|
||||
let mut snooper = DhcpSnooper::new(Duration::ZERO, VM_MAC);
|
||||
snooper.set_lease(Some(Lease::new(
|
||||
OLD_ADDRESS,
|
||||
Duration::from_secs(600),
|
||||
HashSet::new(),
|
||||
)));
|
||||
|
||||
// Ignore a NAK for another client
|
||||
let mut message = Message::new(
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
&OTHER_MAC,
|
||||
);
|
||||
message.set_opcode(Opcode::BootReply);
|
||||
message
|
||||
.opts_mut()
|
||||
.insert(DhcpOption::MessageType(MessageType::Nak));
|
||||
|
||||
let mut encoded = Vec::new();
|
||||
message.encode(&mut Encoder::new(&mut encoded)).unwrap();
|
||||
|
||||
snooper.register_dhcp_reply(&encoded);
|
||||
|
||||
assert_eq!(snooper.lease().as_ref().unwrap().address(), OLD_ADDRESS);
|
||||
|
||||
// Process a NAK for the matching client
|
||||
message.set_chaddr(&VM_MAC);
|
||||
encoded.clear();
|
||||
message.encode(&mut Encoder::new(&mut encoded)).unwrap();
|
||||
|
||||
snooper.register_dhcp_reply(&encoded);
|
||||
|
||||
assert!(snooper.lease().is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
lib/host.rs
122
lib/host.rs
|
|
@ -1,12 +1,27 @@
|
|||
use crate::{Error, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use clap::ValueEnum;
|
||||
use log::info;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
use std::os::unix::net::UnixDatagram;
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc::{sync_channel, SyncSender};
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
use vmnet::mode::Mode;
|
||||
use vmnet::parameters::{Parameter, ParameterKind};
|
||||
use vmnet::{Events, Options};
|
||||
use vmnet::port_forwarding::{AddressFamily, Protocol};
|
||||
use vmnet::{Batch, Events, Options};
|
||||
|
||||
#[derive(ValueEnum, Clone, Debug)]
|
||||
pub enum NetType {
|
||||
/// Shared network
|
||||
///
|
||||
/// Uses NAT-translation to give guests access to the global network
|
||||
Nat,
|
||||
/// Host network
|
||||
///
|
||||
/// Guests will be able to talk only to the host without access to global network
|
||||
Host,
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
interface: vmnet::Interface,
|
||||
|
|
@ -14,47 +29,66 @@ pub struct Host {
|
|||
callback_can_continue_tx: SyncSender<()>,
|
||||
pub gateway_ip: smoltcp::wire::Ipv4Address,
|
||||
pub max_packet_size: u64,
|
||||
pub read_max_packets: u64,
|
||||
finalized: bool,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn new() -> Result<Host> {
|
||||
// Initialize a vmnet.framework NAT interface with isolation enabled
|
||||
pub fn new(vm_net_type: NetType, enable_isolation: bool) -> Result<Host> {
|
||||
// Initialize a vmnet.framework NAT or Host interface with isolation enabled
|
||||
let mut interface = vmnet::Interface::new(
|
||||
Mode::Shared(Default::default()),
|
||||
match vm_net_type {
|
||||
NetType::Nat => Mode::Shared(Default::default()),
|
||||
NetType::Host => Mode::Host(Default::default()),
|
||||
},
|
||||
Options {
|
||||
enable_isolation: Some(true),
|
||||
enable_isolation: Some(enable_isolation),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|err| Error::VmnetFailed { source: err })?;
|
||||
.context("failed to initialize vmnet interface")?;
|
||||
|
||||
// Retrieve first IP (gateway) used for this interface
|
||||
let gateway_ip = match interface.parameters().get(ParameterKind::StartAddress) {
|
||||
Some(Parameter::StartAddress(gateway_ip)) => gateway_ip,
|
||||
_ => return Err(Error::VmnetUnexpected),
|
||||
let Some(Parameter::StartAddress(gateway_ip)) =
|
||||
interface.parameters().get(ParameterKind::StartAddress)
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"failed to retrieve vmnet's interface start address"
|
||||
));
|
||||
};
|
||||
let gateway_ip = Ipv4Addr::from_str(&gateway_ip).map_err(|_| Error::VmnetUnexpected)?;
|
||||
let gateway_ip = Ipv4Addr::from_str(&gateway_ip)
|
||||
.context("failed to parse vmnet's interface start address")?;
|
||||
|
||||
// Retrieve max packet size for this interface
|
||||
let max_packet_size = match interface.parameters().get(ParameterKind::MaxPacketSize) {
|
||||
Some(Parameter::MaxPacketSize(max_packet_size)) => max_packet_size,
|
||||
_ => return Err(Error::VmnetUnexpected),
|
||||
let Some(Parameter::MaxPacketSize(max_packet_size)) =
|
||||
interface.parameters().get(ParameterKind::MaxPacketSize)
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"failed to retrieve vmnet's interface max packet size"
|
||||
));
|
||||
};
|
||||
|
||||
// Retrieve read max packets for this interface
|
||||
let Some(Parameter::ReadMaxPackets(read_max_packets)) =
|
||||
interface.parameters().get(ParameterKind::ReadMaxPackets)
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"failed to retrieve vmnet's interface read max packets"
|
||||
));
|
||||
};
|
||||
|
||||
// Set up a socketpair() to emulate polling of the vmnet interface
|
||||
let (new_packets_tx, new_packets_rx) =
|
||||
UnixDatagram::pair().map_err(|err| Error::InitFailed { source: err.into() })?;
|
||||
new_packets_rx
|
||||
.set_nonblocking(true)
|
||||
.map_err(|err| Error::InitFailed { source: err.into() })?;
|
||||
let (new_packets_tx, new_packets_rx) = UnixDatagram::pair()?;
|
||||
new_packets_rx.set_nonblocking(true)?;
|
||||
|
||||
let (callback_can_continue_tx, callback_can_continue_rx) = sync_channel(0);
|
||||
|
||||
interface
|
||||
.set_event_callback(Events::PACKETS_AVAILABLE, move |_mask, _params| {
|
||||
// Send a dummy datagram to make the other end of socketpair() readable
|
||||
new_packets_tx.send(&[0; 1]).unwrap();
|
||||
// and ignore the error as this merely a signalling channel to wake up
|
||||
// the poller
|
||||
new_packets_tx.send(&[0; 1]).ok();
|
||||
|
||||
// Wait for the permission to continue to avoid
|
||||
// wasting CPU cycles or in case of termination,
|
||||
|
|
@ -64,28 +98,60 @@ impl Host {
|
|||
// [1]: https://en.wikipedia.org/wiki/Blocks_(C_language_extension)
|
||||
callback_can_continue_rx.recv().unwrap();
|
||||
})
|
||||
.map_err(|err| Error::VmnetFailed { source: err })?;
|
||||
.context("failed to set vmnet interface's event callback")?;
|
||||
|
||||
Ok(Host {
|
||||
interface,
|
||||
new_packets_rx,
|
||||
callback_can_continue_tx,
|
||||
gateway_ip: gateway_ip.into(),
|
||||
gateway_ip,
|
||||
max_packet_size,
|
||||
read_max_packets,
|
||||
finalized: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn read(&mut self, buf: &mut [u8]) -> vmnet::Result<usize> {
|
||||
pub fn port_forwarding_add_rule(
|
||||
&mut self,
|
||||
external_port: u16,
|
||||
internal_addr: Ipv4Addr,
|
||||
internal_port: u16,
|
||||
) -> Result<()> {
|
||||
let details = format!(
|
||||
"external_port={external_port}, internal_addr={internal_addr}, internal_port={internal_port}"
|
||||
);
|
||||
|
||||
self.interface
|
||||
.port_forwarding_rule_add(
|
||||
AddressFamily::Ipv4,
|
||||
Protocol::Tcp,
|
||||
external_port,
|
||||
internal_addr.into(),
|
||||
internal_port,
|
||||
)
|
||||
.map(|_| info!("added port forwarding rule {details}"))
|
||||
.map_err(|err| anyhow!("failed to add port forwarding rule {details}: {err}"))
|
||||
}
|
||||
|
||||
pub fn port_forwarding_remove_rule(&mut self, external_port: u16) -> Result<()> {
|
||||
let details = format!("external_port={external_port}");
|
||||
|
||||
self.interface
|
||||
.port_forwarding_rule_remove(AddressFamily::Ipv4, Protocol::Tcp, external_port)
|
||||
.map(|_| info!("removed port forwarding rule {details}"))
|
||||
.map_err(|err| anyhow!("failed to remove port forwarding rule {details}: {err}"))
|
||||
}
|
||||
|
||||
pub fn read(&mut self, batch: &mut Batch, bufs: &mut [Vec<u8>]) -> vmnet::Result<usize> {
|
||||
// Dequeue dummy datagram from the socket (if any)
|
||||
// to free up buffer space and reduce false-positives
|
||||
// when polling
|
||||
let mut buf_to_be_discarded: [u8; 1] = [0; 1];
|
||||
let _ = self.new_packets_rx.recv(&mut buf_to_be_discarded);
|
||||
|
||||
let result = self.interface.read(buf);
|
||||
let result = self.interface.read_batch(batch, bufs);
|
||||
|
||||
if let Err(vmnet::Error::VmnetReadNothing) = result {
|
||||
// We've emptied everything, unlock the callback
|
||||
|
|
@ -104,14 +170,14 @@ impl Host {
|
|||
// First make sure our callback won't be scheduled again after it finishes
|
||||
self.interface
|
||||
.clear_event_callback()
|
||||
.map_err(|err| Error::VmnetFailed { source: err })?;
|
||||
.context("failed to clear vmnet interface's event callback")?;
|
||||
|
||||
// Now let the callback finish
|
||||
self.callback_can_continue_tx.send(()).unwrap();
|
||||
let _ = self.callback_can_continue_tx.send(());
|
||||
|
||||
self.interface
|
||||
.finalize()
|
||||
.map_err(|err| Error::VmnetFailed { source: err })?;
|
||||
.context("failed to finalize vmnet's interface")?;
|
||||
|
||||
self.finalized = true;
|
||||
|
||||
|
|
|
|||
26
lib/mod.rs
26
lib/mod.rs
|
|
@ -1,30 +1,6 @@
|
|||
mod dhcp_snooper;
|
||||
mod host;
|
||||
pub use host::NetType;
|
||||
mod poller;
|
||||
pub mod proxy;
|
||||
mod vm;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("initialization failed")]
|
||||
InitFailed { source: Box<dyn std::error::Error> },
|
||||
|
||||
#[error("failed to poll")]
|
||||
PollFailed { source: std::io::Error },
|
||||
|
||||
#[error("vmnet failed")]
|
||||
VmnetFailed { source: vmnet::Error },
|
||||
|
||||
#[error("vmnet returned unexpected data")]
|
||||
VmnetUnexpected,
|
||||
|
||||
#[error("failed to do I/O on VM socket")]
|
||||
VMIOFailed { source: std::io::Error },
|
||||
|
||||
#[error("failed to do I/O on host socket")]
|
||||
HostIOFailed { source: vmnet::Error },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
|
|
|||
104
lib/poller.rs
104
lib/poller.rs
|
|
@ -1,13 +1,18 @@
|
|||
use crate::{Error, Result};
|
||||
use anyhow::Result;
|
||||
use num_enum::IntoPrimitive;
|
||||
use polling::PollMode;
|
||||
use polling::os::kqueue::PollerKqueueExt;
|
||||
use std::os::fd::{AsRawFd, BorrowedFd};
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct Poller {
|
||||
pub struct Poller<'poller> {
|
||||
poller: polling::Poller,
|
||||
events: Vec<polling::Event>,
|
||||
vm_fd: RawFd,
|
||||
host_fd: RawFd,
|
||||
events: polling::Events,
|
||||
timeout: Duration,
|
||||
vm_fd: BorrowedFd<'poller>,
|
||||
host_fd: BorrowedFd<'poller>,
|
||||
control_fd: Option<BorrowedFd<'poller>>,
|
||||
}
|
||||
|
||||
#[derive(IntoPrimitive)]
|
||||
|
|
@ -15,55 +20,86 @@ pub struct Poller {
|
|||
enum EventKey {
|
||||
VM,
|
||||
Host,
|
||||
Control,
|
||||
Interrupt,
|
||||
}
|
||||
|
||||
impl Poller {
|
||||
pub fn new(vm_fd: RawFd, host_fd: RawFd) -> Result<Poller> {
|
||||
let poller =
|
||||
polling::Poller::new().map_err(|err| Error::InitFailed { source: err.into() })?;
|
||||
impl Poller<'_> {
|
||||
pub fn new<'poller>(
|
||||
vm_fd: RawFd,
|
||||
host_fd: RawFd,
|
||||
control_fd: Option<RawFd>,
|
||||
timeout: Duration,
|
||||
) -> Result<Poller<'poller>> {
|
||||
let poller = polling::Poller::new()?;
|
||||
|
||||
Ok(Poller {
|
||||
poller,
|
||||
events: Vec::new(),
|
||||
vm_fd,
|
||||
host_fd,
|
||||
events: polling::Events::new(),
|
||||
timeout,
|
||||
vm_fd: unsafe { BorrowedFd::borrow_raw(vm_fd) },
|
||||
host_fd: unsafe { BorrowedFd::borrow_raw(host_fd) },
|
||||
control_fd: control_fd.map(|fd| unsafe { BorrowedFd::borrow_raw(fd) }),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn arm(&self) -> Result<()> {
|
||||
self.poller
|
||||
.add(self.vm_fd as RawFd, self.vm_interest())
|
||||
.map_err(|err| Error::PollFailed { source: err })?;
|
||||
unsafe {
|
||||
self.poller.add_with_mode(
|
||||
self.vm_fd.as_raw_fd(),
|
||||
self.vm_interest(),
|
||||
PollMode::Edge,
|
||||
)?;
|
||||
|
||||
if let Some(control_fd) = self.control_fd {
|
||||
self.poller.add_with_mode(
|
||||
control_fd.as_raw_fd(),
|
||||
polling::Event::all(EventKey::Control.into()),
|
||||
PollMode::Edge,
|
||||
)?;
|
||||
}
|
||||
self.poller.add_with_mode(
|
||||
self.host_fd.as_raw_fd(),
|
||||
self.host_interest(),
|
||||
PollMode::Edge,
|
||||
)?;
|
||||
}
|
||||
|
||||
let interrupt_signal = polling::os::kqueue::Signal(libc::SIGINT);
|
||||
self.poller
|
||||
.add(self.host_fd as RawFd, self.host_interest())
|
||||
.map_err(|err| Error::PollFailed { source: err })?;
|
||||
.add_filter(interrupt_signal, EventKey::Interrupt.into(), PollMode::Edge)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rearm(&mut self) -> Result<()> {
|
||||
pub fn rearm(&mut self) {
|
||||
self.events.clear();
|
||||
|
||||
self.poller
|
||||
.modify(self.vm_fd as RawFd, self.vm_interest())
|
||||
.map_err(|err| Error::PollFailed { source: err })?;
|
||||
self.poller
|
||||
.modify(self.host_fd as RawFd, self.host_interest())
|
||||
.map_err(|err| Error::PollFailed { source: err })?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn wait(&mut self) -> Result<(bool, bool)> {
|
||||
self.poller
|
||||
.wait(&mut self.events, Some(Duration::from_millis(100)))
|
||||
.map_err(|err| Error::PollFailed { source: err })?;
|
||||
pub fn wait(&mut self) -> Result<(bool, bool, bool)> {
|
||||
self.poller.wait(&mut self.events, Some(self.timeout))?;
|
||||
|
||||
let vm_readable = self.events.iter().any(|ev| ev.key == EventKey::VM.into());
|
||||
let host_readable = self.events.iter().any(|ev| ev.key == EventKey::Host.into());
|
||||
let vm_readable = self
|
||||
.events
|
||||
.iter()
|
||||
.any(|ev| ev.key == Into::<usize>::into(EventKey::VM));
|
||||
let host_readable = self
|
||||
.events
|
||||
.iter()
|
||||
.any(|ev| ev.key == Into::<usize>::into(EventKey::Host));
|
||||
let interrupt = self
|
||||
.events
|
||||
.iter()
|
||||
.any(|ev| ev.key == Into::<usize>::into(EventKey::Interrupt));
|
||||
Ok((vm_readable, host_readable, interrupt))
|
||||
}
|
||||
|
||||
Ok((vm_readable, host_readable))
|
||||
pub fn remove_control(&mut self) -> Result<()> {
|
||||
if let Some(control_fd) = self.control_fd.take() {
|
||||
self.poller.delete(control_fd)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vm_interest(&self) -> polling::Event {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,47 @@
|
|||
use anyhow::{Context, Error, anyhow};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ExposedPort {
|
||||
pub external_port: u16,
|
||||
pub internal_port: u16,
|
||||
}
|
||||
|
||||
impl FromStr for ExposedPort {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let splits: Vec<&str> = s.split(':').collect();
|
||||
|
||||
match splits.len() {
|
||||
2 => Ok(ExposedPort {
|
||||
external_port: splits[0]
|
||||
.parse()
|
||||
.context(format!("invalid external port {:?}", splits[0]))?,
|
||||
internal_port: splits[1]
|
||||
.parse()
|
||||
.context(format!("invalid internal port {:?}", splits[1]))?,
|
||||
}),
|
||||
_ => Err(anyhow!(
|
||||
"invalid exposed port specification {:?}, the format should be EXTERNAL:INTERNAL",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::proxy::exposed_port::ExposedPort;
|
||||
|
||||
#[test]
|
||||
fn exposed_port() {
|
||||
assert_eq!(
|
||||
ExposedPort {
|
||||
external_port: 2222,
|
||||
internal_port: 22
|
||||
},
|
||||
"2222:22".parse().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
|
||||
use coarsetime::{Duration, Instant};
|
||||
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, Ipv4Address, Ipv4Packet};
|
||||
|
||||
const ECHO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
impl FlowTable {
|
||||
pub(super) fn inspect_icmp(
|
||||
&mut self,
|
||||
ipv4_pkt: &Ipv4Packet<&[u8]>,
|
||||
direction: FlowDirection,
|
||||
now: Instant,
|
||||
) -> FlowMatch {
|
||||
let Ok(icmp) = Icmpv4Packet::new_checked(ipv4_pkt.payload()) else {
|
||||
return FlowMatch::Denied;
|
||||
};
|
||||
if !icmp.verify_checksum() {
|
||||
return FlowMatch::Denied;
|
||||
}
|
||||
|
||||
match (icmp.msg_type(), icmp.msg_code()) {
|
||||
(Icmpv4Message::EchoRequest, 0) => {
|
||||
self.inspect_echo(ipv4_pkt, direction, icmp.echo_ident(), true, now)
|
||||
}
|
||||
(Icmpv4Message::EchoReply, 0) => {
|
||||
self.inspect_echo(ipv4_pkt, direction, icmp.echo_ident(), false, now)
|
||||
}
|
||||
// Other ICMP messages follow normal source policy: treating errors that quote
|
||||
// tracked tuples as RELATED requires validation beyond this exact-tuple table
|
||||
//
|
||||
// Potential degradation of PMTU discovery and traceroute is an accepted tradeoff.
|
||||
_ => FlowMatch::Untracked,
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_echo(
|
||||
&mut self,
|
||||
ipv4_pkt: &Ipv4Packet<&[u8]>,
|
||||
direction: FlowDirection,
|
||||
ident: u16,
|
||||
is_request: bool,
|
||||
now: Instant,
|
||||
) -> FlowMatch {
|
||||
// We need to preserve the request direction so opposite-direction echo flows remain distinct
|
||||
let initiating_direction = if is_request {
|
||||
direction
|
||||
} else {
|
||||
direction.opposite()
|
||||
};
|
||||
|
||||
let key = FlowKey::icmp_echo(
|
||||
ipv4_pkt.src_addr(),
|
||||
ipv4_pkt.dst_addr(),
|
||||
direction,
|
||||
initiating_direction,
|
||||
ident,
|
||||
);
|
||||
|
||||
if let Some(matched) = self.match_existing_flow(key, direction, now, ECHO_TIMEOUT) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
if is_request {
|
||||
FlowMatch::candidate(key, initiating_direction, now, ECHO_TIMEOUT)
|
||||
} else {
|
||||
// Only replies matching an admitted request are tracked
|
||||
FlowMatch::Untracked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FlowKey {
|
||||
fn icmp_echo(
|
||||
src_addr: Ipv4Address,
|
||||
dst_addr: Ipv4Address,
|
||||
direction: FlowDirection,
|
||||
initiating_direction: FlowDirection,
|
||||
ident: u16,
|
||||
) -> Self {
|
||||
let (host_addr, vm_addr) = direction.host_vm_pair(src_addr, dst_addr);
|
||||
Self::IcmpEcho {
|
||||
host_addr,
|
||||
vm_addr,
|
||||
ident,
|
||||
initiating_direction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::{HOST, VM, inspect_from_host, inspect_from_vm, ipv4_packet};
|
||||
use super::super::{FlowMatch, FlowTable};
|
||||
use smoltcp::wire::{Icmpv4Message, Icmpv4Packet, IpProtocol, Ipv4Address};
|
||||
|
||||
#[test]
|
||||
fn echo_request_and_reply_are_tracked_in_both_directions() {
|
||||
let mut tracker = FlowTable::new();
|
||||
let vm_request = echo_packet(VM, HOST, Icmpv4Message::EchoRequest, 7, 1);
|
||||
|
||||
let FlowMatch::Candidate(pending) = inspect_from_vm(&mut tracker, &vm_request) else {
|
||||
panic!("expected a new flow");
|
||||
};
|
||||
assert!(tracker.commit(pending));
|
||||
|
||||
let host_reply = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 7, 1);
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &host_reply),
|
||||
FlowMatch::Allowed
|
||||
));
|
||||
|
||||
let wrong_ident = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 8, 1);
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &wrong_ident),
|
||||
FlowMatch::Untracked
|
||||
));
|
||||
|
||||
// The opposite direction is a distinct flow even with the same identifier.
|
||||
let host_request = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &host_request) else {
|
||||
panic!("expected a new flow");
|
||||
};
|
||||
assert!(tracker.commit(pending));
|
||||
|
||||
let vm_reply = echo_packet(VM, HOST, Icmpv4Message::EchoReply, 7, 1);
|
||||
assert!(matches!(
|
||||
inspect_from_vm(&mut tracker, &vm_reply),
|
||||
FlowMatch::Allowed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_echo_is_denied_and_unsolicited_echo_is_untracked() {
|
||||
let mut tracker = FlowTable::new();
|
||||
let unsolicited = echo_packet(HOST, VM, Icmpv4Message::EchoReply, 7, 1);
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &unsolicited),
|
||||
FlowMatch::Untracked
|
||||
));
|
||||
|
||||
let mut wrong_code = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
|
||||
wrong_code[21] = 1;
|
||||
Icmpv4Packet::new_unchecked(&mut wrong_code[20..]).fill_checksum();
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &wrong_code),
|
||||
FlowMatch::Untracked
|
||||
));
|
||||
|
||||
let mut bad_checksum = echo_packet(HOST, VM, Icmpv4Message::EchoRequest, 7, 1);
|
||||
bad_checksum[27] ^= 1;
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &bad_checksum),
|
||||
FlowMatch::Denied
|
||||
));
|
||||
}
|
||||
|
||||
fn echo_packet(
|
||||
src_addr: Ipv4Address,
|
||||
dst_addr: Ipv4Address,
|
||||
message: Icmpv4Message,
|
||||
ident: u16,
|
||||
sequence: u16,
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Icmp, 8);
|
||||
let mut icmp = Icmpv4Packet::new_unchecked(&mut bytes[20..]);
|
||||
icmp.set_msg_type(message);
|
||||
icmp.set_msg_code(0);
|
||||
icmp.set_echo_ident(ident);
|
||||
icmp.set_echo_seq_no(sequence);
|
||||
icmp.fill_checksum();
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
mod icmp;
|
||||
mod tcp;
|
||||
mod udp;
|
||||
|
||||
use coarsetime::{Duration, Instant};
|
||||
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const MAX_FLOWS: usize = 32_768;
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// A bounded cache of exact-tuple permissions for return traffic.
|
||||
///
|
||||
/// The table establishes only the direction in which a flow was authorized.
|
||||
/// This is deliberately not a TCP state machine. Endpoint transport stacks remain
|
||||
/// responsible for TCP handshakes, teardown, sequence numbers, and receive windows,
|
||||
/// as well as ICMP echo sequences.
|
||||
///
|
||||
/// Capacity is intentionally enforced with one fail-closed limit per VM.
|
||||
/// Exhaustion may deny further networking for that VM; this availability
|
||||
/// tradeoff is accepted to avoid fairness quotas, admission scans, and eviction.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FlowTable {
|
||||
flows: HashMap<FlowKey, Flow>,
|
||||
next_sweep: Instant,
|
||||
}
|
||||
|
||||
/// A proposed flow-table entry awaiting policy authorization.
|
||||
pub(crate) struct PendingFlow {
|
||||
key: FlowKey,
|
||||
flow: Flow,
|
||||
}
|
||||
|
||||
/// Metadata stored for an admitted flow.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Flow {
|
||||
initiating_direction: FlowDirection,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// The result of classifying a packet against the flow table.
|
||||
pub(crate) enum FlowMatch {
|
||||
/// The packet is exact-tuple return traffic for an admitted flow.
|
||||
Allowed,
|
||||
|
||||
/// Policy must authorize this initiator-side packet before committing it.
|
||||
Candidate(PendingFlow),
|
||||
|
||||
/// The packet is malformed or violates a transport invariant that we deliberately enforce.
|
||||
Denied,
|
||||
|
||||
/// The flow table has no stateful interpretation for this packet.
|
||||
Untracked,
|
||||
}
|
||||
|
||||
impl FlowMatch {
|
||||
fn candidate(
|
||||
key: FlowKey,
|
||||
initiating_direction: FlowDirection,
|
||||
now: Instant,
|
||||
timeout: Duration,
|
||||
) -> Self {
|
||||
Self::Candidate(PendingFlow {
|
||||
key,
|
||||
flow: Flow {
|
||||
initiating_direction,
|
||||
expires_at: now + timeout,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical identity used to index a flow-table entry.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
enum FlowKey {
|
||||
IcmpEcho {
|
||||
host_addr: Ipv4Address,
|
||||
vm_addr: Ipv4Address,
|
||||
ident: u16,
|
||||
initiating_direction: FlowDirection,
|
||||
},
|
||||
Tcp {
|
||||
host_addr: Ipv4Address,
|
||||
host_port: u16,
|
||||
vm_addr: Ipv4Address,
|
||||
vm_port: u16,
|
||||
},
|
||||
Udp {
|
||||
host_addr: Ipv4Address,
|
||||
host_port: u16,
|
||||
vm_addr: Ipv4Address,
|
||||
vm_port: u16,
|
||||
},
|
||||
}
|
||||
|
||||
/// A packet direction across the host–VM boundary.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(super) enum FlowDirection {
|
||||
FromHost,
|
||||
FromVm,
|
||||
}
|
||||
|
||||
impl FlowTable {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
flows: HashMap::new(),
|
||||
next_sweep: Instant::recent() + SWEEP_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect(
|
||||
&mut self,
|
||||
ipv4_pkt: &Ipv4Packet<&[u8]>,
|
||||
direction: FlowDirection,
|
||||
) -> FlowMatch {
|
||||
self.inspect_at(ipv4_pkt, direction, Instant::recent())
|
||||
}
|
||||
|
||||
fn inspect_at(
|
||||
&mut self,
|
||||
ipv4_pkt: &Ipv4Packet<&[u8]>,
|
||||
direction: FlowDirection,
|
||||
now: Instant,
|
||||
) -> FlowMatch {
|
||||
// Perform lazy garbage collection of expired flow entries
|
||||
self.sweep_if_due(now);
|
||||
|
||||
// Later fragments do not contain enough transport information to bind
|
||||
// them to an exact flow, so packet policy must decide
|
||||
if ipv4_pkt.more_frags() || ipv4_pkt.frag_offset() != 0 {
|
||||
return FlowMatch::Untracked;
|
||||
}
|
||||
|
||||
match ipv4_pkt.next_header() {
|
||||
IpProtocol::Icmp => self.inspect_icmp(ipv4_pkt, direction, now),
|
||||
IpProtocol::Tcp => self.inspect_tcp(ipv4_pkt, direction, now),
|
||||
IpProtocol::Udp => self.inspect_udp(ipv4_pkt, direction, now),
|
||||
_ => FlowMatch::Untracked,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&mut self, pending: PendingFlow) -> bool {
|
||||
let PendingFlow { key, flow } = pending;
|
||||
|
||||
// Reject new entries when full while allowing existing entries to be updated
|
||||
if !self.flows.contains_key(&key) && self.flows.len() >= MAX_FLOWS {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.flows.insert(key, flow);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.flows.clear();
|
||||
}
|
||||
|
||||
fn sweep_if_due(&mut self, now: Instant) {
|
||||
if now < self.next_sweep {
|
||||
return;
|
||||
}
|
||||
|
||||
self.flows.retain(|_, flow| now < flow.expires_at);
|
||||
|
||||
self.next_sweep = now + SWEEP_INTERVAL;
|
||||
}
|
||||
|
||||
fn match_existing_flow(
|
||||
&self,
|
||||
key: FlowKey,
|
||||
direction: FlowDirection,
|
||||
now: Instant,
|
||||
timeout: Duration,
|
||||
) -> Option<FlowMatch> {
|
||||
let existing = self.flows.get(&key).copied()?;
|
||||
|
||||
// Treat expired entries as missing even before the next sweep
|
||||
if now >= existing.expires_at {
|
||||
return None;
|
||||
}
|
||||
|
||||
if direction == existing.initiating_direction {
|
||||
Some(FlowMatch::candidate(
|
||||
key,
|
||||
existing.initiating_direction,
|
||||
now,
|
||||
timeout,
|
||||
))
|
||||
} else {
|
||||
Some(FlowMatch::Allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FlowDirection {
|
||||
fn host_vm_pair<T>(self, src: T, dst: T) -> (T, T) {
|
||||
match self {
|
||||
Self::FromHost => (src, dst),
|
||||
Self::FromVm => (dst, src),
|
||||
}
|
||||
}
|
||||
|
||||
fn opposite(self) -> Self {
|
||||
match self {
|
||||
Self::FromHost => Self::FromVm,
|
||||
Self::FromVm => Self::FromHost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::{HOST, VM, inspect_from_host, ipv4_packet, udp_packet};
|
||||
use super::{
|
||||
Duration, FlowDirection, FlowMatch, FlowTable, Instant, MAX_FLOWS, SWEEP_INTERVAL,
|
||||
};
|
||||
use smoltcp::wire::{IpProtocol, Ipv4Packet};
|
||||
|
||||
#[test]
|
||||
fn all_ipv4_fragments_are_untracked() {
|
||||
for (more_fragments, offset) in [(true, 0), (false, 8)] {
|
||||
let mut bytes = udp_packet(HOST, 50_000, VM, 53);
|
||||
let mut packet = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
|
||||
packet.set_frag_offset(offset);
|
||||
packet.set_more_frags(more_fragments);
|
||||
|
||||
let mut tracker = FlowTable::new();
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &bytes),
|
||||
FlowMatch::Untracked
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_tuple_is_not_matched_before_the_next_sweep() {
|
||||
let mut tracker = FlowTable::new();
|
||||
let datagram = udp_packet(HOST, 50_000, VM, 53);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &datagram) else {
|
||||
panic!("expected a candidate");
|
||||
};
|
||||
assert!(tracker.commit(pending));
|
||||
|
||||
let now = Instant::recent();
|
||||
let flow = tracker.flows.values_mut().next().unwrap();
|
||||
flow.expires_at = now - Duration::from_secs(1);
|
||||
tracker.next_sweep = now + SWEEP_INTERVAL;
|
||||
|
||||
let reply = udp_packet(VM, 53, HOST, 50_000);
|
||||
assert!(matches!(
|
||||
tracker.inspect_at(
|
||||
&Ipv4Packet::new_checked(reply.as_slice()).unwrap(),
|
||||
FlowDirection::FromVm,
|
||||
now,
|
||||
),
|
||||
FlowMatch::Candidate(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_limit_rejects_new_tuple_but_allows_replacement() {
|
||||
let mut tracker = FlowTable::new();
|
||||
for port in 10_000..10_000 + MAX_FLOWS as u16 {
|
||||
let datagram = udp_packet(HOST, port, VM, 53);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &datagram) else {
|
||||
panic!("expected a candidate");
|
||||
};
|
||||
assert!(tracker.commit(pending));
|
||||
}
|
||||
|
||||
let over_limit = udp_packet(HOST, 50_000, VM, 53);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &over_limit) else {
|
||||
panic!("expected a candidate");
|
||||
};
|
||||
assert!(!tracker.commit(pending));
|
||||
|
||||
let replacement = udp_packet(HOST, 10_000, VM, 53);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &replacement) else {
|
||||
panic!("expected a candidate");
|
||||
};
|
||||
assert!(tracker.commit(pending));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_protocol_is_untracked() {
|
||||
let bytes = ipv4_packet(HOST, VM, IpProtocol::Unknown(253), 0);
|
||||
let mut tracker = FlowTable::new();
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &bytes),
|
||||
FlowMatch::Untracked
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_support {
|
||||
use super::{FlowDirection, FlowMatch, FlowTable};
|
||||
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, TcpPacket, UdpPacket};
|
||||
|
||||
pub(super) const HOST: Ipv4Address = Ipv4Address::new(192, 168, 64, 1);
|
||||
pub(super) const VM: Ipv4Address = Ipv4Address::new(192, 168, 64, 2);
|
||||
|
||||
pub(super) fn inspect_from_host(tracker: &mut FlowTable, bytes: &[u8]) -> FlowMatch {
|
||||
let packet = Ipv4Packet::new_checked(bytes).unwrap();
|
||||
tracker.inspect(&packet, FlowDirection::FromHost)
|
||||
}
|
||||
|
||||
pub(super) fn inspect_from_vm(tracker: &mut FlowTable, bytes: &[u8]) -> FlowMatch {
|
||||
let packet = Ipv4Packet::new_checked(bytes).unwrap();
|
||||
tracker.inspect(&packet, FlowDirection::FromVm)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct TcpFlags {
|
||||
syn: bool,
|
||||
ack: bool,
|
||||
rst: bool,
|
||||
fin: bool,
|
||||
}
|
||||
|
||||
impl TcpFlags {
|
||||
pub(super) const SYN: Self = Self {
|
||||
syn: true,
|
||||
ack: false,
|
||||
rst: false,
|
||||
fin: false,
|
||||
};
|
||||
pub(super) const SYN_ACK: Self = Self {
|
||||
syn: true,
|
||||
ack: true,
|
||||
rst: false,
|
||||
fin: false,
|
||||
};
|
||||
pub(super) const ACK: Self = Self {
|
||||
syn: false,
|
||||
ack: true,
|
||||
rst: false,
|
||||
fin: false,
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn tcp_packet(
|
||||
src_addr: Ipv4Address,
|
||||
src_port: u16,
|
||||
dst_addr: Ipv4Address,
|
||||
dst_port: u16,
|
||||
flags: TcpFlags,
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Tcp, 20);
|
||||
let mut tcp = TcpPacket::new_unchecked(&mut bytes[20..]);
|
||||
tcp.set_src_port(src_port);
|
||||
tcp.set_dst_port(dst_port);
|
||||
tcp.set_header_len(20);
|
||||
tcp.set_syn(flags.syn);
|
||||
tcp.set_ack(flags.ack);
|
||||
tcp.set_rst(flags.rst);
|
||||
tcp.set_fin(flags.fin);
|
||||
tcp.set_window_len(u16::MAX);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub(super) fn udp_packet(
|
||||
src_addr: Ipv4Address,
|
||||
src_port: u16,
|
||||
dst_addr: Ipv4Address,
|
||||
dst_port: u16,
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = ipv4_packet(src_addr, dst_addr, IpProtocol::Udp, 8);
|
||||
let mut udp = UdpPacket::new_unchecked(&mut bytes[20..]);
|
||||
udp.set_src_port(src_port);
|
||||
udp.set_dst_port(dst_port);
|
||||
udp.set_len(8);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub(super) fn ipv4_packet(
|
||||
src_addr: Ipv4Address,
|
||||
dst_addr: Ipv4Address,
|
||||
protocol: IpProtocol,
|
||||
payload_len: usize,
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 20 + payload_len];
|
||||
let total_len = bytes.len() as u16;
|
||||
let mut ipv4 = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
|
||||
ipv4.set_version(4);
|
||||
ipv4.set_header_len(20);
|
||||
ipv4.set_total_len(total_len);
|
||||
ipv4.set_next_header(protocol);
|
||||
ipv4.set_src_addr(src_addr);
|
||||
ipv4.set_dst_addr(dst_addr);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
|
||||
use coarsetime::{Duration, Instant};
|
||||
use smoltcp::wire::{Ipv4Address, Ipv4Packet, TcpPacket};
|
||||
|
||||
const SYN_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TCP_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
impl FlowTable {
|
||||
pub(super) fn inspect_tcp(
|
||||
&mut self,
|
||||
ipv4_pkt: &Ipv4Packet<&[u8]>,
|
||||
direction: FlowDirection,
|
||||
now: Instant,
|
||||
) -> FlowMatch {
|
||||
let Ok(tcp) = TcpPacket::new_checked(ipv4_pkt.payload()) else {
|
||||
return FlowMatch::Denied;
|
||||
};
|
||||
if tcp.src_port() == 0 || tcp.dst_port() == 0 {
|
||||
return FlowMatch::Denied;
|
||||
}
|
||||
|
||||
let key = FlowKey::tcp(
|
||||
direction,
|
||||
(ipv4_pkt.src_addr(), tcp.src_port()),
|
||||
(ipv4_pkt.dst_addr(), tcp.dst_port()),
|
||||
);
|
||||
|
||||
// A bare SYN may be either a retransmission or a new connection reusing
|
||||
// the tuple in either direction. Always return it to policy, and do not
|
||||
// replace an existing permission until the candidate is committed.
|
||||
let is_initial_syn = tcp.syn() && !tcp.ack() && !tcp.fin() && !tcp.rst();
|
||||
|
||||
if is_initial_syn {
|
||||
return FlowMatch::candidate(key, direction, now, SYN_TIMEOUT);
|
||||
}
|
||||
|
||||
self.match_existing_flow(key, direction, now, TCP_TIMEOUT)
|
||||
.unwrap_or(FlowMatch::Untracked)
|
||||
}
|
||||
}
|
||||
|
||||
impl FlowKey {
|
||||
fn tcp(direction: FlowDirection, src: (Ipv4Address, u16), dst: (Ipv4Address, u16)) -> Self {
|
||||
let ((host_addr, host_port), (vm_addr, vm_port)) = direction.host_vm_pair(src, dst);
|
||||
Self::Tcp {
|
||||
host_addr,
|
||||
host_port,
|
||||
vm_addr,
|
||||
vm_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::{
|
||||
HOST, TcpFlags, VM, inspect_from_host, inspect_from_vm, tcp_packet,
|
||||
};
|
||||
use super::super::{FlowMatch, FlowTable};
|
||||
|
||||
fn admit_host_syn(tracker: &mut FlowTable) {
|
||||
let syn = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(tracker, &syn) else {
|
||||
panic!("expected a candidate");
|
||||
};
|
||||
assert!(tracker.commit(pending));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admitted_syn_creates_an_exact_reverse_permission() {
|
||||
let mut tracker = FlowTable::new();
|
||||
admit_host_syn(&mut tracker);
|
||||
let original_expiry = tracker.flows.values().next().unwrap().expires_at;
|
||||
|
||||
let reply = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::ACK);
|
||||
assert!(matches!(
|
||||
inspect_from_vm(&mut tracker, &reply),
|
||||
FlowMatch::Allowed
|
||||
));
|
||||
assert_eq!(
|
||||
tracker.flows.values().next().unwrap().expires_at,
|
||||
original_expiry,
|
||||
"return traffic must not refresh a tuple"
|
||||
);
|
||||
|
||||
let initiator_data = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::ACK);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &initiator_data) else {
|
||||
panic!("expected an initiator candidate");
|
||||
};
|
||||
assert_eq!(
|
||||
tracker.flows.values().next().unwrap().expires_at,
|
||||
original_expiry,
|
||||
"inspection must not refresh a tuple before policy accepts it"
|
||||
);
|
||||
assert!(tracker.commit(pending));
|
||||
assert!(tracker.flows.values().next().unwrap().expires_at > original_expiry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tuple_requires_a_clean_syn() {
|
||||
let mut tracker = FlowTable::new();
|
||||
|
||||
for flags in [TcpFlags::ACK, TcpFlags::SYN_ACK] {
|
||||
let packet = tcp_packet(HOST, 49_152, VM, 22, flags);
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &packet),
|
||||
FlowMatch::Untracked
|
||||
));
|
||||
}
|
||||
|
||||
let syn = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN);
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &syn),
|
||||
FlowMatch::Candidate(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_requires_the_exact_tuple() {
|
||||
let mut tracker = FlowTable::new();
|
||||
admit_host_syn(&mut tracker);
|
||||
|
||||
let wrong_port = tcp_packet(VM, 22, HOST, 49_153, TcpFlags::ACK);
|
||||
assert!(matches!(
|
||||
inspect_from_vm(&mut tracker, &wrong_port),
|
||||
FlowMatch::Untracked
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_bare_syn_is_a_new_policy_candidate() {
|
||||
let mut tracker = FlowTable::new();
|
||||
admit_host_syn(&mut tracker);
|
||||
|
||||
let reverse_syn = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::SYN);
|
||||
let FlowMatch::Candidate(pending) = inspect_from_vm(&mut tracker, &reverse_syn) else {
|
||||
panic!("reverse SYN must return to policy");
|
||||
};
|
||||
|
||||
// Inspection is provisional: a policy rejection leaves the admitted
|
||||
// flow untouched.
|
||||
let old_flow_reply = tcp_packet(VM, 22, HOST, 49_152, TcpFlags::ACK);
|
||||
assert!(matches!(
|
||||
inspect_from_vm(&mut tracker, &old_flow_reply),
|
||||
FlowMatch::Allowed
|
||||
));
|
||||
|
||||
assert!(tracker.commit(pending));
|
||||
|
||||
let reverse_reply = tcp_packet(HOST, 49_152, VM, 22, TcpFlags::SYN_ACK);
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &reverse_reply),
|
||||
FlowMatch::Allowed
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
use super::{FlowDirection, FlowKey, FlowMatch, FlowTable};
|
||||
use coarsetime::{Duration, Instant};
|
||||
use smoltcp::wire::{Ipv4Address, Ipv4Packet, UdpPacket};
|
||||
|
||||
const UDP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
impl FlowTable {
|
||||
pub(super) fn inspect_udp(
|
||||
&mut self,
|
||||
ipv4_pkt: &Ipv4Packet<&[u8]>,
|
||||
direction: FlowDirection,
|
||||
now: Instant,
|
||||
) -> FlowMatch {
|
||||
let Ok(udp) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
|
||||
return FlowMatch::Denied;
|
||||
};
|
||||
if udp.dst_port() == 0 {
|
||||
return FlowMatch::Denied;
|
||||
}
|
||||
|
||||
// Deliberate policy, not merely a packet-format validation:
|
||||
// RFC 8085, §5.1 says UDP senders SHOULD NOT use source port zero.
|
||||
//
|
||||
// We enforce this recommendation to retain source-port entropy
|
||||
// and protection against off-path packet injection.
|
||||
if udp.src_port() == 0 {
|
||||
return FlowMatch::Denied;
|
||||
}
|
||||
|
||||
let key = FlowKey::udp(
|
||||
direction,
|
||||
(ipv4_pkt.src_addr(), udp.src_port()),
|
||||
(ipv4_pkt.dst_addr(), udp.dst_port()),
|
||||
);
|
||||
|
||||
if let Some(matched) = self.match_existing_flow(key, direction, now, UDP_TIMEOUT) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
FlowMatch::candidate(key, direction, now, UDP_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
impl FlowKey {
|
||||
fn udp(direction: FlowDirection, src: (Ipv4Address, u16), dst: (Ipv4Address, u16)) -> Self {
|
||||
let ((host_addr, host_port), (vm_addr, vm_port)) = direction.host_vm_pair(src, dst);
|
||||
Self::Udp {
|
||||
host_addr,
|
||||
host_port,
|
||||
vm_addr,
|
||||
vm_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::test_support::{HOST, VM, inspect_from_host, inspect_from_vm, udp_packet};
|
||||
use super::super::{FlowMatch, FlowTable};
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_source_port_per_rfc_8085() {
|
||||
let mut tracker = FlowTable::new();
|
||||
let datagram = udp_packet(HOST, 0, VM, 5353);
|
||||
|
||||
assert!(matches!(
|
||||
inspect_from_host(&mut tracker, &datagram),
|
||||
FlowMatch::Denied
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_reply_requires_an_exact_admitted_request() {
|
||||
let mut tracker = FlowTable::new();
|
||||
let request = udp_packet(HOST, 50_000, VM, 5353);
|
||||
let reply = udp_packet(VM, 5353, HOST, 50_000);
|
||||
|
||||
assert!(matches!(
|
||||
inspect_from_vm(&mut tracker, &reply),
|
||||
FlowMatch::Candidate(_)
|
||||
));
|
||||
|
||||
let FlowMatch::Candidate(pending) = inspect_from_host(&mut tracker, &request) else {
|
||||
panic!("expected a candidate");
|
||||
};
|
||||
assert!(tracker.commit(pending));
|
||||
assert!(matches!(
|
||||
inspect_from_vm(&mut tracker, &reply),
|
||||
FlowMatch::Allowed
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
use crate::dhcp_snooper::message_matches_bootp_client;
|
||||
use crate::proxy::flows::{FlowDirection, FlowMatch};
|
||||
use crate::proxy::udp_packet_helper::UdpPacketHelper;
|
||||
use crate::proxy::Proxy;
|
||||
use crate::{Error, Result};
|
||||
use smoltcp::wire::{EthernetFrame, EthernetProtocol, Ipv4Packet, UdpPacket};
|
||||
use crate::proxy::{Direction, PolicyDecision, Proxy};
|
||||
use anyhow::{Context, Result};
|
||||
use dhcproto::Decodable;
|
||||
use dhcproto::v4::Opcode;
|
||||
use smoltcp::phy::ChecksumCapabilities;
|
||||
use smoltcp::wire::{EthernetFrame, EthernetProtocol, Ipv4Packet, Ipv4Repr, UdpPacket};
|
||||
|
||||
impl Proxy {
|
||||
impl Proxy<'_> {
|
||||
pub(crate) fn process_frame_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Result<()> {
|
||||
if self.allowed_from_host(frame).is_none() {
|
||||
// Block packet by not forwarding it to the VM
|
||||
|
|
@ -12,22 +17,98 @@ impl Proxy {
|
|||
|
||||
// Snoop bootpd(8) replies from the host to
|
||||
// figure out the IP assigned to the VM
|
||||
self.snoop(frame);
|
||||
if frame.dst_addr() == self.vm_mac_address || frame.dst_addr().is_broadcast() {
|
||||
self.snoop(frame);
|
||||
}
|
||||
|
||||
self.vm
|
||||
.write(frame.as_ref())
|
||||
.map(|_| ())
|
||||
.map_err(|err| Error::VMIOFailed { source: err })
|
||||
match self.vm.write(frame.as_ref()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
if let Some(libc::ENOBUFS) = err.raw_os_error() {
|
||||
if !self.enobufs_encountered {
|
||||
sentry::capture_message(
|
||||
"No buffer space available in VM's socket",
|
||||
sentry::Level::Warning,
|
||||
);
|
||||
self.enobufs_encountered = true;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(err).context("failed to write to the VM")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
|
||||
match frame.ethertype() {
|
||||
EthernetProtocol::Arp => Some(()),
|
||||
EthernetProtocol::Ipv4 => Some(()),
|
||||
EthernetProtocol::Ipv4 => {
|
||||
let ipv4_pkt = Ipv4Packet::new_unchecked(frame.payload());
|
||||
Ipv4Repr::parse(&ipv4_pkt, &ChecksumCapabilities::ignored()).ok()?;
|
||||
|
||||
self.allowed_from_host_ipv4(&ipv4_pkt)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn allowed_from_host_ipv4(&mut self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> Option<()> {
|
||||
// Backwards compatibility with Softnet consumers that only use stateless rules
|
||||
if self.flows.is_none() {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
// DHCP is required to maintain the VM's lease and must bypass user-specified rules
|
||||
if self.is_allowed_dhcp_response(ipv4_pkt) {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
// Consult the flow table before evaluating inbound policy
|
||||
// so established flows are not treated as new traffic
|
||||
let pending = if self
|
||||
.dhcp_snooper
|
||||
.lease()
|
||||
.as_ref()
|
||||
.is_some_and(|lease| lease.is_valid_for(ipv4_pkt.dst_addr()))
|
||||
{
|
||||
match self
|
||||
.flows
|
||||
.as_mut()?
|
||||
.inspect(ipv4_pkt, FlowDirection::FromHost)
|
||||
{
|
||||
FlowMatch::Allowed => return Some(()),
|
||||
FlowMatch::Denied => return None,
|
||||
FlowMatch::Candidate(pending) => Some(pending),
|
||||
FlowMatch::Untracked => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// The flow is either pending or untracked, evaluate it against inbound policy
|
||||
match self
|
||||
.rules
|
||||
.policy_decision(ipv4_pkt.src_addr(), Direction::In)
|
||||
{
|
||||
// Return traffic was handled above; enforce explicit inbound blocks here
|
||||
Some(PolicyDecision::Block) => None,
|
||||
|
||||
// Stateless policy is outbound-only; fail closed if this invariant is violated
|
||||
Some(PolicyDecision::AllowStateless) => None,
|
||||
|
||||
// Untracked packets cannot satisfy stateful policy
|
||||
Some(PolicyDecision::AllowStateful) => self.admit_with_tracking(pending?),
|
||||
|
||||
// No inbound rule matched, so allow by default. Track the flow when needed
|
||||
// so its reply is not treated as a new outbound flow
|
||||
None => {
|
||||
self.admit_with_tracking_if_stateful(pending, ipv4_pkt.src_addr(), Direction::Out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snoop(&mut self, frame: &EthernetFrame<&[u8]>) {
|
||||
if frame.ethertype() != EthernetProtocol::Ipv4 {
|
||||
return;
|
||||
|
|
@ -38,11 +119,7 @@ impl Proxy {
|
|||
_ => return,
|
||||
};
|
||||
|
||||
if ipv4_pkt.src_addr() != self.host.gateway_ip {
|
||||
return;
|
||||
}
|
||||
|
||||
if ipv4_pkt.protocol() != smoltcp::wire::IpProtocol::Udp {
|
||||
if !self.is_allowed_dhcp_response(&ipv4_pkt) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -51,10 +128,91 @@ impl Proxy {
|
|||
Err(_) => return,
|
||||
};
|
||||
|
||||
if !udp_pkt.is_dhcp_response() {
|
||||
return;
|
||||
let address_and_dns_ips_saved = self.dhcp_snooper.address_and_dns_ips();
|
||||
self.dhcp_snooper.register_dhcp_reply(udp_pkt.payload());
|
||||
if address_and_dns_ips_saved != self.dhcp_snooper.address_and_dns_ips()
|
||||
&& let Some(flows) = &mut self.flows
|
||||
{
|
||||
flows.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_dhcp_response(&self, ipv4_pkt: &Ipv4Packet<&[u8]>) -> bool {
|
||||
if ipv4_pkt.src_addr() != self.host.gateway_ip
|
||||
|| ipv4_pkt.next_header() != smoltcp::wire::IpProtocol::Udp
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.dhcp_snooper.register_dhcp_reply(udp_pkt.payload());
|
||||
let Ok(udp_pkt) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Require the standard DHCP server and client ports
|
||||
if !udp_pkt.is_dhcp_response() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Require the BOOTP client hardware address to match this VM
|
||||
// (symmetric with is_allowed_dhcp_request / #191 on the VM→host path)
|
||||
let mut decoder = dhcproto::v4::Decoder::new(udp_pkt.payload());
|
||||
let Ok(message) = dhcproto::v4::Message::decode(&mut decoder) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
message_matches_bootp_client(&message, Opcode::BootReply, self.vm_mac_address.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::dhcp_snooper::message_matches_bootp_client;
|
||||
use dhcproto::Decodable;
|
||||
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode};
|
||||
use dhcproto::{Encodable, Encoder};
|
||||
use smoltcp::wire::Ipv4Address;
|
||||
|
||||
const VM_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01];
|
||||
const OTHER_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02];
|
||||
|
||||
#[test]
|
||||
fn dhcp_boot_reply_chaddr_must_match_vm() {
|
||||
let own = encode_boot_reply(VM_MAC);
|
||||
let foreign = encode_boot_reply(OTHER_MAC);
|
||||
|
||||
let mut dec = dhcproto::v4::Decoder::new(&own);
|
||||
let own_msg = Message::decode(&mut dec).unwrap();
|
||||
let mut dec = dhcproto::v4::Decoder::new(&foreign);
|
||||
let foreign_msg = Message::decode(&mut dec).unwrap();
|
||||
|
||||
assert!(message_matches_bootp_client(
|
||||
&own_msg,
|
||||
Opcode::BootReply,
|
||||
VM_MAC
|
||||
));
|
||||
assert!(!message_matches_bootp_client(
|
||||
&foreign_msg,
|
||||
Opcode::BootReply,
|
||||
VM_MAC
|
||||
));
|
||||
}
|
||||
|
||||
fn encode_boot_reply(chaddr: [u8; 6]) -> Vec<u8> {
|
||||
let mut message = Message::new(
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::new(192, 168, 64, 2),
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
&chaddr,
|
||||
);
|
||||
message.set_opcode(Opcode::BootReply);
|
||||
message
|
||||
.opts_mut()
|
||||
.insert(DhcpOption::MessageType(MessageType::Ack));
|
||||
message.opts_mut().insert(DhcpOption::AddressLeaseTime(600));
|
||||
|
||||
let mut encoded = Vec::new();
|
||||
message.encode(&mut Encoder::new(&mut encoded)).unwrap();
|
||||
encoded
|
||||
}
|
||||
}
|
||||
|
|
|
|||
387
lib/proxy/mod.rs
387
lib/proxy/mod.rs
|
|
@ -1,96 +1,443 @@
|
|||
mod control;
|
||||
mod exposed_port;
|
||||
mod flows;
|
||||
mod host;
|
||||
mod port_forwarder;
|
||||
mod rule;
|
||||
mod rules;
|
||||
mod udp_packet_helper;
|
||||
mod vm;
|
||||
|
||||
use crate::dhcp_snooper::DhcpSnooper;
|
||||
use crate::host::Host;
|
||||
use crate::host::NetType;
|
||||
use crate::poller::Poller;
|
||||
use crate::vm::VM;
|
||||
use crate::Error;
|
||||
use crate::Result;
|
||||
use anyhow::Result;
|
||||
use control::{Control, normalize_rules};
|
||||
pub use exposed_port::ExposedPort;
|
||||
use flows::{FlowTable, PendingFlow};
|
||||
use ipnet::Ipv4Net;
|
||||
use mac_address::MacAddress;
|
||||
use smoltcp::wire::EthernetFrame;
|
||||
use port_forwarder::PortForwarder;
|
||||
pub use rule::{Direction, Rule, Target};
|
||||
pub(crate) use rules::{PolicyDecision, Rules};
|
||||
use smoltcp::wire::{EthernetFrame, Ipv4Address};
|
||||
use std::io::ErrorKind;
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
use std::time::Duration;
|
||||
use vmnet::Batch;
|
||||
|
||||
pub struct Proxy {
|
||||
pub struct Proxy<'proxy> {
|
||||
vm: VM,
|
||||
host: Host,
|
||||
poller: Poller,
|
||||
poller: Poller<'proxy>,
|
||||
vm_mac_address: smoltcp::wire::EthernetAddress,
|
||||
dhcp_snooper: DhcpSnooper,
|
||||
rules: Rules,
|
||||
control: Option<Control>,
|
||||
flows: Option<FlowTable>,
|
||||
enobufs_encountered: bool,
|
||||
port_forwarder: PortForwarder,
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub fn new(vm_fd: RawFd, vm_mac_address: MacAddress) -> Result<Proxy> {
|
||||
impl Proxy<'_> {
|
||||
pub fn new<'proxy>(
|
||||
vm_fd: RawFd,
|
||||
vm_mac_address: MacAddress,
|
||||
vm_net_type: NetType,
|
||||
allow: Vec<Rule>,
|
||||
block: Vec<Rule>,
|
||||
exposed_ports: Vec<ExposedPort>,
|
||||
control_fd: Option<RawFd>,
|
||||
) -> Result<Proxy<'proxy>> {
|
||||
let allow = normalize_rules(allow);
|
||||
let block = normalize_rules(block);
|
||||
|
||||
let vm = VM::new(vm_fd)?;
|
||||
let host = Host::new()?;
|
||||
let poller = Poller::new(vm.as_raw_fd(), host.as_raw_fd())?;
|
||||
let host = Host::new(
|
||||
vm_net_type,
|
||||
!allow.contains(&Rule::Stateless(Target::Prefix(Ipv4Net::default()))),
|
||||
)?;
|
||||
let poller_timeout = Duration::from_millis(100);
|
||||
let control = control_fd
|
||||
.map(|control_fd| {
|
||||
Control::new(control_fd, host.gateway_ip, allow.clone(), block.clone())
|
||||
})
|
||||
.transpose()?;
|
||||
let poller = Poller::new(
|
||||
vm.as_raw_fd(),
|
||||
host.as_raw_fd(),
|
||||
control.as_ref().map(AsRawFd::as_raw_fd),
|
||||
poller_timeout,
|
||||
)?;
|
||||
|
||||
let rules = Rules::new(host.gateway_ip, &allow, &block);
|
||||
|
||||
// Any stateful rule enables flow inspection for the whole VM, including
|
||||
// traffic admitted through implicit global, gateway, and DNS fallbacks
|
||||
let flows = rules.has_stateful().then(FlowTable::new);
|
||||
|
||||
Ok(Proxy {
|
||||
vm,
|
||||
host,
|
||||
poller,
|
||||
vm_mac_address: smoltcp::wire::EthernetAddress(vm_mac_address.bytes()),
|
||||
dhcp_snooper: Default::default(),
|
||||
dhcp_snooper: DhcpSnooper::new(poller_timeout, vm_mac_address.bytes()),
|
||||
rules,
|
||||
control,
|
||||
flows,
|
||||
enobufs_encountered: false,
|
||||
port_forwarder: PortForwarder::new(exposed_ports),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run(&mut self) -> Result<()> {
|
||||
// Create a single buffer from reading from the VM
|
||||
let mut buf: Vec<u8> = vec![0; self.host.max_packet_size as usize];
|
||||
|
||||
// Create multiple buffers and a batch for reading from the host
|
||||
let mut bufs = vec![
|
||||
vec![0u8; self.host.max_packet_size as usize];
|
||||
self.host.read_max_packets as usize
|
||||
];
|
||||
let mut batch = Batch::preallocate(bufs.len());
|
||||
|
||||
self.poller.arm()?;
|
||||
|
||||
loop {
|
||||
let (vm_readable, host_readable) = self.poller.wait()?;
|
||||
let (vm_readable, host_readable, interrupt) = self.poller.wait()?;
|
||||
|
||||
// kqueue does not report peer disconnects for Unix datagram sockets.
|
||||
if !self.vm.is_connected()? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Update coarse time for DHCP snooping and flows
|
||||
coarsetime::Instant::update();
|
||||
|
||||
// Service control on every wake (including timeouts) so a bounded read or a pending
|
||||
// response continues making progress even when no new edge is generated.
|
||||
self.service_control();
|
||||
|
||||
if vm_readable {
|
||||
self.read_from_vm(buf.as_mut_slice())?;
|
||||
}
|
||||
|
||||
if host_readable {
|
||||
self.read_from_host(buf.as_mut_slice())?;
|
||||
self.read_from_host(&mut batch, &mut bufs)?;
|
||||
}
|
||||
|
||||
self.poller.rearm()?;
|
||||
// Graceful termination
|
||||
if interrupt {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if !vm_readable && !host_readable && !interrupt {
|
||||
self.port_forwarder
|
||||
.tick(&mut self.host, self.dhcp_snooper.lease());
|
||||
}
|
||||
|
||||
self.poller.rearm();
|
||||
}
|
||||
}
|
||||
|
||||
fn read_from_vm(&mut self, buf: &mut [u8]) -> Result<()> {
|
||||
let mut packets_read = 0;
|
||||
|
||||
loop {
|
||||
match self.vm.read(buf) {
|
||||
Ok(n) => {
|
||||
// Update coarse time for DHCP snooping and flows
|
||||
coarsetime::Instant::update();
|
||||
|
||||
if let Ok(frame) = EthernetFrame::new_checked(&buf[..n]) {
|
||||
self.process_frame_from_vm(frame)?;
|
||||
}
|
||||
|
||||
packets_read += 1;
|
||||
if packets_read == 128 {
|
||||
self.service_control();
|
||||
packets_read = 0;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if err.kind() == ErrorKind::WouldBlock {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(Error::VMIOFailed { source: err });
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_from_host(&mut self, buf: &mut [u8]) -> Result<()> {
|
||||
fn read_from_host(&mut self, batch: &mut Batch, bufs: &mut [Vec<u8>]) -> Result<()> {
|
||||
loop {
|
||||
match self.host.read(buf) {
|
||||
Ok(n) => {
|
||||
if let Ok(pkt) = EthernetFrame::new_checked(&buf[..n]) {
|
||||
self.process_frame_from_host(&pkt)?;
|
||||
match self.host.read(batch, bufs) {
|
||||
Ok(pktcnt) => {
|
||||
// Update coarse time for DHCP snooping and flows
|
||||
coarsetime::Instant::update();
|
||||
|
||||
for buf in batch.packet_sized_bufs(bufs).take(pktcnt) {
|
||||
if let Ok(pkt) = EthernetFrame::new_checked(buf) {
|
||||
self.process_frame_from_host(&pkt)?;
|
||||
}
|
||||
}
|
||||
|
||||
self.service_control();
|
||||
}
|
||||
Err(err) => {
|
||||
if let vmnet::Error::VmnetReadNothing = err {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(Error::HostIOFailed { source: err });
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn service_control(&mut self) {
|
||||
let Some(control) = self.control.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let keep_open = match control.service(&mut self.rules) {
|
||||
Ok(keep_open) => keep_open,
|
||||
Err(err) => {
|
||||
log::warn!("disabling Softnet control socket: {err:#}");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Invalidate tracked flows whenever the policy changes
|
||||
if control.policy_changed() {
|
||||
self.flows = self.rules.has_stateful().then(FlowTable::new);
|
||||
}
|
||||
|
||||
if keep_open {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.poller.remove_control() {
|
||||
log::warn!("failed to remove Softnet control socket from the poller: {err:#}");
|
||||
}
|
||||
|
||||
if let Some(control) = self.control.take()
|
||||
&& let Err(err) = control.shutdown()
|
||||
{
|
||||
log::warn!("failed to shut down Softnet control socket: {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the pending flow, rejecting the packet if the table cannot store it.
|
||||
fn admit_with_tracking(&mut self, pending: PendingFlow) -> Option<()> {
|
||||
self.flows.as_mut()?.commit(pending).then_some(())
|
||||
}
|
||||
|
||||
/// Commits a pending flow for trackable packets; untracked packets proceed without one.
|
||||
fn admit_with_tracking_if_trackable(&mut self, pending: Option<PendingFlow>) -> Option<()> {
|
||||
match pending {
|
||||
Some(pending) => self.admit_with_tracking(pending),
|
||||
None => Some(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits a pending flow when the return-direction rule is stateful.
|
||||
fn admit_with_tracking_if_stateful(
|
||||
&mut self,
|
||||
pending: Option<PendingFlow>,
|
||||
peer_addr: Ipv4Address,
|
||||
return_direction: Direction,
|
||||
) -> Option<()> {
|
||||
if self.rules.is_stateful(peer_addr, return_direction) {
|
||||
self.admit_with_tracking_if_trackable(pending)
|
||||
} else {
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::NetType;
|
||||
use crate::dhcp_snooper::Lease;
|
||||
use crate::proxy::Proxy;
|
||||
use mac_address::MacAddress;
|
||||
use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair};
|
||||
use serial_test::serial;
|
||||
use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, UdpPacket};
|
||||
use std::collections::HashSet;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_blocking_takes_precedence() {
|
||||
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
|
||||
let mut proxy = create_proxy(vm_ip, vec!["66.66.0.0/16"], vec!["66.66.0.0/16"]);
|
||||
|
||||
assert_eq!(proxy.rules.len(), 1);
|
||||
|
||||
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "66.66.66.66").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_longest_prefix_match_wins() {
|
||||
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
|
||||
let mut proxy = create_proxy(vm_ip, vec!["33.33.33.33/32"], vec!["33.33.33.0/24"]);
|
||||
|
||||
assert_eq!(proxy.rules.len(), 2);
|
||||
|
||||
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.32").is_none());
|
||||
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.33").is_some());
|
||||
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.34").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_allow_host() {
|
||||
let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap();
|
||||
let mut proxy = create_proxy(vm_ip, vec!["@host"], vec!["0.0.0.0/0"]);
|
||||
|
||||
assert_eq!(proxy.rules.len(), 2);
|
||||
|
||||
// Access to global IPs should be disallowed because of --block=0.0.0.0/0
|
||||
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "8.8.8.8").is_none());
|
||||
|
||||
// Despite the above, access to host IP address should be possible because of --allow=@host
|
||||
let gateway_ip = proxy.host.gateway_ip.to_string();
|
||||
assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, &gateway_ip).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_bare_default_block_applies_in_both_directions_in_stateful_mode() {
|
||||
let vm_ip = Ipv4Address::new(192, 168, 0, 2);
|
||||
let fallback_peer = Ipv4Address::new(203, 0, 113, 1);
|
||||
let explicitly_allowed_peer = Ipv4Address::new(192, 0, 2, 1);
|
||||
let mut proxy = create_proxy(vm_ip, vec!["in 192.0.2.0/24"], vec!["0.0.0.0/0"]);
|
||||
|
||||
let fallback_request = udp_packet(fallback_peer, 40_000, vm_ip, 1_234);
|
||||
let fallback_request = Ipv4Packet::new_checked(fallback_request.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_host_ipv4(&fallback_request).is_none());
|
||||
|
||||
let fallback_reply = udp_packet(vm_ip, 1_234, fallback_peer, 40_000);
|
||||
let fallback_reply = Ipv4Packet::new_checked(fallback_reply.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_vm_ipv4(fallback_reply).is_none());
|
||||
|
||||
let allowed_request = udp_packet(explicitly_allowed_peer, 40_000, vm_ip, 1_234);
|
||||
let allowed_request = Ipv4Packet::new_checked(allowed_request.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_host_ipv4(&allowed_request).is_some());
|
||||
|
||||
let allowed_reply = udp_packet(vm_ip, 1_234, explicitly_allowed_peer, 40_000);
|
||||
let allowed_reply = Ipv4Packet::new_checked(allowed_reply.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_vm_ipv4(allowed_reply).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_directional_egress_block_does_not_block_reply_to_unmatched_inbound_flow() {
|
||||
let vm_ip = Ipv4Address::new(192, 168, 0, 2);
|
||||
let peer = Ipv4Address::new(203, 0, 113, 1);
|
||||
let mut proxy = create_proxy(vm_ip, vec![], vec!["out 203.0.113.0/24"]);
|
||||
|
||||
let request = udp_packet(peer, 40_000, vm_ip, 1_234);
|
||||
let request = Ipv4Packet::new_checked(request.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_host_ipv4(&request).is_some());
|
||||
|
||||
let reply = udp_packet(vm_ip, 1_234, peer, 40_000);
|
||||
let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_vm_ipv4(reply).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_directional_ingress_block_does_not_block_reply_to_bare_outbound_allow() {
|
||||
let vm_ip = Ipv4Address::new(192, 168, 0, 2);
|
||||
let peer = Ipv4Address::new(203, 0, 113, 1);
|
||||
let mut proxy = create_proxy(vm_ip, vec!["203.0.113.0/24"], vec!["in 203.0.113.0/24"]);
|
||||
|
||||
let request = udp_packet(vm_ip, 1_234, peer, 40_000);
|
||||
let request = Ipv4Packet::new_checked(request.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_vm_ipv4(request).is_some());
|
||||
|
||||
let reply = udp_packet(peer, 40_000, vm_ip, 1_234);
|
||||
let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap();
|
||||
assert!(proxy.allowed_from_host_ipv4(&reply).is_some());
|
||||
}
|
||||
|
||||
fn create_proxy<'test>(vm_ip: Ipv4Address, allow: Vec<&str>, block: Vec<&str>) -> Proxy<'test> {
|
||||
let (vm_fd, _) = socketpair(
|
||||
AddressFamily::Unix,
|
||||
SockType::Datagram,
|
||||
None,
|
||||
SockFlag::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
let vm_fd = Box::leak(Box::new(vm_fd));
|
||||
|
||||
let mut proxy = Proxy::new(
|
||||
vm_fd.as_raw_fd(),
|
||||
MacAddress::from_str("02:00:00:00:00:01").unwrap(),
|
||||
NetType::Nat,
|
||||
allow
|
||||
.into_iter()
|
||||
.map(|value| value.parse().unwrap())
|
||||
.collect(),
|
||||
block
|
||||
.into_iter()
|
||||
.map(|value| value.parse().unwrap())
|
||||
.collect(),
|
||||
Vec::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
proxy.dhcp_snooper.set_lease(Some(Lease::new(
|
||||
vm_ip,
|
||||
Duration::from_secs(600),
|
||||
HashSet::new(),
|
||||
)));
|
||||
|
||||
proxy
|
||||
}
|
||||
|
||||
fn allowed_from_vm_ipv4(proxy: &mut Proxy, src: Ipv4Address, dst: &str) -> Option<()> {
|
||||
let mut buf = vec![0; 1500];
|
||||
|
||||
let mut ipv4_pkt_mut = Ipv4Packet::new_unchecked(&mut buf[..]);
|
||||
ipv4_pkt_mut.set_src_addr(src);
|
||||
ipv4_pkt_mut.set_dst_addr(Ipv4Address::from_str(dst).unwrap());
|
||||
|
||||
let ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_slice());
|
||||
|
||||
proxy.allowed_from_vm_ipv4(ipv4_pkt)
|
||||
}
|
||||
|
||||
fn udp_packet(
|
||||
src_addr: Ipv4Address,
|
||||
src_port: u16,
|
||||
dst_addr: Ipv4Address,
|
||||
dst_port: u16,
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 28];
|
||||
let mut ipv4 = Ipv4Packet::new_unchecked(bytes.as_mut_slice());
|
||||
ipv4.set_version(4);
|
||||
ipv4.set_header_len(20);
|
||||
ipv4.set_total_len(28);
|
||||
ipv4.set_next_header(IpProtocol::Udp);
|
||||
ipv4.set_src_addr(src_addr);
|
||||
ipv4.set_dst_addr(dst_addr);
|
||||
|
||||
let mut udp = UdpPacket::new_unchecked(ipv4.payload_mut());
|
||||
udp.set_src_port(src_port);
|
||||
udp.set_dst_port(dst_port);
|
||||
udp.set_len(8);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
use crate::dhcp_snooper::Lease;
|
||||
use crate::host::Host;
|
||||
use crate::proxy::exposed_port::ExposedPort;
|
||||
use anyhow::Result;
|
||||
use log::error;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PortForwarder {
|
||||
port_forwardings: Vec<PortForwarding>,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct PortForwarding {
|
||||
exposed_port: ExposedPort,
|
||||
forwarding_to_addr: Option<Ipv4Addr>,
|
||||
}
|
||||
|
||||
impl PortForwarder {
|
||||
pub fn new(exposed_ports: Vec<ExposedPort>) -> PortForwarder {
|
||||
let port_forwardings = exposed_ports
|
||||
.into_iter()
|
||||
.map(|exposed_port| PortForwarding {
|
||||
exposed_port,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
PortForwarder {
|
||||
port_forwardings,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, host: &mut Host, lease: &Option<Lease>) {
|
||||
if self.failed {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = self.tick_inner(host, lease) {
|
||||
error!("port-forwarding failed: {}", err);
|
||||
|
||||
self.failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_inner(&mut self, host: &mut Host, lease: &Option<Lease>) -> Result<()> {
|
||||
if let Some(lease) = lease {
|
||||
// Lease exists, but is not valid, remove all port forwardings
|
||||
if !lease.valid() {
|
||||
self.remove_all_port_forwardings(host)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Lease exists and is valid, install/re-install port forwardings
|
||||
for port_forwarding in &mut self.port_forwardings {
|
||||
if let Some(installed_addr) = port_forwarding.forwarding_to_addr {
|
||||
// Port forwarding already installed, perhaps it's outdated?
|
||||
if installed_addr == lease.address() {
|
||||
// Nope, the port forwarding is up to date
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove port forwarding since the lease address had changed
|
||||
host.port_forwarding_remove_rule(port_forwarding.exposed_port.external_port)?;
|
||||
port_forwarding.forwarding_to_addr = None;
|
||||
}
|
||||
|
||||
// Install new port forwarding
|
||||
host.port_forwarding_add_rule(
|
||||
port_forwarding.exposed_port.external_port,
|
||||
lease.address(),
|
||||
port_forwarding.exposed_port.internal_port,
|
||||
)?;
|
||||
port_forwarding.forwarding_to_addr = Some(lease.address());
|
||||
}
|
||||
} else {
|
||||
// Lease does not exist, remove all port forwardings
|
||||
self.remove_all_port_forwardings(host)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_all_port_forwardings(&mut self, host: &mut Host) -> Result<()> {
|
||||
for port_forwarding in &mut self.port_forwardings {
|
||||
if port_forwarding.forwarding_to_addr.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
host.port_forwarding_remove_rule(port_forwarding.exposed_port.external_port)?;
|
||||
port_forwarding.forwarding_to_addr = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
use ipnet::Ipv4Net;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Rule {
|
||||
Stateless(Target),
|
||||
Stateful {
|
||||
direction: Direction,
|
||||
target: Target,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
Prefix(Ipv4Net),
|
||||
Host,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub(super) fn normalized(self) -> Self {
|
||||
match self {
|
||||
Rule::Stateless(target) => Rule::Stateless(target.normalized()),
|
||||
Rule::Stateful { direction, target } => Rule::Stateful {
|
||||
direction,
|
||||
target: target.normalized(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Rule {
|
||||
type Err = ipnet::AddrParseError;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
let (direction, target) = if let Some(target) = input.strip_prefix("in ") {
|
||||
(Direction::In, target)
|
||||
} else if let Some(target) = input.strip_prefix("out ") {
|
||||
(Direction::Out, target)
|
||||
} else {
|
||||
return input.parse().map(Rule::Stateless);
|
||||
};
|
||||
|
||||
let target = target.trim_start_matches(' ').parse()?;
|
||||
Ok(Rule::Stateful { direction, target })
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Rule {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Rule::Stateless(target) => Display::fmt(target, formatter),
|
||||
Rule::Stateful { direction, target } => match direction {
|
||||
Direction::In => write!(formatter, "in {target}"),
|
||||
Direction::Out => write!(formatter, "out {target}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Target {
|
||||
fn normalized(self) -> Self {
|
||||
match self {
|
||||
Target::Prefix(prefix) => Target::Prefix(prefix.trunc()),
|
||||
Target::Host => Target::Host,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Target {
|
||||
type Err = ipnet::AddrParseError;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
if input == "@host" {
|
||||
Ok(Target::Host)
|
||||
} else {
|
||||
input.parse().map(Target::Prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Target {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Target::Prefix(prefix) => Display::fmt(prefix, formatter),
|
||||
Target::Host => formatter.write_str("@host"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Direction, Rule, Target};
|
||||
use ipnet::Ipv4Net;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn parses_stateless_target() {
|
||||
assert_eq!(
|
||||
"@host".parse::<Rule>().unwrap(),
|
||||
Rule::Stateless(Target::Host)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stateful_directions() {
|
||||
let private_network = Target::Prefix(Ipv4Net::from_str("10.0.0.0/8").unwrap());
|
||||
|
||||
assert_eq!(
|
||||
"in @host".parse::<Rule>().unwrap(),
|
||||
Rule::Stateful {
|
||||
direction: Direction::In,
|
||||
target: Target::Host,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
"out 10.0.0.0/8".parse::<Rule>().unwrap(),
|
||||
Rule::Stateful {
|
||||
direction: Direction::Out,
|
||||
target: private_network,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displays_normalized_rules() {
|
||||
for (input, expected) in [("in 10.1.2.3/8", "in 10.0.0.0/8"), ("@host", "@host")] {
|
||||
assert_eq!(
|
||||
input.parse::<Rule>().unwrap().normalized().to_string(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_rules() {
|
||||
for input in [
|
||||
"",
|
||||
"from @host",
|
||||
"in",
|
||||
"out",
|
||||
"in from @host",
|
||||
"out to @host",
|
||||
"infrom @host",
|
||||
" in @host",
|
||||
"in @host ",
|
||||
"in\t@host",
|
||||
] {
|
||||
assert!(input.parse::<Rule>().is_err(), "{input:?} should fail");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
use super::{Direction, Rule, Target};
|
||||
use ipnet::Ipv4Net;
|
||||
use prefix_trie::PrefixMap;
|
||||
use smoltcp::wire::Ipv4Address;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Action {
|
||||
Block,
|
||||
Allow,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PolicyDecision {
|
||||
Block,
|
||||
AllowStateless,
|
||||
AllowStateful,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
#[default]
|
||||
Legacy,
|
||||
Stateful,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Rules {
|
||||
mode: Mode,
|
||||
inbound: PrefixMap<Ipv4Net, Action>,
|
||||
outbound: PrefixMap<Ipv4Net, Action>,
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
pub(crate) fn new(host_address: Ipv4Address, allow: &[Rule], block: &[Rule]) -> Self {
|
||||
// Preserve legacy behavior for bare-only policies. Once a directional rule
|
||||
// is present, compile the whole policy using directional semantics.
|
||||
let mode = if allow
|
||||
.iter()
|
||||
.chain(block)
|
||||
.any(|rule| matches!(rule, Rule::Stateful { .. }))
|
||||
{
|
||||
Mode::Stateful
|
||||
} else {
|
||||
Mode::Legacy
|
||||
};
|
||||
|
||||
let mut rules = Self {
|
||||
mode,
|
||||
..Self::default()
|
||||
};
|
||||
|
||||
for &rule in allow {
|
||||
rules.insert(rule, Action::Allow, host_address);
|
||||
}
|
||||
|
||||
// SECURITY: blocking rules must always take precedence
|
||||
// over allowing rules when the rules are identical.
|
||||
for &rule in block {
|
||||
rules.insert(rule, Action::Block, host_address);
|
||||
}
|
||||
|
||||
rules
|
||||
}
|
||||
|
||||
pub(crate) fn policy_decision(
|
||||
&self,
|
||||
address: Ipv4Address,
|
||||
direction: Direction,
|
||||
) -> Option<PolicyDecision> {
|
||||
match (self.select(address, direction)?, self.mode) {
|
||||
(Action::Block, _) => Some(PolicyDecision::Block),
|
||||
(Action::Allow, Mode::Legacy) => Some(PolicyDecision::AllowStateless),
|
||||
(Action::Allow, Mode::Stateful) => Some(PolicyDecision::AllowStateful),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_stateful(&self, address: Ipv4Address, direction: Direction) -> bool {
|
||||
self.mode == Mode::Stateful && self.select(address, direction).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.inbound.len() + self.outbound.len()
|
||||
}
|
||||
|
||||
pub(crate) fn has_stateful(&self) -> bool {
|
||||
self.mode == Mode::Stateful
|
||||
}
|
||||
|
||||
fn select(&self, address: Ipv4Address, direction: Direction) -> Option<Action> {
|
||||
let entries = match direction {
|
||||
Direction::In => &self.inbound,
|
||||
Direction::Out => &self.outbound,
|
||||
};
|
||||
|
||||
entries
|
||||
.get_lpm(&Ipv4Net::from(address))
|
||||
.map(|(_, action)| *action)
|
||||
}
|
||||
|
||||
fn insert(&mut self, rule: Rule, action: Action, host_address: Ipv4Address) {
|
||||
match rule {
|
||||
Rule::Stateless(target) => {
|
||||
// Bare rules apply in both directions in stateful mode
|
||||
if self.mode == Mode::Stateful {
|
||||
self.insert_direction(Direction::In, target, action, host_address);
|
||||
}
|
||||
|
||||
self.insert_direction(Direction::Out, target, action, host_address);
|
||||
}
|
||||
Rule::Stateful { direction, target } => {
|
||||
self.insert_direction(direction, target, action, host_address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_direction(
|
||||
&mut self,
|
||||
direction: Direction,
|
||||
target: Target,
|
||||
action: Action,
|
||||
host_address: Ipv4Address,
|
||||
) {
|
||||
let prefix = match target {
|
||||
Target::Prefix(prefix) => prefix,
|
||||
Target::Host => host_address.into(),
|
||||
};
|
||||
let entries = match direction {
|
||||
Direction::In => &mut self.inbound,
|
||||
Direction::Out => &mut self.outbound,
|
||||
};
|
||||
|
||||
entries.insert(prefix, action);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Action, Mode, PolicyDecision, Rules};
|
||||
use crate::proxy::Direction;
|
||||
use smoltcp::wire::Ipv4Address;
|
||||
|
||||
const HOST: Ipv4Address = Ipv4Address::new(192, 168, 64, 1);
|
||||
|
||||
fn stateful_rules() -> Rules {
|
||||
Rules {
|
||||
mode: Mode::Stateful,
|
||||
..Rules::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_precedence() {
|
||||
let target = Ipv4Address::new(10, 0, 0, 1);
|
||||
let mut rules = stateful_rules();
|
||||
|
||||
rules.insert("0.0.0.0/0".parse().unwrap(), Action::Block, HOST);
|
||||
rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Allow, HOST);
|
||||
|
||||
assert_eq!(
|
||||
rules.policy_decision(target, Direction::In),
|
||||
Some(PolicyDecision::AllowStateful)
|
||||
);
|
||||
assert_eq!(
|
||||
rules.policy_decision(target, Direction::Out),
|
||||
Some(PolicyDecision::Block)
|
||||
);
|
||||
|
||||
rules.insert("10.0.0.1/32".parse().unwrap(), Action::Allow, HOST);
|
||||
assert_eq!(
|
||||
rules.policy_decision(target, Direction::Out),
|
||||
Some(PolicyDecision::AllowStateful)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_rules_at_same_prefix_are_independent() {
|
||||
let mut rules = stateful_rules();
|
||||
|
||||
for (target, action) in [
|
||||
("in @host", Action::Allow),
|
||||
("out @host", Action::Allow),
|
||||
("in @host", Action::Block),
|
||||
] {
|
||||
rules.insert(target.parse().unwrap(), action, HOST);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
rules.policy_decision(HOST, Direction::In),
|
||||
Some(PolicyDecision::Block)
|
||||
);
|
||||
assert_eq!(
|
||||
rules.policy_decision(HOST, Direction::Out),
|
||||
Some(PolicyDecision::AllowStateful)
|
||||
);
|
||||
assert_eq!(rules.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stateless_rules_are_outbound_only() {
|
||||
let target = Ipv4Address::new(10, 1, 2, 3);
|
||||
let mut rules = Rules::default();
|
||||
|
||||
rules.insert("10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
|
||||
|
||||
assert!(rules.policy_decision(target, Direction::In).is_none());
|
||||
assert_eq!(
|
||||
rules.policy_decision(target, Direction::Out),
|
||||
Some(PolicyDecision::Block)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inbound_selection_uses_more_specific_bare_rule() {
|
||||
let target = Ipv4Address::new(10, 1, 2, 3);
|
||||
let mut rules = stateful_rules();
|
||||
|
||||
rules.insert("10.1.0.0/16".parse().unwrap(), Action::Allow, HOST);
|
||||
rules.insert("in 10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
|
||||
|
||||
assert_eq!(
|
||||
rules.policy_decision(target, Direction::In),
|
||||
Some(PolicyDecision::AllowStateful)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_block_wins_over_allow_at_same_outbound_prefix() {
|
||||
let target = Ipv4Address::new(10, 1, 2, 3);
|
||||
|
||||
for (allow, block) in [
|
||||
("10.0.0.0/8", "out 10.0.0.0/8"),
|
||||
("out 10.0.0.0/8", "10.0.0.0/8"),
|
||||
] {
|
||||
let mut rules = stateful_rules();
|
||||
rules.insert(allow.parse().unwrap(), Action::Allow, HOST);
|
||||
rules.insert(block.parse().unwrap(), Action::Block, HOST);
|
||||
|
||||
assert_eq!(
|
||||
rules.policy_decision(target, Direction::Out),
|
||||
Some(PolicyDecision::Block)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_rule_makes_bare_rules_stateful() {
|
||||
let allow = "out @host".parse().unwrap();
|
||||
let block = "0.0.0.0/0".parse().unwrap();
|
||||
let rules = Rules::new(HOST, &[allow], &[block]);
|
||||
|
||||
assert_eq!(rules.len(), 3);
|
||||
assert!(rules.has_stateful());
|
||||
assert_eq!(
|
||||
rules.policy_decision(HOST, Direction::In),
|
||||
Some(PolicyDecision::Block)
|
||||
);
|
||||
assert_eq!(
|
||||
rules.policy_decision(HOST, Direction::Out),
|
||||
Some(PolicyDecision::AllowStateful)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_return_tracking_applies_to_all_rules_in_stateful_mode() {
|
||||
let stateful_target = Ipv4Address::new(10, 1, 2, 3);
|
||||
let stateless_target = Ipv4Address::new(192, 0, 2, 1);
|
||||
let mut rules = stateful_rules();
|
||||
|
||||
rules.insert("0.0.0.0/0".parse().unwrap(), Action::Block, HOST);
|
||||
rules.insert("out 10.0.0.0/8".parse().unwrap(), Action::Block, HOST);
|
||||
|
||||
assert!(rules.is_stateful(stateful_target, Direction::Out));
|
||||
assert!(rules.is_stateful(stateless_target, Direction::Out));
|
||||
assert!(rules.is_stateful(stateful_target, Direction::In));
|
||||
|
||||
rules.insert("10.0.0.0/8".parse().unwrap(), Action::Allow, HOST);
|
||||
assert!(rules.is_stateful(stateful_target, Direction::Out));
|
||||
}
|
||||
}
|
||||
|
|
@ -17,10 +17,50 @@ impl UdpPacketHelper for UdpPacket<&[u8]> {
|
|||
}
|
||||
|
||||
fn is_dhcp_request(&self) -> bool {
|
||||
self.src_port() == Self::BOOTPC_PORT || self.dst_port() == Self::BOOTPS_PORT
|
||||
self.src_port() == Self::BOOTPC_PORT && self.dst_port() == Self::BOOTPS_PORT
|
||||
}
|
||||
|
||||
fn is_dhcp_response(&self) -> bool {
|
||||
self.src_port() == Self::BOOTPS_PORT || self.dst_port() == Self::BOOTPC_PORT
|
||||
self.src_port() == Self::BOOTPS_PORT && self.dst_port() == Self::BOOTPC_PORT
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UdpPacketHelper;
|
||||
use smoltcp::wire::UdpPacket;
|
||||
|
||||
#[test]
|
||||
fn test_is_dhcp_request_requires_both_standard_ports() {
|
||||
assert!(is_dhcp_request(68, 67));
|
||||
assert!(!is_dhcp_request(68, 9999));
|
||||
assert!(!is_dhcp_request(9999, 67));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_dhcp_response_requires_both_standard_ports() {
|
||||
assert!(is_dhcp_response(67, 68));
|
||||
assert!(!is_dhcp_response(67, 9999));
|
||||
assert!(!is_dhcp_response(9999, 68));
|
||||
}
|
||||
|
||||
fn is_dhcp_request(src_port: u16, dst_port: u16) -> bool {
|
||||
let buffer = udp_packet_buffer(src_port, dst_port);
|
||||
let udp_pkt = UdpPacket::new_unchecked(&buffer[..]);
|
||||
udp_pkt.is_dhcp_request()
|
||||
}
|
||||
|
||||
fn is_dhcp_response(src_port: u16, dst_port: u16) -> bool {
|
||||
let buffer = udp_packet_buffer(src_port, dst_port);
|
||||
let udp_pkt = UdpPacket::new_unchecked(&buffer[..]);
|
||||
udp_pkt.is_dhcp_response()
|
||||
}
|
||||
|
||||
fn udp_packet_buffer(src_port: u16, dst_port: u16) -> [u8; 8] {
|
||||
let mut buffer = [0; 8];
|
||||
let mut udp_pkt = UdpPacket::new_unchecked(&mut buffer[..]);
|
||||
udp_pkt.set_src_port(src_port);
|
||||
udp_pkt.set_dst_port(dst_port);
|
||||
buffer
|
||||
}
|
||||
}
|
||||
|
|
|
|||
416
lib/proxy/vm.rs
416
lib/proxy/vm.rs
|
|
@ -1,12 +1,20 @@
|
|||
use crate::dhcp_snooper::{Lease, message_matches_bootp_client};
|
||||
use crate::proxy::flows::{FlowDirection, FlowMatch};
|
||||
use crate::proxy::udp_packet_helper::UdpPacketHelper;
|
||||
use crate::proxy::Proxy;
|
||||
use crate::{Error, Result};
|
||||
use crate::proxy::{Direction, PolicyDecision, Proxy};
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use dhcproto::Decodable;
|
||||
use dhcproto::v4::Opcode;
|
||||
use smoltcp::phy::ChecksumCapabilities;
|
||||
use smoltcp::wire::{
|
||||
ArpPacket, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Packet, UdpPacket,
|
||||
ArpOperation, ArpPacket, ArpRepr, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Address,
|
||||
Ipv4Packet, Ipv4Repr, UdpPacket,
|
||||
};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
impl Proxy {
|
||||
const IPV4_HEADER_LEN_WITHOUT_OPTIONS: u8 = 20;
|
||||
|
||||
impl Proxy<'_> {
|
||||
pub(crate) fn process_frame_from_vm(&mut self, frame: EthernetFrame<&[u8]>) -> Result<()> {
|
||||
if self.allowed_from_vm(&frame).is_none() {
|
||||
// Block packet by not forwarding it to the host
|
||||
|
|
@ -16,10 +24,10 @@ impl Proxy {
|
|||
self.host
|
||||
.write(frame.as_ref())
|
||||
.map(|_| ())
|
||||
.map_err(|err| Error::HostIOFailed { source: err })
|
||||
.context("failed to write to the host")
|
||||
}
|
||||
|
||||
fn allowed_from_vm(&self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
|
||||
fn allowed_from_vm(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> {
|
||||
if frame.src_addr() != self.vm_mac_address {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -30,7 +38,14 @@ impl Proxy {
|
|||
self.allowed_from_vm_arp(arp_pkt)
|
||||
}
|
||||
EthernetProtocol::Ipv4 => {
|
||||
let ipv4_pkt = Ipv4Packet::new_checked(frame.payload()).ok()?;
|
||||
let ipv4_pkt = Ipv4Packet::new_unchecked(frame.payload());
|
||||
Ipv4Repr::parse(&ipv4_pkt, &ChecksumCapabilities::ignored()).ok()?;
|
||||
|
||||
// Reject IPv4 options because source routing could bypass destination-based policy
|
||||
if ipv4_pkt.header_len() != IPV4_HEADER_LEN_WITHOUT_OPTIONS {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.allowed_from_vm_ipv4(ipv4_pkt)
|
||||
}
|
||||
_ => None,
|
||||
|
|
@ -38,56 +53,363 @@ impl Proxy {
|
|||
}
|
||||
|
||||
fn allowed_from_vm_arp(&self, arp_pkt: ArpPacket<&[u8]>) -> Option<()> {
|
||||
if arp_pkt.source_hardware_addr() != self.vm_mac_address.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let source_protocol_addr: [u8; 4] = arp_pkt.source_protocol_addr().try_into().unwrap();
|
||||
let source_protocol_addr = Ipv4Addr::from(source_protocol_addr);
|
||||
|
||||
if let Some(lease) = self.dhcp_snooper.lease() {
|
||||
if lease.valid_ip_source(source_protocol_addr.into()) {
|
||||
return Some(());
|
||||
}
|
||||
} else if source_protocol_addr.is_unspecified() {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
None
|
||||
vm_arp_allowed(arp_pkt, self.vm_mac_address, self.dhcp_snooper.lease())
|
||||
}
|
||||
|
||||
fn allowed_from_vm_ipv4(&self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> {
|
||||
// Once we've learned the VM's IP from the DHCP snooping,
|
||||
// allow all global traffic for that VM's IP
|
||||
if let Some(lease) = &self.dhcp_snooper.lease() {
|
||||
let dst_is_global =
|
||||
ip_network::IpNetwork::from(Ipv4Addr::from(ipv4_pkt.dst_addr().0)).is_global();
|
||||
|
||||
if lease.valid_ip_source(ipv4_pkt.src_addr()) && dst_is_global {
|
||||
pub(crate) fn allowed_from_vm_ipv4(&mut self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> {
|
||||
// Is this packet coming from VM's IP address that we've learned from DHCP snooping?
|
||||
if let Some(lease) = &self.dhcp_snooper.lease()
|
||||
&& lease.is_valid_for(ipv4_pkt.src_addr())
|
||||
{
|
||||
// Unicast DHCP renewal is required to maintain the VM's lease
|
||||
// and must bypass user-specified rules
|
||||
if is_allowed_dhcp_request(
|
||||
&ipv4_pkt,
|
||||
Some(self.host.gateway_ip),
|
||||
self.vm_mac_address,
|
||||
self.dhcp_snooper.lease(),
|
||||
) {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
|
||||
// Allow communication with host
|
||||
if ipv4_pkt.dst_addr() == self.host.gateway_ip {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
if ipv4_pkt.protocol() == IpProtocol::Udp {
|
||||
let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?;
|
||||
|
||||
// Allow DNS communication with the DNS-servers provided by DHCP
|
||||
if udp_pkt.is_dns_request() && self.dhcp_snooper.valid_dns_target(&ipv4_pkt.dst_addr())
|
||||
// Consult the flow table before evaluating outbound policy
|
||||
// so established flows are not treated as new traffic
|
||||
let pending = match self
|
||||
.flows
|
||||
.as_mut()
|
||||
.map(|flows| flows.inspect(&ipv4_pkt, FlowDirection::FromVm))
|
||||
.unwrap_or(FlowMatch::Untracked)
|
||||
{
|
||||
return Some(());
|
||||
FlowMatch::Allowed => return Some(()),
|
||||
FlowMatch::Denied => return None,
|
||||
FlowMatch::Candidate(pending) => Some(pending),
|
||||
FlowMatch::Untracked => None,
|
||||
};
|
||||
|
||||
// The flow is either pending or untracked, evaluate it against outbound policy
|
||||
let dst_addr = ipv4_pkt.dst_addr();
|
||||
|
||||
match self.rules.policy_decision(dst_addr, Direction::Out) {
|
||||
// Return traffic was handled above; enforce explicit outbound blocks here
|
||||
Some(PolicyDecision::Block) => return None,
|
||||
|
||||
// Track statelessly allowed traffic only when needed so its reply is not
|
||||
// treated as a new inbound flow
|
||||
Some(PolicyDecision::AllowStateless) => {
|
||||
return self.admit_with_tracking_if_stateful(pending, dst_addr, Direction::In);
|
||||
}
|
||||
|
||||
// Untracked packets cannot satisfy stateful policy
|
||||
Some(PolicyDecision::AllowStateful) => return self.admit_with_tracking(pending?),
|
||||
|
||||
// No outbound rule matched; apply the built-in fallbacks below
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Allow DHCP communication with the bootpd(8) on host via broadcast address
|
||||
if udp_pkt.is_dhcp_request() && ipv4_pkt.dst_addr().is_broadcast() {
|
||||
return Some(());
|
||||
// When no user-specified rules matched, simply allow all global traffic
|
||||
if ip_network::IpNetwork::from(dst_addr).is_global() {
|
||||
return self.admit_with_tracking_if_trackable(pending);
|
||||
}
|
||||
|
||||
// Additionally, allow communication with the host,
|
||||
// otherwise things like SSH to a VM won't work
|
||||
if dst_addr == self.host.gateway_ip {
|
||||
return self.admit_with_tracking_if_trackable(pending);
|
||||
}
|
||||
|
||||
// Additionally, allow DNS requests to DNS-servers
|
||||
// provided to a VM by the host's DHCP server
|
||||
if ipv4_pkt.next_header() == IpProtocol::Udp {
|
||||
let udp_pkt = UdpPacket::new_checked(ipv4_pkt.payload()).ok()?;
|
||||
|
||||
if udp_pkt.is_dns_request() && self.dhcp_snooper.valid_dns_target(&dst_addr) {
|
||||
return self.admit_with_tracking_if_trackable(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow outgoing DHCP requests to the bootpd(8) broadcast address,
|
||||
// otherwise DHCP snooper will never be populated
|
||||
if is_allowed_dhcp_request(
|
||||
&ipv4_pkt,
|
||||
None,
|
||||
self.vm_mac_address,
|
||||
self.dhcp_snooper.lease(),
|
||||
) {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_dhcp_request(
|
||||
ipv4_pkt: &Ipv4Packet<&[u8]>,
|
||||
unicast_target: Option<Ipv4Address>,
|
||||
vm_mac_address: smoltcp::wire::EthernetAddress,
|
||||
lease: &Option<Lease>,
|
||||
) -> bool {
|
||||
// Require the source address to be either:
|
||||
// * covered by the VM's current lease
|
||||
// * unspecified on the broadcast DHCP path
|
||||
let src_addr = ipv4_pkt.src_addr();
|
||||
let src_has_valid_lease = lease
|
||||
.as_ref()
|
||||
.is_some_and(|lease| lease.is_valid_for(src_addr));
|
||||
if !src_has_valid_lease && !(unicast_target.is_none() && src_addr.is_unspecified()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let dst_addr = ipv4_pkt.dst_addr();
|
||||
|
||||
// Keep the common path cheap and inspect UDP only for a permitted DHCP target
|
||||
if !dst_addr.is_broadcast() && unicast_target != Some(dst_addr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ipv4_pkt.next_header() != IpProtocol::Udp {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok(udp_pkt) = UdpPacket::new_checked(ipv4_pkt.payload()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Require the standard DHCP client and server ports
|
||||
if !udp_pkt.is_dhcp_request() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Require the BOOTP client hardware address to match this VM
|
||||
let mut decoder = dhcproto::v4::Decoder::new(udp_pkt.payload());
|
||||
let Ok(message) = dhcproto::v4::Message::decode(&mut decoder) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
message_matches_bootp_client(&message, Opcode::BootRequest, vm_mac_address.0)
|
||||
}
|
||||
|
||||
fn vm_arp_allowed(
|
||||
arp_pkt: ArpPacket<&[u8]>,
|
||||
vm_mac_address: smoltcp::wire::EthernetAddress,
|
||||
lease: &Option<Lease>,
|
||||
) -> Option<()> {
|
||||
let (operation, source_hardware_addr, source_protocol_addr) =
|
||||
match ArpRepr::parse(&arp_pkt).ok()? {
|
||||
ArpRepr::EthernetIpv4 {
|
||||
operation,
|
||||
source_hardware_addr,
|
||||
source_protocol_addr,
|
||||
..
|
||||
} => (operation, source_hardware_addr, source_protocol_addr),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if !matches!(operation, ArpOperation::Request | ArpOperation::Reply) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if source_hardware_addr != vm_mac_address {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(lease) = lease {
|
||||
if lease.is_valid_for(source_protocol_addr) {
|
||||
return Some(());
|
||||
}
|
||||
} else if source_protocol_addr.is_unspecified() {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::dhcp_snooper::Lease;
|
||||
use dhcproto::v4::{DhcpOption, Message, MessageType};
|
||||
use dhcproto::{Encodable, Encoder};
|
||||
use smoltcp::wire::{
|
||||
ArpHardware, ArpOperation, ArpPacket, EthernetAddress, EthernetProtocol, IpProtocol,
|
||||
Ipv4Address, Ipv4Packet, UdpPacket,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
const VM_MAC: EthernetAddress = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
|
||||
#[test]
|
||||
fn test_allowed_dhcp_request_policy() {
|
||||
let gateway = Ipv4Address::new(192, 168, 64, 1);
|
||||
let lease_ip = Ipv4Address::new(192, 168, 64, 2);
|
||||
let other = Ipv4Address::new(192, 168, 64, 3);
|
||||
let no_lease = None;
|
||||
let lease = Some(Lease::new(
|
||||
lease_ip,
|
||||
Duration::from_secs(600),
|
||||
HashSet::new(),
|
||||
));
|
||||
let initial = |src, chaddr| {
|
||||
allowed_dhcp_request(src, Ipv4Address::BROADCAST, None, chaddr, &no_lease)
|
||||
};
|
||||
let renewal = |src, dst| allowed_dhcp_request(src, dst, Some(gateway), VM_MAC.0, &lease);
|
||||
let other_mac = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02];
|
||||
|
||||
assert!(initial(Ipv4Address::UNSPECIFIED, VM_MAC.0));
|
||||
assert!(renewal(lease_ip, gateway));
|
||||
assert!(!renewal(other, gateway));
|
||||
assert!(!renewal(Ipv4Address::UNSPECIFIED, gateway));
|
||||
assert!(!renewal(lease_ip, other));
|
||||
assert!(!initial(Ipv4Address::UNSPECIFIED, other_mac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_from_vm_arp_allows_unspecified_request_without_lease() {
|
||||
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
let buf = arp_packet(vm_mac_address.0, [0, 0, 0, 0], ArpOperation::Request, 6, 4);
|
||||
let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap();
|
||||
|
||||
assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_from_vm_arp_allows_reply_for_leased_ip() {
|
||||
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
let lease_ip = Ipv4Address::new(192, 168, 0, 2);
|
||||
let lease = Some(Lease::new(
|
||||
lease_ip,
|
||||
Duration::from_secs(600),
|
||||
HashSet::new(),
|
||||
));
|
||||
let buf = arp_packet(
|
||||
vm_mac_address.0,
|
||||
lease_ip.octets(),
|
||||
ArpOperation::Reply,
|
||||
6,
|
||||
4,
|
||||
);
|
||||
let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap();
|
||||
|
||||
assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &lease).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_from_vm_arp_rejects_unknown_operation() {
|
||||
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
let buf = arp_packet(
|
||||
vm_mac_address.0,
|
||||
[0, 0, 0, 0],
|
||||
ArpOperation::Unknown(3),
|
||||
6,
|
||||
4,
|
||||
);
|
||||
let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap();
|
||||
|
||||
assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_from_vm_arp_rejects_non_ethernet_hardware_type() {
|
||||
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
let mut buf = arp_packet(vm_mac_address.0, [0, 0, 0, 0], ArpOperation::Request, 6, 4);
|
||||
let mut arp_pkt = ArpPacket::new_unchecked(buf.as_mut_slice());
|
||||
arp_pkt.set_hardware_type(ArpHardware::Unknown(2));
|
||||
let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap();
|
||||
|
||||
assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_from_vm_arp_rejects_non_ipv4_protocol_type() {
|
||||
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
let mut buf = arp_packet(vm_mac_address.0, [0, 0, 0, 0], ArpOperation::Request, 6, 4);
|
||||
let mut arp_pkt = ArpPacket::new_unchecked(buf.as_mut_slice());
|
||||
arp_pkt.set_protocol_type(EthernetProtocol::Ipv6);
|
||||
let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap();
|
||||
|
||||
assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_from_vm_arp_rejects_non_ipv4_protocol_length() {
|
||||
let vm_mac_address = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
let buf = arp_packet(vm_mac_address.0, [0, 0, 0], ArpOperation::Request, 6, 3);
|
||||
let arp_pkt = ArpPacket::new_checked(buf.as_slice()).unwrap();
|
||||
|
||||
assert!(super::vm_arp_allowed(arp_pkt, vm_mac_address, &None).is_none());
|
||||
}
|
||||
|
||||
fn arp_packet(
|
||||
source_hardware_addr: [u8; 6],
|
||||
source_protocol_addr: impl AsRef<[u8]>,
|
||||
operation: ArpOperation,
|
||||
hardware_len: u8,
|
||||
protocol_len: u8,
|
||||
) -> Vec<u8> {
|
||||
let source_protocol_addr = source_protocol_addr.as_ref();
|
||||
let payload_len = 8 + 2 * (hardware_len as usize + protocol_len as usize);
|
||||
let mut buf = vec![0; payload_len];
|
||||
let mut arp_pkt = ArpPacket::new_unchecked(buf.as_mut_slice());
|
||||
arp_pkt.set_hardware_type(ArpHardware::Ethernet);
|
||||
arp_pkt.set_protocol_type(EthernetProtocol::Ipv4);
|
||||
arp_pkt.set_hardware_len(hardware_len);
|
||||
arp_pkt.set_protocol_len(protocol_len);
|
||||
arp_pkt.set_operation(operation);
|
||||
arp_pkt.set_source_hardware_addr(&source_hardware_addr[..hardware_len as usize]);
|
||||
arp_pkt.set_source_protocol_addr(source_protocol_addr);
|
||||
arp_pkt.set_target_hardware_addr(&[0; 6][..hardware_len as usize]);
|
||||
arp_pkt.set_target_protocol_addr(&vec![0; protocol_len as usize]);
|
||||
buf
|
||||
}
|
||||
|
||||
fn allowed_dhcp_request(
|
||||
src_addr: Ipv4Address,
|
||||
dst_addr: Ipv4Address,
|
||||
unicast_target: Option<Ipv4Address>,
|
||||
chaddr: [u8; 6],
|
||||
lease: &Option<Lease>,
|
||||
) -> bool {
|
||||
let mut buf = dhcp_request(chaddr);
|
||||
let mut ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_mut_slice());
|
||||
ipv4_pkt.set_src_addr(src_addr);
|
||||
ipv4_pkt.set_dst_addr(dst_addr);
|
||||
|
||||
let ipv4_pkt = Ipv4Packet::new_checked(buf.as_slice()).unwrap();
|
||||
super::is_allowed_dhcp_request(&ipv4_pkt, unicast_target, VM_MAC, lease)
|
||||
}
|
||||
|
||||
fn dhcp_request(chaddr: [u8; 6]) -> Vec<u8> {
|
||||
let mut message = Message::new(
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
Ipv4Address::UNSPECIFIED,
|
||||
&chaddr,
|
||||
);
|
||||
message
|
||||
.opts_mut()
|
||||
.insert(DhcpOption::MessageType(MessageType::Discover));
|
||||
|
||||
let mut dhcp_payload = Vec::new();
|
||||
message
|
||||
.encode(&mut Encoder::new(&mut dhcp_payload))
|
||||
.unwrap();
|
||||
|
||||
let total_len = 20 + 8 + dhcp_payload.len();
|
||||
let mut buf = vec![0; total_len];
|
||||
let mut ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_mut_slice());
|
||||
ipv4_pkt.set_version(4);
|
||||
ipv4_pkt.set_header_len(20);
|
||||
ipv4_pkt.set_total_len(total_len as u16);
|
||||
ipv4_pkt.set_next_header(IpProtocol::Udp);
|
||||
ipv4_pkt.set_src_addr(Ipv4Address::UNSPECIFIED);
|
||||
ipv4_pkt.set_dst_addr(Ipv4Address::BROADCAST);
|
||||
|
||||
let mut udp_pkt = UdpPacket::new_unchecked(ipv4_pkt.payload_mut());
|
||||
udp_pkt.set_src_port(68);
|
||||
udp_pkt.set_dst_port(67);
|
||||
udp_pkt.set_len((8 + dhcp_payload.len()) as u16);
|
||||
udp_pkt.payload_mut().copy_from_slice(&dhcp_payload);
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
|
|
|||
235
lib/vm.rs
235
lib/vm.rs
|
|
@ -1,5 +1,7 @@
|
|||
use crate::{Error, Result};
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::io;
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
|
||||
use std::os::unix::net::UnixDatagram;
|
||||
|
||||
pub struct VM {
|
||||
|
|
@ -8,9 +10,11 @@ pub struct VM {
|
|||
|
||||
impl VM {
|
||||
pub fn new(vm_fd: RawFd) -> Result<VM> {
|
||||
let vm_fd = duplicate_vm_fd(vm_fd)?;
|
||||
|
||||
// SAFETY: duplicate_vm_fd only returns a valid descriptor that it owns.
|
||||
let sock = unsafe { UnixDatagram::from_raw_fd(vm_fd) };
|
||||
sock.set_nonblocking(true)
|
||||
.map_err(|err| Error::InitFailed { source: err.into() })?;
|
||||
sock.set_nonblocking(true)?;
|
||||
|
||||
Ok(VM { sock })
|
||||
}
|
||||
|
|
@ -22,6 +26,92 @@ impl VM {
|
|||
pub fn read(&self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.sock.recv(buf)
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> io::Result<bool> {
|
||||
match self.sock.peer_addr() {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotConnected => Ok(false),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate_vm_fd(vm_fd: RawFd) -> Result<RawFd> {
|
||||
if vm_fd < 0 {
|
||||
bail!("invalid VM file descriptor {vm_fd}: value must be non-negative");
|
||||
}
|
||||
|
||||
// SAFETY: fcntl duplicates the descriptor without transferring ownership of vm_fd.
|
||||
let duplicated_fd = unsafe { libc::fcntl(vm_fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicated_fd == -1 {
|
||||
return Err(io::Error::last_os_error())
|
||||
.with_context(|| format!("failed to duplicate VM file descriptor {vm_fd}"));
|
||||
}
|
||||
|
||||
if let Err(error) = validate_vm_fd(duplicated_fd) {
|
||||
// SAFETY: duplicated_fd is an open descriptor owned by this function.
|
||||
unsafe { libc::close(duplicated_fd) };
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(duplicated_fd)
|
||||
}
|
||||
|
||||
fn validate_vm_fd(vm_fd: RawFd) -> Result<()> {
|
||||
// SAFETY: fcntl only reads descriptor state and does not take ownership.
|
||||
if unsafe { libc::fcntl(vm_fd, libc::F_GETFD) } == -1 {
|
||||
return Err(io::Error::last_os_error())
|
||||
.with_context(|| format!("failed to inspect VM file descriptor {vm_fd}"));
|
||||
}
|
||||
|
||||
let mut socket_type = 0;
|
||||
let mut socket_type_len = size_of::<libc::c_int>() as libc::socklen_t;
|
||||
|
||||
// SAFETY: socket_type and socket_type_len are valid writable buffers of the sizes given.
|
||||
if unsafe {
|
||||
libc::getsockopt(
|
||||
vm_fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_TYPE,
|
||||
(&mut socket_type as *mut libc::c_int).cast(),
|
||||
&mut socket_type_len,
|
||||
)
|
||||
} == -1
|
||||
{
|
||||
return Err(io::Error::last_os_error())
|
||||
.with_context(|| format!("VM file descriptor {vm_fd} is not a socket"));
|
||||
}
|
||||
|
||||
if socket_type != libc::SOCK_DGRAM {
|
||||
bail!("VM file descriptor {vm_fd} is not a Unix datagram socket");
|
||||
}
|
||||
|
||||
let mut address: libc::sockaddr_storage = unsafe { zeroed() };
|
||||
let mut address_len = size_of::<libc::sockaddr_storage>() as libc::socklen_t;
|
||||
|
||||
// SAFETY: address and address_len are valid writable buffers of the sizes given.
|
||||
if unsafe {
|
||||
libc::getsockname(
|
||||
vm_fd,
|
||||
(&mut address as *mut libc::sockaddr_storage).cast(),
|
||||
&mut address_len,
|
||||
)
|
||||
} == -1
|
||||
{
|
||||
return Err(io::Error::last_os_error()).with_context(|| {
|
||||
format!("failed to inspect the address family of VM file descriptor {vm_fd}")
|
||||
});
|
||||
}
|
||||
|
||||
// macOS returns a zero-length address for unnamed UNIX-domain sockets,
|
||||
// including socketpair descriptors. Other socket families return their
|
||||
// address family when getsockname succeeds.
|
||||
let is_unix_socket = address_len == 0 || address.ss_family as libc::c_int == libc::AF_UNIX;
|
||||
if !is_unix_socket {
|
||||
bail!("VM file descriptor {vm_fd} is not a Unix socket");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl AsRawFd for VM {
|
||||
|
|
@ -29,3 +119,140 @@ impl AsRawFd for VM {
|
|||
self.sock.as_raw_fd()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::VM;
|
||||
use polling::{Event, Events, PollMode, Poller};
|
||||
use std::fs::File;
|
||||
use std::net::UdpSocket;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::{UnixDatagram, UnixStream};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_new_rejects_negative_fd() {
|
||||
let error = VM::new(-1).err().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"invalid VM file descriptor -1: value must be non-negative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_rejects_non_socket_fd_without_taking_ownership() {
|
||||
let file = File::open("/dev/null").unwrap();
|
||||
let error = VM::new(file.as_raw_fd()).err().unwrap();
|
||||
|
||||
assert!(error.to_string().contains("is not a socket"));
|
||||
assert!(file.metadata().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_rejects_closed_fd() {
|
||||
let (socket, _peer) = UnixDatagram::pair().unwrap();
|
||||
let vm_fd = socket.as_raw_fd();
|
||||
drop(socket);
|
||||
|
||||
let error = VM::new(vm_fd).err().unwrap();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to duplicate VM file descriptor")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_rejects_non_datagram_socket() {
|
||||
let (stream, _peer) = UnixStream::pair().unwrap();
|
||||
let error = VM::new(stream.as_raw_fd()).err().unwrap();
|
||||
|
||||
assert!(error.to_string().contains("not a Unix datagram socket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_rejects_internet_datagram_socket() {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let error = VM::new(socket.as_raw_fd()).err().unwrap();
|
||||
|
||||
assert!(error.to_string().contains("not a Unix socket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_does_not_close_original_fd_when_vm_is_dropped() {
|
||||
let (socket, _peer) = UnixDatagram::pair().unwrap();
|
||||
let vm = VM::new(socket.as_raw_fd()).unwrap();
|
||||
drop(vm);
|
||||
|
||||
let socket_fd_is_open = unsafe { libc::fcntl(socket.as_raw_fd(), libc::F_GETFD) != -1 };
|
||||
|
||||
if socket_fd_is_open {
|
||||
drop(socket);
|
||||
} else {
|
||||
// Avoid double-closing the descriptor if this test catches an unsafe implementation.
|
||||
std::mem::forget(socket);
|
||||
}
|
||||
|
||||
assert!(socket_fd_is_open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connected_socket_has_peer() {
|
||||
let (socket, _peer) = UnixDatagram::pair().unwrap();
|
||||
let vm = VM::new(socket.as_raw_fd()).unwrap();
|
||||
|
||||
assert!(vm.is_connected().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnected_peer_is_detected_without_kqueue_event() {
|
||||
let (socket, peer) = UnixDatagram::pair().unwrap();
|
||||
let vm = VM::new(socket.as_raw_fd()).unwrap();
|
||||
let poller = Poller::new().unwrap();
|
||||
let mut events = Events::new();
|
||||
|
||||
unsafe {
|
||||
poller
|
||||
.add_with_mode(vm.as_raw_fd(), Event::readable(0), PollMode::Edge)
|
||||
.unwrap();
|
||||
}
|
||||
drop(peer);
|
||||
|
||||
poller
|
||||
.wait(&mut events, Some(Duration::from_millis(20)))
|
||||
.unwrap();
|
||||
|
||||
assert!(events.is_empty());
|
||||
assert!(!vm.is_connected().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disconnected_peer_is_detected_when_another_socket_wakes_kqueue() {
|
||||
let (socket, peer) = UnixDatagram::pair().unwrap();
|
||||
let (host, host_peer) = UnixDatagram::pair().unwrap();
|
||||
let vm = VM::new(socket.as_raw_fd()).unwrap();
|
||||
let poller = Poller::new().unwrap();
|
||||
let mut events = Events::new();
|
||||
|
||||
unsafe {
|
||||
poller
|
||||
.add_with_mode(vm.as_raw_fd(), Event::readable(0), PollMode::Edge)
|
||||
.unwrap();
|
||||
poller
|
||||
.add_with_mode(host.as_raw_fd(), Event::readable(1), PollMode::Edge)
|
||||
.unwrap();
|
||||
}
|
||||
drop(peer);
|
||||
host_peer.send(&[1]).unwrap();
|
||||
|
||||
poller
|
||||
.wait(&mut events, Some(Duration::from_millis(20)))
|
||||
.unwrap();
|
||||
|
||||
assert!(events.iter().any(|event| event.key == 1));
|
||||
assert!(!events.iter().any(|event| event.key == 0));
|
||||
assert!(!vm.is_connected().unwrap());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "nightly"
|
||||
281
src/main.rs
281
src/main.rs
|
|
@ -1,33 +1,56 @@
|
|||
use anyhow::{Context, anyhow};
|
||||
use clap::Parser;
|
||||
use log::LevelFilter;
|
||||
use nix::sys::signal::{SigHandler, Signal, signal};
|
||||
use oslog::OsLogger;
|
||||
use privdrop::PrivDrop;
|
||||
use softnet::NetType;
|
||||
use softnet::proxy::ExposedPort;
|
||||
use softnet::proxy::Proxy;
|
||||
use softnet::proxy::Rule;
|
||||
use std::env;
|
||||
use std::os::raw::c_int;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, ExitCode};
|
||||
use system_configuration::core_foundation::base::TCFType;
|
||||
use system_configuration::core_foundation::boolean::CFBoolean;
|
||||
use system_configuration::core_foundation::dictionary::CFDictionary;
|
||||
use system_configuration::core_foundation::number::CFNumber;
|
||||
use system_configuration::core_foundation::string::CFString;
|
||||
use system_configuration::preferences::SCPreferences;
|
||||
use system_configuration::sys::preferences::{SCPreferencesCommitChanges, SCPreferencesSetValue};
|
||||
use users::{get_current_groupname, get_current_username, get_effective_uid};
|
||||
use system_configuration::sys::preferences::{
|
||||
SCPreferencesApplyChanges, SCPreferencesCommitChanges, SCPreferencesLock,
|
||||
SCPreferencesSetValue, SCPreferencesUnlock,
|
||||
};
|
||||
use uzers::{get_current_groupname, get_current_username, get_effective_uid};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
#[clap(
|
||||
long,
|
||||
value_parser = parse_vm_fd,
|
||||
help = "FD number to use for communicating with the VM's networking stack"
|
||||
)]
|
||||
vm_fd: c_int,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
value_parser = parse_vm_fd,
|
||||
help = "connected Unix stream FD for newline-delimited JSON-RPC policy control"
|
||||
)]
|
||||
control_fd: Option<c_int>,
|
||||
|
||||
#[clap(long, help = "MAC address to enforce for the VM")]
|
||||
vm_mac_address: mac_address::MacAddress,
|
||||
|
||||
#[clap(long, value_enum, help = "type of network to use for the VM", default_value_t=NetType::Nat)]
|
||||
vm_net_type: NetType,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
help = "set bootpd(8) lease time to this value (in seconds) before starting the VM",
|
||||
default_value_t = 60
|
||||
default_value_t = 600
|
||||
)]
|
||||
bootpd_lease_time: u32,
|
||||
|
||||
|
|
@ -37,24 +60,122 @@ struct Args {
|
|||
#[clap(long, help = "group name to drop privileges to")]
|
||||
group: Option<String>,
|
||||
|
||||
#[clap(long, hide=true)]
|
||||
#[clap(
|
||||
long,
|
||||
help = "Comma-separated rules for allowing traffic, in the following forms:\n\n\
|
||||
* TARGET: traffic sent from the VM to TARGET; reverse traffic is not filtered by this rule\n\
|
||||
* in TARGET: flows initiated from TARGET to the VM\n\
|
||||
* out TARGET: flows initiated from the VM to TARGET\n\n\
|
||||
Targets are:\n\n\
|
||||
* IPv4 CIDRs\n\
|
||||
* @host, which matches the vmnet bridge gateway IP\n\n\
|
||||
Directional rules make bare TARGET rules stateful in both directions.\n\n\
|
||||
When used with --block, the longest prefix match wins. If an identical rule is both \
|
||||
allowed and blocked, blocking takes precedence.\n\n\
|
||||
--allow=0.0.0.0/0 additionally disables bridge isolation, even when \
|
||||
--block=0.0.0.0/0 is specified.\n\n\
|
||||
Examples:\n\n\
|
||||
* --allow=192.168.0.0/24 — allow stateless traffic with this LAN\n\
|
||||
* --allow=\"in @host\" — allow stateful flows initiated from @host\n\
|
||||
* --allow=\"out 192.168.0.0/24\" — allow stateful flows initiated toward this LAN\n\
|
||||
* --allow=\"in @host,out 192.168.0.0/24\" — multiple rules may be comma-separated",
|
||||
value_name = "comma-separated rules",
|
||||
use_value_delimiter = true,
|
||||
action = clap::ArgAction::Set
|
||||
)]
|
||||
allow: Vec<Rule>,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
help = "Comma-separated rules for blocking traffic, in the following forms:\n\n\
|
||||
* TARGET: traffic sent from the VM to TARGET; reverse traffic is not filtered by this rule\n\
|
||||
* in TARGET: flows initiated from TARGET to the VM\n\
|
||||
* out TARGET: flows initiated from the VM to TARGET\n\n\
|
||||
Targets are:\n\n\
|
||||
* IPv4 CIDRs\n\
|
||||
* @host, which matches the vmnet bridge gateway IP\n\n\
|
||||
Directional rules make bare TARGET rules stateful in both directions.\n\n\
|
||||
When used with --allow, the longest prefix match wins. If an identical rule is both \
|
||||
allowed and blocked, blocking takes precedence.\n\n\
|
||||
Examples:\n\n\
|
||||
* --block=0.0.0.0/0 — establish a stateless default-deny egress policy\n\
|
||||
* --block=\"out @host\" — block stateful flows initiated toward @host\n\
|
||||
* --block=\"out 66.66.66.0/24\" — block stateful flows initiated toward this CIDR\n\
|
||||
* --block=\"out @host,out 66.66.66.0/24\" — multiple rules may be comma-separated",
|
||||
value_name = "comma-separated rules",
|
||||
use_value_delimiter = true,
|
||||
action = clap::ArgAction::Set
|
||||
)]
|
||||
block: Vec<Rule>,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
help = "comma-separated list of TCP ports to expose (e.g. --expose 2222:22,8080:80)",
|
||||
value_name = "comma-separated port specifications",
|
||||
use_value_delimiter = true,
|
||||
action = clap::ArgAction::Set
|
||||
)]
|
||||
expose: Vec<ExposedPort>,
|
||||
|
||||
#[clap(long, hide = true)]
|
||||
sudo_escalation_probing: bool,
|
||||
|
||||
#[clap(long, hide=true)]
|
||||
#[clap(long, hide = true)]
|
||||
sudo_escalation_done: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = try_main() {
|
||||
match err.source() {
|
||||
Some(source) => eprintln!("{}: {}", err, source),
|
||||
None => eprintln!("{}", err),
|
||||
fn main() -> ExitCode {
|
||||
// Enable backtraces by default
|
||||
if env::var("RUST_BACKTRACE").is_err() {
|
||||
unsafe {
|
||||
env::set_var("RUST_BACKTRACE", "full");
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Sentry
|
||||
let _sentry = sentry::init(
|
||||
sentry::ClientOptions::default()
|
||||
.maybe_release(option_env!("CIRRUS_TAG").map(|tag| format!("softnet@{tag}"))),
|
||||
);
|
||||
|
||||
// Enrich future events with Cirrus CI-specific tags
|
||||
if let Ok(tags) = env::var("CIRRUS_SENTRY_TAGS") {
|
||||
sentry::configure_scope(|scope| {
|
||||
for (key, value) in tags.split(',').filter_map(|tag| tag.split_once('=')) {
|
||||
scope.set_tag(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
match try_main() {
|
||||
Ok(_) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
// Print the error into stderr
|
||||
let causes: Vec<String> = err.chain().map(|x| x.to_string()).collect();
|
||||
eprintln!("{}", causes.join(": "));
|
||||
|
||||
// Capture the error into Sentry
|
||||
sentry_anyhow::capture_anyhow(&err);
|
||||
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn try_main() -> anyhow::Result<()> {
|
||||
// Initialize logger
|
||||
OsLogger::new("org.cirruslabs.softnet")
|
||||
.level_filter(LevelFilter::Info)
|
||||
.init()?;
|
||||
|
||||
// The default signal(3)[1] action for SIGINT is to interrupt program,
|
||||
// but we want to handle SIGINT ourselves, so we ignore it. The kqueue(2)'s[2]
|
||||
// EVFILT_SIGNAL will receive it anyways, because it has lower precedence.
|
||||
//
|
||||
// [1]: https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/signal.3.html
|
||||
// [2]: https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kqueue.2.html
|
||||
unsafe { signal(Signal::SIGINT, SigHandler::SigIgn) }?;
|
||||
|
||||
let args: Args = Args::parse();
|
||||
|
||||
// No need to run anything, just return
|
||||
|
|
@ -66,11 +187,11 @@ fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
|
||||
// Retrieve real (not effective) user and group names
|
||||
let current_user_name = get_current_username()
|
||||
.ok_or("failed to resolve real user name")?
|
||||
.ok_or(anyhow!("failed to resolve real user name"))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let current_group_name = get_current_groupname()
|
||||
.ok_or("failed to resolve real group name")?
|
||||
.ok_or(anyhow!("failed to resolve real group name"))?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
|
|
@ -81,7 +202,8 @@ fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let args = std::env::args().skip(1);
|
||||
|
||||
let _ = Command::new("sudo")
|
||||
.arg("-n")
|
||||
.arg("--non-interactive")
|
||||
.arg("--preserve-env=SENTRY_DSN,CIRRUS_SENTRY_TAGS")
|
||||
.arg(&exe)
|
||||
.args(args)
|
||||
.arg("--sudo-escalation-done")
|
||||
|
|
@ -92,14 +214,25 @@ fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.exec();
|
||||
}
|
||||
|
||||
return Err("root privileges are required to run and passwordless sudo was not available".into());
|
||||
return Err(anyhow!(
|
||||
"root privileges are required to run and passwordless sudo was not available"
|
||||
));
|
||||
}
|
||||
|
||||
// Set bootpd(8) min/max lease time while still having the root privileges
|
||||
set_bootpd_lease_time(args.bootpd_lease_time);
|
||||
// Configure bootpd(8) while still having the root privileges
|
||||
configure_bootpd(args.bootpd_lease_time)?;
|
||||
|
||||
// Initialize the proxy while still having the root privileges
|
||||
let mut proxy = Proxy::new(args.vm_fd as RawFd, args.vm_mac_address)?;
|
||||
let mut proxy = Proxy::new(
|
||||
args.vm_fd as RawFd,
|
||||
args.vm_mac_address,
|
||||
args.vm_net_type,
|
||||
args.allow,
|
||||
args.block,
|
||||
args.expose,
|
||||
args.control_fd.map(|fd| fd as RawFd),
|
||||
)
|
||||
.context("failed to initialize proxy")?;
|
||||
|
||||
// Drop effective privileges to the user
|
||||
// and group which have had invoked us
|
||||
|
|
@ -107,10 +240,22 @@ fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.user(args.user.unwrap_or(current_user_name))
|
||||
.group(args.group.unwrap_or(current_group_name))
|
||||
.apply()
|
||||
.map_err(|err| format!("failed to drop privileges: {}", err))?;
|
||||
.context("failed to drop privileges")?;
|
||||
|
||||
// Run proxy
|
||||
proxy.run().map_err(|err| err.into())
|
||||
proxy.run()
|
||||
}
|
||||
|
||||
fn parse_vm_fd(value: &str) -> Result<c_int, String> {
|
||||
let vm_fd = value
|
||||
.parse::<c_int>()
|
||||
.map_err(|err| format!("invalid file descriptor: {err}"))?;
|
||||
|
||||
if vm_fd < 0 {
|
||||
return Err("file descriptor must be non-negative".to_string());
|
||||
}
|
||||
|
||||
Ok(vm_fd)
|
||||
}
|
||||
|
||||
fn sudo_escalation_works() -> bool {
|
||||
|
|
@ -127,24 +272,96 @@ fn sudo_escalation_works() -> bool {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn set_bootpd_lease_time(lease_time: u32) {
|
||||
fn configure_bootpd(lease_time: u32) -> anyhow::Result<()> {
|
||||
let prefs = SCPreferences::group(
|
||||
&CFString::new("softnet"),
|
||||
&CFString::new("com.apple.InternetSharing.default.plist"),
|
||||
);
|
||||
|
||||
let bootpd_dict = CFDictionary::from_CFType_pairs(&[(
|
||||
CFString::new("DHCPLeaseTimeSecs"),
|
||||
CFNumber::from(lease_time as i32),
|
||||
)]);
|
||||
let bootpd_dict = CFDictionary::from_CFType_pairs(&[
|
||||
(
|
||||
CFString::new("DHCPLeaseTimeSecs"),
|
||||
CFNumber::from(lease_time as i32).as_CFType(),
|
||||
),
|
||||
(
|
||||
CFString::new("dhcp_ignore_client_identifier"),
|
||||
CFBoolean::true_value().as_CFType(),
|
||||
),
|
||||
]);
|
||||
|
||||
unsafe {
|
||||
SCPreferencesSetValue(
|
||||
prefs.as_concrete_TypeRef(),
|
||||
CFString::new("bootpd").as_concrete_TypeRef(),
|
||||
bootpd_dict.as_concrete_TypeRef().cast(),
|
||||
let prefs = prefs.as_concrete_TypeRef();
|
||||
anyhow::ensure!(
|
||||
SCPreferencesLock(prefs, 1) != 0,
|
||||
"failed to lock bootpd preferences"
|
||||
);
|
||||
|
||||
SCPreferencesCommitChanges(prefs.as_concrete_TypeRef());
|
||||
let result = (|| -> anyhow::Result<()> {
|
||||
anyhow::ensure!(
|
||||
SCPreferencesSetValue(
|
||||
prefs,
|
||||
CFString::new("bootpd").as_concrete_TypeRef(),
|
||||
bootpd_dict.as_concrete_TypeRef().cast(),
|
||||
) != 0,
|
||||
"failed to set bootpd preferences"
|
||||
);
|
||||
|
||||
anyhow::ensure!(
|
||||
SCPreferencesCommitChanges(prefs) != 0,
|
||||
"failed to commit bootpd preferences"
|
||||
);
|
||||
|
||||
anyhow::ensure!(
|
||||
SCPreferencesApplyChanges(prefs) != 0,
|
||||
"failed to apply bootpd preferences"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let unlocked = SCPreferencesUnlock(prefs) != 0;
|
||||
result?;
|
||||
anyhow::ensure!(unlocked, "failed to unlock bootpd preferences");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Args;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn test_cli_rejects_negative_vm_fd_before_startup() {
|
||||
let error = Args::try_parse_from([
|
||||
"softnet",
|
||||
"--vm-fd=-1",
|
||||
"--vm-mac-address=02:00:00:00:00:01",
|
||||
])
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("file descriptor must be non-negative")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_rejects_negative_control_fd_before_startup() {
|
||||
let error = Args::try_parse_from([
|
||||
"softnet",
|
||||
"--vm-fd=0",
|
||||
"--control-fd=-1",
|
||||
"--vm-mac-address=02:00:00:00:00:01",
|
||||
])
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("file descriptor must be non-negative")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue