Pull request #60: Introduce a setup wizard tool

Merge in ADGUARD-CORE-LIBS/vpn-libs-endpoint from feature/AG-22596 to master

Squashed commit of the following:

commit 8927b3155db76dcc2e3cb45677c30774a4173b02
Merge: ac3b807 68a3ae5
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Tue Jul 4 16:31:27 2023 +0300

    Merge remote-tracking branch 'origin/master' into feature/AG-22596

    # Conflicts:
    #	Cargo.toml

commit ac3b80744f8fa70c13ef1b58298982bb4d0cebc9
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jul 3 16:44:50 2023 +0300

    wizard: allow specifying multiple client through dialogue

commit fc718a24d824857287a80e099e22142a9f7e36b6
Merge: 732c1b3 3b5b0e7
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jul 3 13:49:12 2023 +0300

    Merge remote-tracking branch 'origin/master' into feature/AG-22596

    # Conflicts:
    #	Cargo.toml
    #	examples/my_vpn/auth_info.txt
    #	examples/my_vpn/vpn.toml
    #	lib/src/authentication/file_based.rs
    #	lib/src/settings.rs

commit 732c1b3ead367bb2b0740d86ba255d8c3334446e
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Tue Jun 27 14:25:21 2023 +0300

    wizard: minor

commit 284182a2d3d75ebefb968b0a44316b889e30036d
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jun 26 19:39:35 2023 +0300

    macros: fix doc

commit 250d7d8f5759c2618281147d0aa159c13eda0238
Merge: d944c6f 3df93e3
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jun 26 19:35:30 2023 +0300

    Merge remote-tracking branch 'origin/master' into feature/AG-22596

    # Conflicts:
    #	Cargo.toml
    #	lib/Cargo.toml
    #	lib/src/settings.rs

commit d944c6f21675841a0511da4c6c158d47fff0b30e
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jun 26 16:16:37 2023 +0300

    Revert "Revert "remove accidental changes""

    This reverts commit 00b8f98dbd7bb98baf91403fa98a6b604c63d50c.

commit 7fd663c2deff4ad568e1047ba31346b698c7baf1
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jun 26 16:15:02 2023 +0300

    :security:

commit 4bab5e857dbfdfc8af2a25cb220d870160a36973
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jun 26 11:18:37 2023 +0300

    wizard: minor

commit fb31f912b0ddf1235ec6451bd8c849d38619ff28
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jun 26 11:16:10 2023 +0300

    wizard: fix non-interactive mode

commit 10d106a440a21e5fd99711aed5f9aa28d0e02b1e
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Mon Jun 26 11:13:21 2023 +0300

    wizard: add an option to specify certificate path

commit 960f6457ad099875c29b204f0b7758a4f64736dc
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Fri Jun 23 14:37:21 2023 +0300

    Deduplicate docs + print descriptions and disabled features into output file

commit cf55c8ee8a0a69410c1b67643d7646d81dfb5123
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Thu Jun 22 14:27:52 2023 +0300

    wizard: get rid of excessive modes and be less picky on user

commit 7d25c5b3297c0b4fd99c712cab10050547dea504
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Fri Jun 9 17:24:40 2023 +0300

    fix common name

commit 2715f246c3feaf24f4fb0f14e3670d71a168bcf2
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Fri Jun 9 13:17:03 2023 +0300

    wizard: add common name in alt names as well

commit f5003a5008fbc54468ce59608c3038f9bcbf154a
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Thu Jun 8 18:04:41 2023 +0300

    wizard: don't accept empty string without the default value

commit 64b3b7f432169c3332304ac667bf7daa4d2938fc
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Wed Jun 7 19:48:01 2023 +0300

    build binaries along with running unit tests

commit 2356b188493f446a683b61d65f0e58cf4129727f
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Wed Jun 7 19:16:55 2023 +0300

    Fix readme

commit 07aa8fae5a4b94c078324803a107e23c65ab764a
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Wed Jun 7 19:03:29 2023 +0300

    bench: use the wizard for configuration

commit 00b8f98dbd7bb98baf91403fa98a6b604c63d50c
Author: Sergei Gunchenko <s.gunchenko@adguard.com>
Date:   Wed Jun 7 17:59:54 2023 +0300

    Revert "remove accidental changes"

    This reverts commit d52bac61d50f97ffea3bdb30a9c6fa82a5c2b52d.

... and 15 more commits
This commit is contained in:
Sergei Gunchenko
2023-07-05 12:39:16 +03:00
parent 68a3ae5fd0
commit 921f28e386
33 changed files with 1615 additions and 524 deletions

View File

@@ -1,25 +1,10 @@
[package]
name = "vpn_endpoint"
version = "0.9.49"
authors = ["Sergei Gunchenko <s.gunchenko@adguard.com>"]
edition = "2021"
[[bin]]
name = "vpn_endpoint"
doctest = false
[workspace]
# The empty workspace prevents redundant builds of each component
members = [
"endpoint",
"lib",
"macros",
"tools",
]
[dependencies]
clap = "=4.3.8"
console-subscriber = { version = "=0.1.9", optional = true }
log = "=0.4.19"
sentry = { version = "=0.31.5", default-features = false, features = ["backtrace", "panic", "reqwest", "rustls", "contexts"] }
tokio = { version = "=1.28.2", features = ["rt-multi-thread", "signal"] }
toml = { version = "=0.7.4", default-features = false, features = ["parse"] }
vpn_libs_endpoint = { version = "0.1", path = "lib" }
[features]
# RUSTFLAGS="--cfg tokio_unstable" must also be set
tracing = ["vpn_libs_endpoint/tracing", "tokio/tracing", "dep:console-subscriber"]
[profile.release]
debug = true

View File

@@ -3,45 +3,43 @@
## Building
Execute the following commands in the Terminal:
```shell
cargo build
```
to build the debug version, or
```shell
cargo build --release
```
to build the release version.
## Issuing self-signed cert and keys (RSA)
Execute the following commands in the Terminal:
```shell
openssl req -config <openssl.conf> -new -x509 -sha256 -newkey rsa:2048 -nodes -days 1000 -keyout key.pem -out cert.pem
```
where
* `<openssl.conf>` is an optional OpenSSL request template file
## Endpoint configuration
### Library configuration
An endpoint can be configured using a couple of TOML files.
An endpoint can be configured using a couple of TOML files:
#### Settings
1) The main library settings reflect (`struct Settings` in [settings.rs](./lib/src/settings.rs)).
2) The TLS hosts library settings reflect (`struct TlsHostsSettings` in [settings.rs](./lib/src/settings.rs)).
These settings may be reloaded dynamically (see [here](#dynamic-reloading-of-tls-hosts-settings) for details).
The example file with the full set of available options and their descriptions
can be found [here](./examples/my_vpn/vpn.toml).
The file structure reflects the library settings (`struct Settings` in [settings.rs](./lib/src/settings.rs)).
All of them may be generated using the [setup wizard](./tools/setup_wizard) tool.
To configure the most basic options, execute the following command in the Terminal:
#### TlsHostsSettings
```shell
cargo run --bin setup_wizard
```
The example file with the full set of available options and their descriptions
can be found [here](./examples/my_vpn/tls_hosts.toml).
The file structure reflects the TLS hosts library settings
(`struct TlsHostsSettings` in [settings.rs](./lib/src/settings.rs)).
These settings may be reloaded dynamically (see [here](#dynamic-reloading-of-tls-hosts-settings) for details).
To see the full set of available options, execute the following command in the Terminal:
### Executable features
```shell
cargo run --bin setup_wizard -- -h
```
### Endpoint executable features
#### Configuration
@@ -54,9 +52,10 @@ line arguments. For example:
* Logging file is configured by `--log_file <path>`. If not specified, the instance logs
to `stdout`.
To see the full set of available options, execute the following commands in the Terminal:
To see the full set of available options, execute the following command in the Terminal:
```shell
<path/to/target>/vpn_endpoint -h
cargo run --bin vpn_endpoint -- -h
```
#### Dynamic reloading of TLS hosts settings
@@ -71,50 +70,42 @@ the next reloading.
## Running
To run the binary through `cargo`, execute the following commands in the Terminal:
```shell
cargo run --bin vpn_endpoint -- <path/to/vpn.config> <path/to/tls_hosts.config>
```
To run the binary directly, execute the following commands in the Terminal:
```shell
<path/to/target>/vpn_endpoint <path/to/vpn.config> <path/to/tls_hosts.config>
```
where `<path/to/target>` is determined by the build command (by default it is `./target/debug` or
`./target/release` depending on the build type).
## Example endpoint
For a quic setup, you can run the example endpoint (see [here](./examples/my_vpn)).
It shows the essential things needed to run an instance.
To start one, run the following commands in the Terminal:
```shell
cd ./examples/my_vpn && ./run.sh
```
It may ask you to enter some information for generating your certificate.
Skip it clicking `enter` if it does not matter.
## Testing with Google Chrome
1) 2 options:
* Add the generated certificate to the trusted store and run the Google Chrome
* Run the Google Chrome from Terminal like this:
* Add the generated certificate to the trusted store and run the Google Chrome
* Run the Google Chrome from Terminal like this:
```shell
google-chrome --ignore-certificate-errors
```
**IMPORTANT:** the second option should be used just for testing, it removes the first line
of defence against malicious resources
2) Set up the endpoint as an HTTPS proxy server in the browser (either via browser settings or
using an extension like `Proxy SwitchyOmega`)
of defence against malicious resources
2) Set up the endpoint as an HTTPS proxy server in the browser (either via browser settings or
using an extension like `Proxy SwitchyOmega`)
## Collecting metrics
Common ways:
* As plain text: send a GET request to `<ip>:<port>/metrics`, for example, using CURL
or a web browser
or a web browser
* Set up Prometheus:
1) Configure the instance to monitor the endpoint metrics (see [here](https://prometheus.io/docs/prometheus/latest/getting_started/#configure-prometheus-to-monitor-the-sample-targets))
2) Use [the graph interface](https://prometheus.io/docs/prometheus/latest/getting_started/#using-the-graphing-interface)
1) Configure the instance to monitor the endpoint metrics (see [here](https://prometheus.io/docs/prometheus/latest/getting_started/#configure-prometheus-to-monitor-the-sample-targets))
2) Use [the graph interface](https://prometheus.io/docs/prometheus/latest/getting_started/#using-the-graphing-interface)
## License

View File

@@ -45,10 +45,10 @@ Increment version:
git remote set-url origin ${bamboo_planRepository_repositoryUrl}
git pull
git reset
git reset
./scripts/increment_version.sh ${bamboo_custom_version}
git add Cargo.toml CHANGELOG.md
git add ./endpoint/Cargo.toml CHANGELOG.md
git commit -m "skipci: Automatic version increment by Bamboo"
git push

View File

@@ -29,10 +29,18 @@ Test on Linux:
force-clean-build: 'true'
- script:
interpreter: SHELL
description: Run tests
scripts:
- |-
set -x -e
cargo test --workspace
- script:
interpreter: SHELL
description: Build binaries
scripts:
- |-
set -x -e
cargo build --bins
requirements:
- adg-privileged-docker
artifact-subscriptions: [ ]
@@ -45,10 +53,18 @@ Test on macOS:
force-clean-build: 'true'
- script:
interpreter: SHELL
description: Run tests
scripts:
- |-
set -x -e
cargo test --workspace --target x86_64-apple-darwin
- script:
interpreter: SHELL
description: Build binaries
scripts:
- |-
set -x -e
cargo build --bins --target x86_64-apple-darwin
requirements:
- macOS 11.0 or later
artifact-subscriptions: [ ]

View File

@@ -16,28 +16,16 @@ COPY $ENDPOINT_DIR /bench/$ENDPOINT_DIR
WORKDIR /bench/
RUN cd "$ENDPOINT_DIR" && \
cargo build --release --bin setup_wizard && \
cargo build --release --bin vpn_endpoint && \
mv ./target/release/vpn_endpoint /bench/ && \
cd examples/my_vpn && \
openssl req -config openssl.conf -new -x509 -sha256 -newkey rsa:2048 -nodes -days 1000 \
-keyout /bench/key.pem -out /bench/cert.pem \
-subj "/C=de/CN=$ENDPOINT_HOSTNAME" \
-addext "subjectAltName = DNS:*.$ENDPOINT_HOSTNAME"
mv ./target/release/setup_wizard ./target/release/vpn_endpoint /bench/
RUN echo "\
listen_address = \"[::]:4433\"\n\
allow_private_network_connections = true\n\
[listen_protocols.http1]\n\
[listen_protocols.http2]\n\
[listen_protocols.quic]\n\
" >>$CONFIG_FILE
RUN echo "\
[[main_hosts]]\n\
hostname = \"$ENDPOINT_HOSTNAME\"\n\
cert_chain_path = \"cert.pem\"\n\
private_key_path = \"key.pem\"\n\
" >>$TLS_HOSTS_SETTINGS_FILE
RUN ./setup_wizard --mode non-interactive \
--address [::]:4433 \
--creds premium:premium \
--hostname "$ENDPOINT_HOSTNAME" \
--lib-settings "$CONFIG_FILE" \
--hosts-settings "$TLS_HOSTS_SETTINGS_FILE"
ENV LOG_LEVEL=$LOG_LEVEL \
CONFIG_FILE=$CONFIG_FILE \

22
endpoint/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "vpn_endpoint"
version = "0.9.49"
authors = ["Sergei Gunchenko <s.gunchenko@adguard.com>"]
edition = "2021"
[[bin]]
name = "vpn_endpoint"
doctest = false
[dependencies]
clap = "=4.3.8"
console-subscriber = { version = "=0.1.9", optional = true }
log = "=0.4.19"
sentry = { version = "=0.31.5", default-features = false, features = ["backtrace", "panic", "reqwest", "rustls", "contexts"] }
tokio = { version = "=1.28.2", features = ["rt-multi-thread", "signal"] }
toml = { version = "=0.7.4", default-features = false, features = ["parse"] }
vpn_libs_endpoint = { version = "0.1", path = "../lib" }
[features]
# RUSTFLAGS="--cfg tokio_unstable" must also be set
tracing = ["vpn_libs_endpoint/tracing", "tokio/tracing", "dep:console-subscriber"]

View File

@@ -1 +0,0 @@
*.pem

View File

@@ -1,7 +0,0 @@
[[client]]
username = "premium"
password = "premium"
[[client]]
username = "free"
password = "free"

View File

@@ -1,81 +0,0 @@
[req]
distinguished_name = subject
req_extensions = req_ext
x509_extensions = x509_ext
string_mask = utf8only
# The Subject DN can be formed using X501 or RFC 4514 (see RFC 4519 for a description).
# Its sort of a mashup. For example, RFC 4514 does not provide emailAddress.
[ subject ]
countryName = Country Name (2 letter code)
countryName_default = MC
stateOrProvinceName = State or Province Name (full name)
stateOrProvinceName_default = My State
localityName = Locality Name (eg, city)
localityName_default = My Locality
organizationName = Organization Name (eg, company)
organizationName_default = My Organization Limited
# Use a friendly name here because its presented to the user. The server's DNS
# names are placed in Subject Alternate Names. Plus, DNS names here is deprecated
# by both IETF and CA/Browser Forums. If you place a DNS name here, then you
# must include the DNS name in the SAN too (otherwise, Chrome and others that
# strictly follow the CA/Browser Baseline Requirements will fail).
commonName = Common Name (e.g. server FQDN or YOUR name)
commonName_default = localhost
emailAddress = Email Address
emailAddress_default = support@email.com
# Section x509_ext is used when generating a self-signed certificate. I.e., openssl req -x509 ...
[ x509_ext ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
# You only need digitalSignature below. *If* you don't allow
# RSA Key transport (i.e., you use ephemeral cipher suites), then
# omit keyEncipherment because that's key transport.
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
subjectAltName = @alternate_names
nsComment = "OpenSSL Generated Certificate"
# RFC 5280, Section 4.2.1.12 makes EKU optional
# CA/Browser Baseline Requirements, Appendix (B)(3)(G) makes me confused
# In either case, you probably only need serverAuth.
# extendedKeyUsage = serverAuth, clientAuth
# Section req_ext is used when generating a certificate signing request. I.e., openssl req ...
[ req_ext ]
subjectKeyIdentifier = hash
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
subjectAltName = @alternate_names
nsComment = "OpenSSL Generated Certificate"
# RFC 5280, Section 4.2.1.12 makes EKU optional
# CA/Browser Baseline Requirements, Appendix (B)(3)(G) makes me confused
# In either case, you probably only need serverAuth.
# extendedKeyUsage = serverAuth, clientAuth
[ alternate_names ]
DNS.1 = localhost
DNS.2 = localhost.localdomain
DNS.3 = 127.0.0.1
# IPv6 localhost
# DNS.4 = ::1
[ root_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:TRUE
keyUsage = critical, digitalSignature, cRLSign, keyCertSign

View File

@@ -1,14 +0,0 @@
#!/bin/bash
OPENSSL_CONF_FILE="openssl.conf"
KEY_FILE_NAME="key.pem"
CERT_FILE_NAME="cert.pem"
VPN_CONF_FILE="vpn.conf"
TLS_HOSTS_FILE="tls_hosts.conf"
if [ ! -f "$KEY_FILE_NAME" ] || [ ! -f "$CERT_FILE_NAME" ]; then
openssl req -config "$OPENSSL_CONF_FILE" -new -x509 -sha256 -newkey rsa:2048 -nodes -days 1000 \
-keyout "$KEY_FILE_NAME" -out "$CERT_FILE_NAME"
fi
cargo run --package vpn_endpoint --bin vpn_endpoint "$VPN_CONF_FILE" "$TLS_HOSTS_FILE"

View File

@@ -1,55 +0,0 @@
# The TLS hosts for traffic tunneling and service requests handling.
[[main_hosts]]
# Used as a key for selecting a certificate chain in TLS handshake.
# MUST be unique.
hostname = "localhost"
# Path to a file containing the certificate chain.
# MUST remain valid until the endpoint is running or the next TLS hosts settings reload.
cert_chain_path = "cert.pem"
# Path to a file containing the private key.
# May be equal to `cert_chain_path` if it contains both of them.
# MUST remain valid until the endpoint is running or the next TLS hosts settings reload.
private_key_path = "key.pem"
[[main_hosts]]
hostname = "tunnel.localhost"
cert_chain_path = "cert.pem"
private_key_path = "key.pem"
# The TLS hosts for HTTPS pinging.
# With this one set up the endpoint responds with `200 OK` to HTTPS `GET` requests
# to the specified domains.
[[ping_hosts]]
hostname = "ping.localhost"
cert_chain_path = "cert.pem"
private_key_path = "key.pem"
[[ping_hosts]]
hostname = "ping2.localhost"
cert_chain_path = "cert.pem"
private_key_path = "key.pem"
# The TLS hosts for speed testing.
# With this one set up the endpoint accepts connections to the specified hosts and
# handles HTTP requests in the following way:
# * `GET` requests with `/Nmb.bin` path (where `N` is 1 to 100, e.g. `/100mb.bin`)
# are considered as download speedtest transferring `N` megabytes to a client
# * `POST` requests with `/upload.html` path and `Content-Length: N`
# are considered as upload speedtest receiving `N` bytes from a client,
# where `N` is up to 120 * 1024 * 1024 bytes
[[speedtest_hosts]]
hostname = "speed.localhost"
cert_chain_path = "cert.pem"
private_key_path = "key.pem"
[[speedtest_hosts]]
hostname = "speed2.localhost"
cert_chain_path = "cert.pem"
private_key_path = "key.pem"
# The TLS hosts for the connections must be forwarded to the reverse proxy.
# Only makes sense if the reverse proxy is set up, otherwise it is ignored.
[[reverse_proxy_hosts]]
hostname = "hello.localhost"
cert_chain_path = "cert.pem"
private_key_path = "key.pem"

View File

@@ -1,190 +0,0 @@
# (Required) The address to listen on.
listen_address = "[::]:4433"
# Whether IPv6 connections can be routed or rejected with unreachable status.
# Default is true.
ipv6_available = true
# Whether connections to private network of the endpoint are allowed.
# Default is false.
allow_private_network_connections = false
# Timeout of a TLS handshake in seconds.
# Default is 10s.
tls_handshake_timeout_secs = 10
# Timeout of a client listener in seconds.
# Default is 600s.
client_listener_timeout_secs = 600
# Timeout of connection establishment in seconds. For example, it is related to
# client's connection requests.
# Default is 30s.
connection_establishment_timeout_secs = 30
# Idle timeout of tunneled TCP connections in seconds.
# Default is 604800s (1 week).
tcp_connections_timeout_secs = 604800
# Timeout of tunneled UDP "connections" in seconds.
# Default is 300s.
udp_connections_timeout_secs = 300
# (Optional) The registry of client credentials.
# Must be TOML-formatted and contain an array of clients:
# [[client]]
# username = "a name"
# password = "a password"
# [[client]]
# ...
#
# If this one is omitted:
# * if `forward_protocol` is set to `socks5`, the endpoint will try to authenticate
# requests using the SOCKS5 authentication protocol,
# * otherwise, any client is welcome.
credentials_file = "auth_info.txt"
# (Required) The set of connection forwarder settings.
# Possible values:
# * direct: a direct forwarder routes a connection directly to its target host,
# * socks5: a SOCKS5 forwarder routes a connection though a SOCKS5 proxy.
# Default is direct
#[forward_protocol]
# The set of direct forwarder settings.
[forward_protocol.direct]
# The set of SOCKS5 forwarder settings.
#[forward_protocol.socks5]
## (Required) The address of the SOCKS5 server.
#address = "127.0.0.1:1080"
## Enable/disable extended authentication.
## See lib/README.md#extended-authentication for details.
## Disabled by default.
#extended_auth = false
# The list of enabled client listener codecs.
# Possible values:
# * http1: enables HTTP1 codec,
# * http2: enables HTTP2 codec,
# * quic: enables QUIC/HTTP3 codec.
# At least one listener codec MUST be specified.
[listen_protocols]
# The set of HTTP1 listener codec settings.
[listen_protocols.http1]
# Buffer size for outgoing traffic.
# Default is 32K.
upload_buffer_size = 32768
# The set of HTTP2 listener codec settings.
[listen_protocols.http2]
# The initial window size (in octets) for connection-level flow control for received data.
# Default is 8M.
initial_connection_window_size = 8_388_608
# The initial window size (in octets) for stream-level flow control for received data.
# Default is 128K.
initial_stream_window_size = 131072
# The number of streams that the sender permits the receiver to create.
# Default is 1000.
max_concurrent_streams = 1000
# The size (in octets) of the largest HTTP/2 frame payload that we are able to accept.
# Default is 16K.
max_frame_size = 16384
# The max size of received header frames.
# Default is 64K.
header_table_size = 65536
# The set of QUIC listener codec settings.
[listen_protocols.quic]
# The size of UDP payloads that the endpoint is willing to receive. UDP datagrams with
# payloads larger than this limit are not likely to be processed.
# Default is 1350.
recv_udp_payload_size = 1350
# The size of UDP payloads that the endpoint is willing to send.
# Default is 1350.
send_udp_payload_size = 1350
# The initial value for the maximum amount of data that can be sent on the connection.
# Default is 100M.
initial_max_data = 104_857_600
# The initial flow control limit for locally initiated bidirectional streams.
# Default is 1M.
max_stream_data_bidi_local = 1_048_576
# The initial flow control limit for peer-initiated bidirectional streams.
# Default is 1M.
max_stream_data_bidi_remote = 1_048_576
# The initial flow control limit for unidirectional streams.
# Default is 1M.
max_stream_data_uni = 1_048_576
# The initial maximum number of bidirectional streams the endpoint that receives this
# transport parameter is permitted to initiate.
# Default is 4K.
max_streams_bidi = 4096
# The initial maximum number of unidirectional streams the endpoint that receives this
# transport parameter is permitted to initiate.
# Default is 4K.
max_streams_uni = 4096
# The maximum size of the connection window.
# Default is 24M.
max_connection_window = 25_165_824
# The maximum size of the stream window.
# Default is 16M.
max_stream_window = 16_777_216
# Whether the active connection migration is enabled on the address being used
# during the handshake.
# Disabled by default.
disable_active_migration = true
# Whether sending or receiving early data is enabled.
# Enabled by default.
enable_early_data = true
# The capacity of the QUIC multiplexer message queue.
# Decreasing it may cause packet dropping in case the multiplexer cannot keep up the pace.
# Increasing it may lead to high memory consumption.
# Default is 4K.
message_queue_capacity = 4096
# (Optional) The reverse proxy settings.
# With this one set up the endpoint does TLS termination on matching connections and
# translates HTTP/x traffic into HTTP/1.1 protocol towards the server and back
# into original HTTP/x towards the client. Like this:
#
# ```(client) TLS(HTTP/x) <--(endpoint)--> (server) HTTP/1.1```
#
# The translated HTTP/1.1 requests have the custom header `X-Original-Protocol`
# appended. For now, its value can be either `HTTP1`, or `HTTP3`.
# TLS hosts for the reverse proxy channel are configured through the TLS hosts settings.
[reverse_proxy]
# (Required) The origin server address.
server_address = "127.0.0.1:1111"
# Connections to the main hosts with paths starting with this mask are routed
# to the reverse proxy server.
# MUST not be empty and start with slash.
path_mask = "/proxy"
# With this one set to `true` the endpoint overrides the HTTP method while
# translating an HTTP3 request to HTTP1 in case the request has the `GET` method
# and its path is `/`
# Disabled by default.
h3_backward_compatibility = false
# (Optional) The ICMP forwarding settings.
# Setting up this feature requires superuser rights on some systems.
[icmp]
# (Required) The name of a network interface to bind the outbound ICMP socket to.
interface_name = "eth0"
# Timeout of tunneled ICMP requests in seconds.
# Default is 3s.
request_timeout_secs = 3
# The capacity of the ICMP multiplexer received messages queue.
# Decreasing it may cause packet dropping in case the multiplexer cannot keep up the pace.
# Increasing it may lead to high memory consumption.
# Each client has its own queue.
# Default is 256.
recv_message_queue_capacity = 256
# (Optional) The metrics gathering request handler settings.
[metrics]
# The address to listen on for settings export requests.
# Default is 0.0.0.0:1987.
address = "0.0.0.0:1987"
# Timeout of a metrics request in seconds.
# Default is 3s.
request_timeout_secs = 3

View File

@@ -25,6 +25,7 @@ httparse = "=1.8.0"
lazy_static = "=1.4.0"
libc = "=0.2.147"
log = "=0.4.19"
macros = { version = "0.1.0", path = "../macros", optional = true }
once_cell = "=1.18.0"
prometheus = { version = "=0.13.3", features = ["process"] }
quiche = { version = "=0.17.2", features = ["qlog"] }
@@ -43,4 +44,5 @@ hyper = { version = "=0.14.26", features = ["http1", "http2", "client", "server"
rustls = { version = "=0.21.2", features = ["logging", "dangerous_configuration"] }
[features]
rt_doc = ["dep:macros"]
tracing = ["tokio/tracing"]

View File

@@ -1,5 +1,7 @@
#[macro_use]
extern crate log;
#[cfg(feature = "rt_doc")]
extern crate macros;
pub mod authentication;
pub mod core;

View File

@@ -6,11 +6,14 @@ use std::net::{Ipv4Addr, SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
#[cfg(feature = "rt_doc")]
use macros::{Getter, RuntimeDoc};
use serde::{Deserialize, Serialize};
use toml_edit::{Document, Item};
use authentication::registry_based::RegistryBasedAuthenticator;
use crate::authentication::Authenticator;
use crate::{authentication, utils};
use serde::Deserialize;
use toml_edit::{Document, Item};
pub type Socks5BuilderResult<T> = Result<T, Socks5Error>;
@@ -55,49 +58,48 @@ impl Debug for Socks5Error {
}
}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "rt_doc", derive(Getter, RuntimeDoc))]
pub struct Settings {
/// The address to listen on
#[serde(default = "Settings::default_listen_address")]
pub(crate) listen_address: SocketAddr,
/// See [`SettingsBuilder::reverse_proxy`]
pub(crate) reverse_proxy: Option<ReverseProxySettings>,
/// IPv6 availability
/// Whether IPv6 connections can be routed or rejected with unreachable status
#[serde(default = "Settings::default_ipv6_available")]
pub(crate) ipv6_available: bool,
/// Whether connections to private network of the endpoint are allowed
#[serde(default = "Settings::default_allow_private_network_connections")]
pub(crate) allow_private_network_connections: bool,
/// Timeout of a TLS handshake
/// Timeout of an incoming TLS handshake
#[serde(default = "Settings::default_tls_handshake_timeout")]
#[serde(rename(deserialize = "tls_handshake_timeout_secs"))]
#[serde(deserialize_with = "deserialize_duration_secs")]
#[serde(rename = "tls_handshake_timeout_secs")]
#[serde(deserialize_with = "deserialize_duration_secs", serialize_with = "serialize_duration_secs")]
pub(crate) tls_handshake_timeout: Duration,
/// Timeout of a client listener
#[serde(default = "Settings::default_client_listener_timeout")]
#[serde(rename(deserialize = "client_listener_timeout_secs"))]
#[serde(deserialize_with = "deserialize_duration_secs")]
#[serde(rename = "client_listener_timeout_secs")]
#[serde(deserialize_with = "deserialize_duration_secs", serialize_with = "serialize_duration_secs")]
pub(crate) client_listener_timeout: Duration,
/// Timeout of connection establishment. For example, it is related to
/// client's connection requests.
/// Timeout of outgoing connection establishment.
/// For example, it is related to client's connection requests.
#[serde(default = "Settings::default_connection_establishment_timeout")]
#[serde(rename(deserialize = "connection_establishment_timeout_secs"))]
#[serde(deserialize_with = "deserialize_duration_secs")]
#[serde(rename = "connection_establishment_timeout_secs")]
#[serde(deserialize_with = "deserialize_duration_secs", serialize_with = "serialize_duration_secs")]
pub(crate) connection_establishment_timeout: Duration,
/// Timeout of tunneled TCP connections
/// Idle timeout of tunneled TCP connections
#[serde(default = "Settings::default_tcp_connections_timeout")]
#[serde(rename(deserialize = "tcp_connections_timeout_secs"))]
#[serde(deserialize_with = "deserialize_duration_secs")]
#[serde(rename = "tcp_connections_timeout_secs")]
#[serde(deserialize_with = "deserialize_duration_secs", serialize_with = "serialize_duration_secs")]
pub(crate) tcp_connections_timeout: Duration,
/// Timeout of tunneled UDP "connections"
#[serde(default = "Settings::default_udp_connections_timeout")]
#[serde(rename(deserialize = "udp_connections_timeout_secs"))]
#[serde(deserialize_with = "deserialize_duration_secs")]
#[serde(rename = "udp_connections_timeout_secs")]
#[serde(deserialize_with = "deserialize_duration_secs", serialize_with = "serialize_duration_secs")]
pub(crate) udp_connections_timeout: Duration,
/// The forwarder codec settings
/// The set of connection forwarder settings
#[serde(default)]
pub(crate) forward_protocol: ForwardProtocolSettings,
/// The listener codec settings
/// The set of enabled client listener codecs
pub(crate) listen_protocols: ListenProtocolSettings,
/// The client authenticator.
///
@@ -121,13 +123,25 @@ pub struct Settings {
/// ...
/// ```
#[serde(default)]
#[serde(skip_serializing)]
#[serde(rename(deserialize = "credentials_file"))]
#[serde(deserialize_with = "deserialize_authenticator")]
pub(crate) authenticator: Option<Arc<dyn Authenticator>>,
/// The reverse proxy settings.
/// With this one set up the endpoint does TLS termination on such connections and
/// translates HTTP/x traffic into HTTP/1.1 protocol towards the server and back
/// into original HTTP/x towards the client. Like this:
///
/// ```(client) TLS(HTTP/x) <--(endpoint)--> (server) HTTP/1.1```
///
/// The translated HTTP/1.1 requests have the custom header `X-Original-Protocol`
/// appended. For now, its value can be either `HTTP1`, or `HTTP3`.
/// TLS hosts for the reverse proxy channel are configured through [`TlsHostsSettings`].
pub(crate) reverse_proxy: Option<ReverseProxySettings>,
/// The ICMP forwarding settings.
/// Setting up this feature requires superuser rights on some systems.
pub(crate) icmp: Option<IcmpSettings>,
/// The metrics handling settings
/// The metrics gathering request handler settings
pub(crate) metrics: Option<MetricsSettings>,
/// Whether an instance was built through a [`SettingsBuilder`].
@@ -138,7 +152,8 @@ pub struct Settings {
built: bool,
}
#[derive(Default, Deserialize)]
#[derive(Default, Serialize, Deserialize)]
#[cfg_attr(feature = "rt_doc", derive(RuntimeDoc))]
pub struct TlsHostInfo {
/// Used as a key for selecting a certificate chain in TLS handshake.
/// MUST be unique.
@@ -158,18 +173,31 @@ pub struct TlsHostInfo {
pub private_key_path: String,
}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
#[cfg_attr(test, derive(Default))]
#[cfg_attr(feature = "rt_doc", derive(RuntimeDoc))]
pub struct TlsHostsSettings {
/// See [`TlsSettingsBuilder::main_hosts`]
/// Еhe main TLS hosts.
/// Used for traffic tunneling and service requests handling.
pub(crate) main_hosts: Vec<TlsHostInfo>,
/// See [`TlsSettingsBuilder::ping_hosts`]
/// The TLS hosts for HTTPS pinging.
/// With this one set up the endpoint responds with `200 OK` to HTTPS `GET` requests
/// to the specified domains.
#[serde(default)]
pub(crate) ping_hosts: Vec<TlsHostInfo>,
/// See [`TlsSettingsBuilder::speedtest_hosts`]
/// The TLS hosts for speed testing.
/// With this one set up the endpoint accepts connections to the specified hosts and
/// handles HTTP requests in the following way:
/// * `GET` requests with `/Nmb.bin` path (where `N` is 1 to 100, e.g. `/100mb.bin`)
/// are considered as download speedtest transferring `N` megabytes to a client
/// * `POST` requests with `/upload.html` path and `Content-Length: N`
/// are considered as upload speedtest receiving `N` bytes from a client,
/// where `N` is up to 120 * 1024 * 1024 bytes
#[serde(default)]
pub(crate) speedtest_hosts: Vec<TlsHostInfo>,
/// See [`TlsSettingsBuilder::reverse_proxy_hosts`]
/// The TLS hosts for the connections must be forwarded to the reverse proxy
/// (see [`Settings::reverse_proxy`]).
/// Only makes sense if the reverse proxy is set up, otherwise it is ignored.
#[serde(default)]
pub(crate) reverse_proxy_hosts: Vec<TlsHostInfo>,
@@ -181,19 +209,26 @@ pub struct TlsHostsSettings {
built: bool,
}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "rt_doc", derive(RuntimeDoc))]
pub struct ReverseProxySettings {
/// See [`ReverseProxySettingsBuilder::server_address`]
/// The origin server address
pub(crate) server_address: SocketAddr,
/// See [`ReverseProxySettingsBuilder::path_mask`]
/// Connections to [the main hosts](TlsHostsSettings.main_hosts) with
/// paths starting with this mask are routed to the reverse proxy server.
/// MUST start with slash.
pub(crate) path_mask: String,
/// See [`ReverseProxySettingsBuilder::h3_backward_compatibility`]
/// With this one set to `true` the endpoint overrides the HTTP method while
/// translating an HTTP3 request to HTTP1 in case the request has the `GET` method
/// and its path is `/` or matches [`ReverseProxySettings.path_mask`]
#[serde(default)]
pub(crate) h3_backward_compatibility: bool,
}
#[derive(Deserialize)]
/// The set of connection forwarder settings
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "rt_doc", derive(RuntimeDoc))]
pub enum ForwardProtocolSettings {
/// A direct forwarder routes a connection directly to its target host
Direct(DirectForwarderSettings),
@@ -201,15 +236,15 @@ pub enum ForwardProtocolSettings {
Socks5(Socks5ForwarderSettings),
}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
pub struct DirectForwarderSettings {}
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "rt_doc", derive(Getter, RuntimeDoc))]
pub struct Socks5ForwarderSettings {
/// The address of a proxy
pub(crate) address: SocketAddr,
/// The extended authentication flag.
/// See [`Socks5ForwarderSettingsBuilder::extended_auth`] for details.
/// Whether the extended authentication is enabled
#[serde(default)]
pub(crate) extended_auth: bool,
}
@@ -218,7 +253,9 @@ pub struct Socks5ForwarderSettingsBuilder {
settings: Socks5ForwarderSettings,
}
#[derive(Clone, Default, Deserialize)]
/// The set of enabled client listener codecs
#[derive(Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "rt_doc", derive(RuntimeDoc))]
pub struct ListenProtocolSettings {
/// HTTP/1.1 listener settings
#[serde(default)]
@@ -231,14 +268,18 @@ pub struct ListenProtocolSettings {
pub quic: Option<QuicSettings>,
}
#[derive(Deserialize)]
/// The ICMP forwarding settings.
/// Setting up this feature requires superuser rights on some systems.
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "rt_doc", derive(Getter, RuntimeDoc))]
pub struct IcmpSettings {
/// The name of an interface to bind the ICMP socket to
/// The name of a network interface to bind the outbound ICMP socket to
#[serde(default = "IcmpSettings::default_interface_name")]
pub(crate) interface_name: String,
/// Timeout of tunneled ICMP requests
#[serde(default = "IcmpSettings::default_request_timeout")]
#[serde(rename(deserialize = "request_timeout_secs"))]
#[serde(deserialize_with = "deserialize_duration_secs")]
#[serde(rename = "request_timeout_secs")]
#[serde(deserialize_with = "deserialize_duration_secs", serialize_with = "serialize_duration_secs")]
pub(crate) request_timeout: Duration,
/// The capacity of the ICMP multiplexer received messages queue.
/// Decreasing it may cause packet dropping in case the multiplexer cannot keep up the pace.
@@ -248,26 +289,32 @@ pub struct IcmpSettings {
pub(crate) recv_message_queue_capacity: usize,
}
#[derive(Deserialize)]
/// The metrics gathering request handler settings
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "rt_doc", derive(Getter, RuntimeDoc))]
pub struct MetricsSettings {
/// The address to listen on for settings export requests
#[serde(default = "MetricsSettings::default_listen_address")]
pub(crate) address: SocketAddr,
/// Timeout of a metrics request
#[serde(default = "MetricsSettings::default_request_timeout")]
#[serde(rename(deserialize = "request_timeout_secs"))]
#[serde(deserialize_with = "deserialize_duration_secs")]
#[serde(rename = "request_timeout_secs")]
#[serde(deserialize_with = "deserialize_duration_secs", serialize_with = "serialize_duration_secs")]
pub(crate) request_timeout: Duration,
}
#[derive(Deserialize, Clone)]
/// The set of HTTP/1.1 listener codec settings
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "rt_doc", derive(Getter, RuntimeDoc))]
pub struct Http1Settings {
/// Buffer size for outgoing traffic
#[serde(default = "Http1Settings::default_upload_buffer_size")]
pub(crate) upload_buffer_size: usize,
}
#[derive(Deserialize, Clone)]
/// The set of HTTP/2 listener codec settings
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "rt_doc", derive(Getter, RuntimeDoc))]
pub struct Http2Settings {
/// The initial window size (in octets) for connection-level flow control for received data
#[serde(default = "Http2Settings::default_initial_connection_window_size")]
@@ -286,7 +333,9 @@ pub struct Http2Settings {
pub(crate) header_table_size: u32,
}
#[derive(Deserialize, Clone)]
/// The set of QUIC listener codec settings
#[derive(Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "rt_doc", derive(Getter, RuntimeDoc))]
pub struct QuicSettings {
/// The size of UDP payloads that the endpoint is willing to receive. UDP datagrams with
/// payloads larger than this limit are not likely to be processed.
@@ -393,35 +442,35 @@ impl Settings {
Ok(())
}
fn default_listen_address() -> SocketAddr {
pub fn default_listen_address() -> SocketAddr {
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 443))
}
fn default_ipv6_available() -> bool {
pub fn default_ipv6_available() -> bool {
true
}
fn default_allow_private_network_connections() -> bool {
pub fn default_allow_private_network_connections() -> bool {
false
}
fn default_tls_handshake_timeout() -> Duration {
pub fn default_tls_handshake_timeout() -> Duration {
Duration::from_secs(10)
}
fn default_client_listener_timeout() -> Duration {
pub fn default_client_listener_timeout() -> Duration {
Duration::from_secs(10 * 60)
}
fn default_connection_establishment_timeout() -> Duration {
pub fn default_connection_establishment_timeout() -> Duration {
Duration::from_secs(30)
}
fn default_tcp_connections_timeout() -> Duration {
pub fn default_tcp_connections_timeout() -> Duration {
Duration::from_secs(604800) // 1 week (match client tcpip module)
}
fn default_udp_connections_timeout() -> Duration {
pub fn default_udp_connections_timeout() -> Duration {
Duration::from_secs(300) // 5 minutes (match client tcpip module)
}
}
@@ -431,7 +480,6 @@ impl Default for Settings {
fn default() -> Self {
Self {
listen_address: SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)),
reverse_proxy: None,
ipv6_available: false,
allow_private_network_connections: true,
tls_handshake_timeout: Settings::default_tls_handshake_timeout(),
@@ -446,6 +494,7 @@ impl Default for Settings {
quic: Some(QuicSettings::builder().build()),
},
authenticator: None,
reverse_proxy: None,
icmp: None,
metrics: Default::default(),
built: false,
@@ -514,7 +563,7 @@ impl Http1Settings {
Http1SettingsBuilder::new()
}
fn default_upload_buffer_size() -> usize {
pub fn default_upload_buffer_size() -> usize {
32 * 1024
}
}
@@ -524,23 +573,23 @@ impl Http2Settings {
Http2SettingsBuilder::new()
}
fn default_initial_connection_window_size() -> u32 {
pub fn default_initial_connection_window_size() -> u32 {
8 * 1024 * 1024
}
fn default_initial_stream_window_size() -> u32 {
pub fn default_initial_stream_window_size() -> u32 {
128 * 1024 // Chrome constant
}
fn default_max_concurrent_streams() -> u32 {
pub fn default_max_concurrent_streams() -> u32 {
1000 // Chrome constant
}
fn default_max_frame_size() -> u32 {
pub fn default_max_frame_size() -> u32 {
1 << 14 // Firefox constant
}
fn default_header_table_size() -> u32 {
pub fn default_header_table_size() -> u32 {
65536
}
}
@@ -550,55 +599,55 @@ impl QuicSettings {
QuicSettingsBuilder::new()
}
fn default_recv_udp_payload_size() -> usize {
pub fn default_recv_udp_payload_size() -> usize {
1350
}
fn default_send_udp_payload_size() -> usize {
pub fn default_send_udp_payload_size() -> usize {
1350
}
fn default_initial_max_data() -> u64 {
pub fn default_initial_max_data() -> u64 {
100 * 1024 * 1024
}
fn default_initial_max_stream_data_bidi_local() -> u64 {
pub fn default_initial_max_stream_data_bidi_local() -> u64 {
1024 * 1024
}
fn default_initial_max_stream_data_bidi_remote() -> u64 {
pub fn default_initial_max_stream_data_bidi_remote() -> u64 {
1024 * 1024
}
fn default_initial_max_stream_data_uni() -> u64 {
pub fn default_initial_max_stream_data_uni() -> u64 {
1024 * 1024
}
fn default_initial_max_streams_bidi() -> u64 {
pub fn default_initial_max_streams_bidi() -> u64 {
4 * 1024
}
fn default_initial_max_streams_uni() -> u64 {
pub fn default_initial_max_streams_uni() -> u64 {
4 * 1024
}
fn default_max_connection_window() -> u64 {
pub fn default_max_connection_window() -> u64 {
24 * 1024 * 1024
}
fn default_max_stream_window() -> u64 {
pub fn default_max_stream_window() -> u64 {
16 * 1024 * 1024
}
fn default_disable_active_migration() -> bool {
pub fn default_disable_active_migration() -> bool {
true
}
fn default_enable_early_data() -> bool {
pub fn default_enable_early_data() -> bool {
true
}
fn default_message_queue_capacity() -> usize {
pub fn default_message_queue_capacity() -> usize {
4 * 1024
}
}
@@ -626,11 +675,19 @@ impl IcmpSettings {
IcmpSettingsBuilder::new()
}
fn default_request_timeout() -> Duration {
pub fn default_interface_name() -> String {
if cfg!(target_os = "linux") {
"eth0"
} else {
"en0"
}.into()
}
pub fn default_request_timeout() -> Duration {
Duration::from_secs(3)
}
fn default_message_queue_capacity() -> usize {
pub fn default_message_queue_capacity() -> usize {
256
}
}
@@ -640,11 +697,11 @@ impl MetricsSettings {
MetricsSettingsBuilder::new()
}
fn default_listen_address() -> SocketAddr {
pub fn default_listen_address() -> SocketAddr {
(Ipv4Addr::UNSPECIFIED, 1987).into()
}
fn default_request_timeout() -> Duration {
pub fn default_request_timeout() -> Duration {
Duration::from_secs(3)
}
}
@@ -663,7 +720,6 @@ impl SettingsBuilder {
Self {
settings: Settings {
listen_address: Settings::default_listen_address(),
reverse_proxy: None,
ipv6_available: Settings::default_ipv6_available(),
allow_private_network_connections: Settings::default_allow_private_network_connections(),
tls_handshake_timeout: Settings::default_tls_handshake_timeout(),
@@ -674,6 +730,7 @@ impl SettingsBuilder {
forward_protocol: Default::default(),
listen_protocols: Default::default(),
authenticator: None,
reverse_proxy: None,
icmp: None,
metrics: Default::default(),
built: true,
@@ -735,6 +792,13 @@ impl SettingsBuilder {
self
}
/// Set timeout of outgoing connection establishment.
/// For example, it is related to client's connection requests.
pub fn connection_establishment_timeout(mut self, v: Duration) -> Self {
self.settings.connection_establishment_timeout = v;
self
}
/// Set timeout of tunneled TCP connections
pub fn tcp_connections_timeout(mut self, v: Duration) -> Self {
self.settings.tcp_connections_timeout = v;
@@ -770,6 +834,12 @@ impl SettingsBuilder {
self.settings.icmp = Some(x);
self
}
/// Set the metrics request listener settings
pub fn metrics(mut self, x: MetricsSettings) -> Self {
self.settings.metrics = Some(x);
self
}
}
impl TlsSettingsBuilder {
@@ -791,7 +861,8 @@ impl TlsSettingsBuilder {
Ok(self.settings)
}
/// Set the main TLS hosts
/// Set the main TLS hosts.
/// Used for traffic tunneling and service requests handling.
pub fn main_hosts(mut self, hosts: Vec<TlsHostInfo>) -> Self {
self.settings.main_hosts = hosts;
self
@@ -1175,6 +1246,13 @@ fn deserialize_duration_secs<'de, D>(deserializer: D) -> Result<Duration, D::Err
Ok(Duration::from_secs(x))
}
fn serialize_duration_secs<S>(x: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_u64(x.as_secs())
}
fn deserialize_file_path<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::de::Deserializer<'de>,

View File

@@ -118,6 +118,7 @@ impl TlsDemux {
cert_chain: if cfg!(test) {
Default::default()
} else {
// @todo: check if cert is expired?
utils::load_certs(&x.cert_chain_path)?
},
key: if cfg!(test) {

View File

@@ -22,7 +22,7 @@ pub fn hex_dump_uppercase(buf: &[u8]) -> String {
}
/// Can hold either of the options
pub(crate) enum Either<L, R> {
pub enum Either<L, R> {
Left(L),
Right(R),
}
@@ -71,16 +71,55 @@ impl<L, R> Either<L, R> {
}
}
pub(crate) fn load_certs(filename: &str) -> io::Result<Vec<Certificate>> {
pub fn load_certs(filename: &str) -> io::Result<Vec<Certificate>> {
certs(&mut BufReader::new(File::open(filename)?))
.map_err(|e| io::Error::new(
ErrorKind::InvalidInput, format!("Invalid cert: {}", e)))
.map(|mut certs| certs.drain(..).map(Certificate).collect())
}
pub(crate) fn load_private_key(filename: &str) -> io::Result<PrivateKey> {
pub fn load_private_key(filename: &str) -> io::Result<PrivateKey> {
pkcs8_private_keys(&mut BufReader::new(File::open(filename)?))
.map_err(|e| io::Error::new(
ErrorKind::InvalidInput, format!("Invalid key: {}", e)))
.map(|mut keys| PrivateKey(keys.remove(0)))
.and_then(|keys| keys.first().cloned()
.ok_or_else(|| io::Error::new(ErrorKind::Other, "No keys found")))
.map(PrivateKey)
}
pub trait IterJoin {
type Output;
/// Like [`Iterator::fold`] but drops the trailing separator
fn join(self, sep: impl AsRef<str>) -> Self::Output;
}
impl<I, T> IterJoin for I
where
I: Iterator<Item=T>,
T: AsRef<str>,
{
type Output = String;
fn join(self, sep: impl AsRef<str>) -> Self::Output {
let mut ret = self
.fold(String::new(), |acc, x| acc + x.as_ref() + sep.as_ref());
if ret.len() > sep.as_ref().len() {
ret.replace_range((ret.len() - sep.as_ref().len()).., "");
}
ret
}
}
#[cfg(test)]
mod tests {
use crate::utils::IterJoin;
#[test]
fn iter_join() {
assert_eq!("a.b.c", ["a", "b", "c"].iter().join("."));
assert_eq!("a", std::iter::once("a").join("x"));
assert_eq!("", std::iter::empty::<&str>().join("x"));
}
}

12
macros/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "macros"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "=1.0.60"
quote = "=1.0.28"
syn = "=1.0.109"

36
macros/src/getter.rs Normal file
View File

@@ -0,0 +1,36 @@
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{Data, DataStruct, DeriveInput, Fields};
pub(crate) fn derive(input: TokenStream) -> TokenStream {
let ast: DeriveInput = syn::parse(input).unwrap();
let fields = match &ast.data {
Data::Struct(DataStruct { fields: Fields::Named(fields), .. }) => {
fields.named.iter()
.filter_map(|field| field.ident.as_ref()
.zip(Some(&field.ty)))
.collect::<Vec<_>>()
}
_ => panic!("`Getter` has to be used only with structs"),
};
let funcs = fields.into_iter()
.fold(quote!(), |stream, (name, ty)| {
let fn_name = format_ident!("get_{name}");
quote! {
#stream
pub fn #fn_name(&self) -> &#ty {
&self.#name
}
}
});
let name = format_ident!("{}", ast.ident);
let gen = quote! {
impl #name {
#funcs
}
};
gen.into()
}

82
macros/src/lib.rs Normal file
View File

@@ -0,0 +1,82 @@
mod getter;
mod rt_doc;
use proc_macro::TokenStream;
/// Collect docs for each identifier of the struct or enum and generate
/// static methods to get the docs in runtime.
///
/// ```
/// use macros::RuntimeDoc;
///
/// /// Trololo
/// #[derive(RuntimeDoc)]
/// enum Foo1 {
/// /// Haha
/// Bar,
/// /// Hehe
/// Baz,
/// }
///
/// // Is equivalent to
/// /// Trololo
/// enum Foo2 {
/// /// Haha
/// Bar,
/// /// Hehe
/// Baz,
/// }
///
/// impl Foo2 {
/// pub fn doc() -> &'static str {
/// "Trololo"
/// }
/// pub fn doc_bar() -> &'static str {
/// "Haha"
/// }
/// pub fn doc_baz() -> &'static str {
/// "Hehe"
/// }
/// }
///
/// assert_eq!(Foo1::doc(), "Trololo");
/// assert_eq!(Foo1::doc_bar(), "Haha");
/// assert_eq!(Foo1::doc_baz(), "Hehe");
/// ```
#[proc_macro_derive(RuntimeDoc)]
pub fn parse_rt_doc(input: TokenStream) -> TokenStream {
rt_doc::derive(input)
}
/// Generate getters for each field of the struct.
///
/// ```
/// use macros::Getter;
///
/// #[derive(Getter)]
/// struct Foo1 {
/// x: usize,
/// y: String,
/// }
///
/// // Is equivalent to
/// struct Foo2 {
/// x: usize,
/// y: String,
/// }
///
/// impl Foo2 {
/// pub fn get_x(&self) -> &usize {
/// &self.x
/// }
/// pub fn get_y(&self) -> &String {
/// &self.y
/// }
/// }
///
/// assert_eq!(Foo1 { x: 42, y: Default::default() }.get_x(), &42);
/// ```
#[proc_macro_derive(Getter)]
pub fn parse_getter(input: TokenStream) -> TokenStream {
getter::derive(input)
}

78
macros/src/rt_doc.rs Normal file
View File

@@ -0,0 +1,78 @@
use proc_macro::TokenStream;
use std::iter;
use quote::{format_ident, quote};
use syn::{Attribute, Data, DataEnum, DataStruct, DeriveInput, Fields, Lit, Meta, MetaNameValue};
#[cfg(target_family = "unix")]
const OS_LINE_ENDING: &str = "\n";
#[cfg(target_family = "windows")]
const OS_LINE_ENDING: &str = "\r\n";
pub(crate) fn derive(input: TokenStream) -> TokenStream {
let ast: DeriveInput = syn::parse(input).unwrap();
let docs = match &ast.data {
Data::Struct(DataStruct { fields: Fields::Named(fields), .. }) => {
fields.named.iter()
.filter_map(|field| field.ident.clone()
.zip(Some(collect_docs(field.attrs.iter()))))
.collect::<Vec<_>>()
}
Data::Struct(_) => Default::default(),
Data::Enum(DataEnum { variants, .. }) => {
variants.iter()
.map(|variant| (
format_ident!("{}", variant.ident.to_string().to_lowercase()),
collect_docs(variant.attrs.iter())
))
.collect::<Vec<_>>()
}
_ => panic!("`RuntimeDoc` has to be used only with structs or enums"),
};
let funcs = iter::once((None, collect_docs(ast.attrs.iter())))
.chain(docs.into_iter().map(|(ident, doc)| (Some(ident), doc)))
.fold(quote!(), |stream, (name, doc)| {
if doc.is_empty() {
return stream;
}
let name = match name {
Some(x) => format_ident!("doc_{x}"),
None => format_ident!("doc"),
};
quote! {
#stream
pub fn #name() -> &'static str {
#doc
}
}
});
let name = format_ident!("{}", ast.ident);
let gen = quote! {
impl #name {
#funcs
}
};
gen.into()
}
fn collect_docs<'a, I>(attrs: I) -> String
where I: Iterator<Item=&'a Attribute>
{
attrs
.filter_map(|attr| attr.parse_meta().ok())
.filter(|meta| meta.path().is_ident("doc"))
.filter_map(|meta| match meta {
Meta::NameValue(
MetaNameValue {
lit: Lit::Str(lit), ..
}
) => Some(lit.value().trim().to_string()),
_ => None,
})
.filter(|doc| !doc.is_empty())
.collect::<Vec<_>>()
.join(OS_LINE_ENDING)
}

11
macros/tests/getter.rs Normal file
View File

@@ -0,0 +1,11 @@
use macros::Getter;
#[test]
fn test() {
#[derive(Getter)]
struct Foo {
x: usize,
}
assert_eq!(Foo { x: 42 }.get_x(), &42);
}

View File

@@ -0,0 +1,58 @@
use macros::RuntimeDoc;
#[test]
fn slashed() {
/// Trololo
#[allow(dead_code)]
#[derive(RuntimeDoc)]
enum Foo {
/// Haha
Bar,
/// Hehe
Baz,
}
assert_eq!(Foo::doc(), "Trololo");
assert_eq!(Foo::doc_bar(), "Haha");
assert_eq!(Foo::doc_baz(), "Hehe");
}
#[test]
fn doc_attr() {
#[doc = "Trololo"]
#[allow(dead_code)]
#[derive(RuntimeDoc)]
enum Foo {
#[doc = "Haha"]
Bar,
#[doc = "Hehe"]
Baz,
}
assert_eq!(Foo::doc(), "Trololo");
assert_eq!(Foo::doc_bar(), "Haha");
assert_eq!(Foo::doc_baz(), "Hehe");
}
#[test]
fn mixed() {
/// - How much watch?
#[doc = "- Six watch"]
#[allow(dead_code)]
#[derive(RuntimeDoc)]
enum Foo {
/// - Such much?
#[doc = "- For whom how"]
Bar,
/// - MGIMO finished?
#[doc = "- Ask"]
Baz,
}
assert_eq!(Foo::doc(), r#"- How much watch?
- Six watch"#);
assert_eq!(Foo::doc_bar(), r#"- Such much?
- For whom how"#);
assert_eq!(Foo::doc_baz(), r#"- MGIMO finished?
- Ask"#);
}

View File

@@ -0,0 +1,47 @@
use macros::RuntimeDoc;
#[test]
fn slashed() {
/// Trololo
#[allow(dead_code)]
#[derive(RuntimeDoc)]
struct Foo {
/// Haha
pub x: u32,
}
assert_eq!(Foo::doc(), "Trololo");
assert_eq!(Foo::doc_x(), "Haha");
}
#[test]
fn doc_attr() {
#[doc = "Trololo"]
#[allow(dead_code)]
#[derive(RuntimeDoc)]
struct Foo {
#[doc = "Haha"]
pub x: u32,
}
assert_eq!(Foo::doc(), "Trololo");
assert_eq!(Foo::doc_x(), "Haha");
}
#[test]
fn mixed() {
/// - How much watch?
#[doc = "- Six watch"]
#[allow(dead_code)]
#[derive(RuntimeDoc)]
struct Foo {
/// - Such much?
#[doc = "- For whom how"]
pub x: u32,
}
assert_eq!(Foo::doc(), r#"- How much watch?
- Six watch"#);
assert_eq!(Foo::doc_x(), r#"- Such much?
- For whom how"#);
}

View File

@@ -2,6 +2,8 @@
set -e
MANIFEST_FILE=$2
increment_version() {
major=${1%%.*}
minor=$(echo ${1#*.} | sed -e "s/\.[0-9]*//")
@@ -9,7 +11,7 @@ increment_version() {
echo ${major}.${minor}.$((revision+1))
}
VERSION=$(cat Cargo.toml | grep "version = " | head -n 1 | sed -e 's/version = "\(.*\)"/\1/')
VERSION=$(cat "$MANIFEST_FILE" | grep "version = " | head -n 1 | sed -e 's/version = "\(.*\)"/\1/')
argument_version=$1
if [ -z "$argument_version" ]
@@ -30,7 +32,7 @@ echo "New version is ${NEW_VERSION}"
set -x
sed -i -e "s/^version = \"${VERSION}\"$/version = \"${NEW_VERSION}\"/" Cargo.toml
sed -i -e "s/^version = \"${VERSION}\"$/version = \"${NEW_VERSION}\"/" "$MANIFEST_FILE"
# Update changelog
sed -i -e "3{

22
tools/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "vpn_endpoint_tools"
version = "0.1.0"
authors = ["Sergei Gunchenko <s.gunchenko@adguard.com>"]
edition = "2021"
[[bin]]
name = "setup_wizard"
path = "setup_wizard/main.rs"
[dependencies]
chrono = "=0.4.26"
clap = "=4.3.8"
dialoguer = "=0.10.4"
lazy_static = "=1.4.0"
once_cell = "=1.18.0"
rcgen = "=0.10.0"
serde = "=1.0.164"
toml = "=0.7.4"
toml_edit = "=0.19.10"
vpn_libs_endpoint = { version = "0.1", path = "../lib", features = ["rt_doc"] }
x509-parser = "=0.15.0"

View File

@@ -0,0 +1,143 @@
use std::iter::once;
use toml_edit::{Document, value};
use vpn_libs_endpoint::settings::{ForwardProtocolSettings, Http1Settings, Http2Settings, IcmpSettings, ListenProtocolSettings, MetricsSettings, QuicSettings, Settings};
use vpn_libs_endpoint::utils::IterJoin;
use crate::template_settings;
use crate::template_settings::ToTomlComment;
pub fn compose_document(settings: &Settings, credentials_path: &str) -> String {
once(compose_main_table(settings, credentials_path))
.chain(once(compose_forward_protocol_table(settings.get_forward_protocol())))
.chain(once(compose_listener_protocol_table(settings.get_listen_protocols())))
.chain(once(compose_icmp_table(settings.get_icmp().as_ref())))
.chain(once(compose_metrics_table(settings.get_metrics().as_ref())))
.join("\n")
}
fn compose_main_table(settings: &Settings, credentials_path: &str) -> String {
let mut doc: Document = template_settings::MAIN_TABLE.parse().unwrap();
doc["listen_address"] = value(settings.get_listen_address().to_string());
doc["credentials_file"] = value(credentials_path);
doc["ipv6_available"] = value(*settings.get_ipv6_available());
doc["allow_private_network_connections"] = value(*settings.get_allow_private_network_connections());
doc["tls_handshake_timeout_secs"] = value(settings.get_tls_handshake_timeout().as_secs() as i64);
doc["client_listener_timeout_secs"] = value(settings.get_client_listener_timeout().as_secs() as i64);
doc["connection_establishment_timeout_secs"] = value(settings.get_connection_establishment_timeout().as_secs() as i64);
doc["tcp_connections_timeout_secs"] = value(settings.get_tcp_connections_timeout().as_secs() as i64);
doc["udp_connections_timeout_secs"] = value(settings.get_udp_connections_timeout().as_secs() as i64);
doc.to_string()
}
fn compose_forward_protocol_table(settings: &ForwardProtocolSettings) -> String {
let spec = match settings {
ForwardProtocolSettings::Direct(_) => template_settings::DIRECT_FORWARDER_TABLE.clone(),
ForwardProtocolSettings::Socks5(x) => {
let mut doc: Document = template_settings::SOCKS_FORWARDER_TABLE.parse().unwrap();
let table = doc["forward_protocol"]["socks5"].as_table_mut().unwrap();
table["address"] = value(x.get_address().to_string());
table["extended_auth"] = value(*x.get_extended_auth());
doc.to_string()
}
};
format!("{}\n{}", *template_settings::FORWARD_PROTOCOL_COMMON_TABLE, spec)
}
fn compose_listener_protocol_table(settings: &ListenProtocolSettings) -> String {
once(template_settings::LISTENER_COMMON_TABLE.clone())
.chain(once(compose_http1_listener_table(settings.http1.as_ref())))
.chain(once(compose_http2_listener_table(settings.http2.as_ref())))
.chain(once(compose_quic_listener_table(settings.quic.as_ref())))
.join("\n")
}
fn compose_http1_listener_table(settings: Option<&Http1Settings>) -> String {
match settings {
Some(x) => {
let mut doc: Document = template_settings::HTTP1_LISTENER_TABLE.parse().unwrap();
let table = doc["listen_protocols"]["http1"].as_table_mut().unwrap();
table["upload_buffer_size"] = value(*x.get_upload_buffer_size() as i64);
doc.to_string()
}
None => template_settings::HTTP1_LISTENER_TABLE.to_toml_comment(),
}
}
fn compose_http2_listener_table(settings: Option<&Http2Settings>) -> String {
match settings {
Some(x) => {
let mut doc: Document = template_settings::HTTP2_LISTENER_TABLE.parse().unwrap();
let table = doc["listen_protocols"]["http2"].as_table_mut().unwrap();
table["initial_connection_window_size"] = value(*x.get_initial_connection_window_size() as i64);
table["initial_stream_window_size"] = value(*x.get_initial_stream_window_size() as i64);
table["max_concurrent_streams"] = value(*x.get_max_concurrent_streams() as i64);
table["max_frame_size"] = value(*x.get_max_frame_size() as i64);
table["header_table_size"] = value(*x.get_header_table_size() as i64);
doc.to_string()
}
None => template_settings::HTTP2_LISTENER_TABLE.to_toml_comment(),
}
}
fn compose_quic_listener_table(settings: Option<&QuicSettings>) -> String {
match settings {
Some(x) => {
let mut doc: Document = template_settings::QUIC_LISTENER_TABLE.parse().unwrap();
let table = doc["listen_protocols"]["quic"].as_table_mut().unwrap();
table["recv_udp_payload_size"] = value(*x.get_recv_udp_payload_size() as i64);
table["send_udp_payload_size"] = value(*x.get_send_udp_payload_size() as i64);
table["initial_max_data"] = value(*x.get_initial_max_data() as i64);
table["max_stream_data_bidi_local"] = value(*x.get_initial_max_stream_data_bidi_local() as i64);
table["max_stream_data_bidi_remote"] = value(*x.get_initial_max_stream_data_bidi_remote() as i64);
table["max_stream_data_uni"] = value(*x.get_initial_max_stream_data_uni() as i64);
table["max_streams_bidi"] = value(*x.get_initial_max_streams_bidi() as i64);
table["max_streams_uni"] = value(*x.get_initial_max_streams_uni() as i64);
table["max_connection_window"] = value(*x.get_max_connection_window() as i64);
table["max_stream_window"] = value(*x.get_max_stream_window() as i64);
table["disable_active_migration"] = value(*x.get_disable_active_migration());
table["enable_early_data"] = value(*x.get_enable_early_data());
table["message_queue_capacity"] = value(*x.get_message_queue_capacity() as i64);
doc.to_string()
}
None => template_settings::QUIC_LISTENER_TABLE.to_toml_comment(),
}
}
fn compose_icmp_table(settings: Option<&IcmpSettings>) -> String {
match settings {
Some(x) => {
let mut doc: Document = template_settings::ICMP_TABLE.parse().unwrap();
let table = doc["icmp"].as_table_mut().unwrap();
table["interface_name"] = value(x.get_interface_name());
table["request_timeout_secs"] = value(x.get_request_timeout().as_secs() as i64);
table["recv_message_queue_capacity"] = value(*x.get_recv_message_queue_capacity() as i64);
doc.to_string()
}
None => template_settings::ICMP_TABLE.to_toml_comment(),
}
}
fn compose_metrics_table(settings: Option<&MetricsSettings>) -> String {
match settings {
Some(x) => {
let mut doc: Document = template_settings::METRICS_TABLE.parse().unwrap();
let table = doc["metrics"].as_table_mut().unwrap();
table["address"] = value(x.get_address().to_string());
table["request_timeout_secs"] = value(x.get_request_timeout().as_secs() as i64);
doc.to_string()
}
None => template_settings::METRICS_TABLE.to_toml_comment(),
}
}

View File

@@ -0,0 +1,107 @@
use std::fs;
use toml_edit::{ArrayOfTables, Item, Key, Table};
use vpn_libs_endpoint::settings::{Http1Settings, Http2Settings, ListenProtocolSettings, QuicSettings, Settings};
use crate::Mode;
use crate::user_interaction::{ask_for_agreement, ask_for_input, ask_for_password, checked_overwrite, select_variant};
pub const DEFAULT_CREDENTIALS_PATH: &str = "credentials.toml";
pub struct Built {
pub settings: Settings,
pub credentials_path: String,
}
pub fn build() -> Built {
let builder = Settings::builder()
.listen_address(ask_for_input(
Settings::doc_listen_address(),
Some(crate::get_predefined_params().listen_address.clone()
.unwrap_or(Settings::default_listen_address().to_string())),
)).unwrap();
Built {
settings: builder
.listen_protocols(ListenProtocolSettings {
http1: Some(Http1Settings::builder().build()),
http2: Some(Http2Settings::builder().build()),
quic: Some(QuicSettings::builder().build()),
})
.build().expect("Couldn't build the library settings"),
credentials_path: build_authenticator(),
}
}
fn build_authenticator() -> String {
let path = if crate::get_mode() != Mode::NonInteractive
&& check_file_exists(".", DEFAULT_CREDENTIALS_PATH)
&& ask_for_agreement(&format!("Reuse the existing credentials file: {DEFAULT_CREDENTIALS_PATH}?"))
{
DEFAULT_CREDENTIALS_PATH.into()
} else {
let path = ask_for_input::<String>(
"Path to the credentials file",
Some(DEFAULT_CREDENTIALS_PATH.into()),
);
if checked_overwrite(&path, "Overwrite the existing credentials file?") {
println!("Let's create user credentials");
let users = build_user_list();
fs::write(&path, compose_credentials_content(users.into_iter()))
.expect("Couldn't write the credentials into a file");
println!("The user credentials are written to file: {}", path);
}
path
};
path
}
fn build_user_list() -> Vec<(String, String)> {
if let Some(x) = crate::get_predefined_params().credentials.clone() {
return vec![x];
}
let mut list = vec![(
ask_for_input::<String>("Username", None),
ask_for_password("Password"),
)];
loop {
if "no" == select_variant("Add one more user?", &["yes", "no"], Some(1)) {
break;
}
list.push((
ask_for_input::<String>("Username", None),
ask_for_password("Password"),
));
}
list
}
fn compose_credentials_content(clients: impl Iterator<Item=(String, String)>) -> String {
let mut doc = toml_edit::Document::new();
let x = clients
.map(|(u, p)| Table::from_iter(
std::iter::once(("username", u))
.chain(std::iter::once(("password", p)))
))
.collect::<ArrayOfTables>();
doc.insert_formatted(&Key::new("client"), Item::ArrayOfTables(x));
doc.to_string()
}
fn check_file_exists(path: &str, name: &str) -> bool {
match fs::read_dir(path) {
Ok(x) => x.filter_map(Result::ok)
.filter(|entry| entry.metadata()
.map(|meta| meta.is_file()).unwrap_or_default())
.any(|entry| Ok(name) == entry.file_name().into_string().as_ref().map(String::as_str)),
Err(_) => false,
}
}

188
tools/setup_wizard/main.rs Normal file
View File

@@ -0,0 +1,188 @@
use std::fs;
use std::sync::{Mutex, MutexGuard};
use vpn_libs_endpoint::settings::{Settings, TlsHostsSettings};
use crate::user_interaction::{ask_for_agreement, ask_for_input, checked_overwrite};
mod composer;
mod library_settings;
mod template_settings;
mod tls_hosts_settings;
mod user_interaction;
const MODE_PARAM_NAME: &str = "mode";
const MODE_NON_INTERACTIVE: &str = "non-interactive";
const LISTEN_ADDRESS_PARAM_NAME: &str = "addr";
const CREDENTIALS_PARAM_NAME: &str = "creds";
const HOSTNAME_PARAM_NAME: &str = "host";
const LIBRARY_SETTINGS_FILE_PARAM_NAME: &str = "lib_settings";
const TLS_HOSTS_SETTINGS_FILE_PARAM_NAME: &str = "hosts_settings";
#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
pub enum Mode {
NonInteractive,
Interactive,
}
static MODE: Mutex<Mode> = Mutex::new(Mode::Interactive);
pub fn get_mode() -> Mode {
*MODE.lock().unwrap()
}
#[derive(Default)]
pub struct PredefinedParameters {
listen_address: Option<String>,
credentials: Option<(String, String)>,
hostname: Option<String>,
library_settings_file: Option<String>,
tls_hosts_settings_file: Option<String>,
}
lazy_static::lazy_static! {
pub static ref PREDEFINED_PARAMS: Mutex<PredefinedParameters> = Mutex::default();
}
pub fn get_predefined_params() -> MutexGuard<'static, PredefinedParameters> {
PREDEFINED_PARAMS.lock().unwrap()
}
fn main() {
let args = clap::Command::new("VPN endpoint setup wizard")
.args(&[
clap::Arg::new(MODE_PARAM_NAME)
.short('m')
.long("mode")
.action(clap::ArgAction::Set)
.value_parser(["interactive", MODE_NON_INTERACTIVE])
.default_value("interactive")
.help(r#"Available wizard running modes:
* interactive - set up only the essential without deep diving into details
* non-interactive - prepare the setup without interacting with a user,
requires some parameters set up via command-line arguments
"#),
clap::Arg::new(LISTEN_ADDRESS_PARAM_NAME)
.short('a')
.long("address")
.action(clap::ArgAction::Set)
.value_parser(clap::builder::NonEmptyStringValueParser::new())
.required_if_eq(MODE_PARAM_NAME, MODE_NON_INTERACTIVE)
.help(Settings::doc_listen_address()),
clap::Arg::new(CREDENTIALS_PARAM_NAME)
.short('c')
.long("creds")
.action(clap::ArgAction::Set)
.value_parser(clap::builder::NonEmptyStringValueParser::new())
.required_if_eq(MODE_PARAM_NAME, MODE_NON_INTERACTIVE)
.help(r#"A user credentials formatted as: <username>:<password>.
Required in non-interactive mode."#),
clap::Arg::new(HOSTNAME_PARAM_NAME)
.short('n')
.long("hostname")
.action(clap::ArgAction::Set)
.value_parser(clap::builder::NonEmptyStringValueParser::new())
.required_if_eq(MODE_PARAM_NAME, MODE_NON_INTERACTIVE)
.help(r#"A hostname of the certificate for serving TLS connections.
Required in non-interactive mode."#),
clap::Arg::new(LIBRARY_SETTINGS_FILE_PARAM_NAME)
.long("lib-settings")
.action(clap::ArgAction::Set)
.value_parser(clap::builder::NonEmptyStringValueParser::new())
.required_if_eq(MODE_PARAM_NAME, MODE_NON_INTERACTIVE)
.help("Path to store the library settings file. Required in non-interactive mode."),
clap::Arg::new(TLS_HOSTS_SETTINGS_FILE_PARAM_NAME)
.long("hosts-settings")
.action(clap::ArgAction::Set)
.value_parser(clap::builder::NonEmptyStringValueParser::new())
.required_if_eq(MODE_PARAM_NAME, MODE_NON_INTERACTIVE)
.help("Path to store the TLS hosts settings file. Required in non-interactive mode."),
])
.get_matches();
*MODE.lock().unwrap() = match args.get_one::<String>(MODE_PARAM_NAME).map(String::as_str) {
None => Mode::Interactive,
Some(MODE_NON_INTERACTIVE) => Mode::NonInteractive,
Some("interactive") => Mode::Interactive,
_ => unreachable!(),
};
*PREDEFINED_PARAMS.lock().unwrap() = PredefinedParameters {
listen_address: args.get_one::<String>(LISTEN_ADDRESS_PARAM_NAME).cloned(),
credentials: args.get_one::<String>(CREDENTIALS_PARAM_NAME)
.map(|x| x.splitn(2, ':'))
.and_then(|mut x| x.next().zip(x.next()))
.map(|(a, b)| (a.to_string(), b.to_string())),
hostname: args.get_one::<String>(HOSTNAME_PARAM_NAME).cloned(),
library_settings_file: args.get_one::<String>(LIBRARY_SETTINGS_FILE_PARAM_NAME).cloned(),
tls_hosts_settings_file: args.get_one::<String>(TLS_HOSTS_SETTINGS_FILE_PARAM_NAME).cloned(),
};
println!("Welcome to the setup wizard");
let library_settings_path = find_existent_settings::<Settings>(".")
.and_then(|fname|
ask_for_agreement(&format!("Use the existing library settings {}?", fname))
.then_some(fname)
)
.or_else(|| {
println!("Let's build the library settings");
let built = library_settings::build();
println!("The library settings are successfully built\n");
let path = ask_for_input::<String>(
"Path to a file to store the library settings",
Some(get_predefined_params().library_settings_file.clone()
.unwrap_or("vpn.toml".into())),
);
if checked_overwrite(&path, "Overwrite the existing library settings file?") {
let doc = composer::compose_document(&built.settings, &built.credentials_path);
fs::write(&path, doc)
.expect("Couldn't write the library settings to a file");
}
Some(path)
});
let hosts_settings_path = find_existent_settings::<TlsHostsSettings>(".")
.and_then(|fname|
ask_for_agreement(&format!("Use the existing TLS hosts settings {}?", fname))
.then_some(fname)
)
.or_else(|| {
println!("Let's build the TLS hosts settings");
let settings = tls_hosts_settings::build();
println!("The TLS hosts settings are successfully built\n");
let path = ask_for_input::<String>(
"Path to a file to store the TLS hosts settings",
Some(get_predefined_params().tls_hosts_settings_file.clone()
.unwrap_or("hosts.toml".into())),
);
if checked_overwrite(&path, "Overwrite the existing TLS hosts settings file?") {
fs::write(
&path,
toml::ser::to_string(&settings)
.expect("Couldn't serialize the TLS hosts settings"),
).expect("Couldn't write the TLS hosts settings to a file");
}
Some(path)
});
if let (Some(l), Some(h)) = (library_settings_path, hosts_settings_path) {
println!("To start endpoint, run the following command:");
println!("\tvpn_endpoint {} {}", l, h);
}
println!("To see full set of the available options, run the following command:");
println!("\tvpn_endpoint -h");
}
fn find_existent_settings<T: serde::de::DeserializeOwned>(path: &str) -> Option<String> {
(get_mode() != Mode::NonInteractive)
.then(|| fs::read_dir(path).ok()?
.filter_map(Result::ok)
.filter(|entry| entry.metadata()
.map(|meta| meta.is_file()).unwrap_or_default())
.filter_map(|entry| entry.file_name().into_string().ok())
.filter_map(|fname| fs::read_to_string(&fname).ok().zip(Some(fname)))
.find_map(|(content, fname)| toml::from_str::<T>(&content).map(|_| fname).ok())
)
.flatten()
}

View File

@@ -0,0 +1,250 @@
use once_cell::sync::Lazy;
use vpn_libs_endpoint::settings::{ForwardProtocolSettings, Http1Settings, Http2Settings, IcmpSettings, ListenProtocolSettings, MetricsSettings, QuicSettings, Settings, Socks5ForwarderSettings};
use vpn_libs_endpoint::utils::IterJoin;
pub trait ToTomlComment {
/// Prepend each line of string with "# " turning
/// the whole string it into TOML comment.
fn to_toml_comment(&self) -> String;
}
impl ToTomlComment for &str {
fn to_toml_comment(&self) -> String {
self.lines()
.map(|x| format!("# {x}"))
.join("\n")
}
}
impl ToTomlComment for String {
fn to_toml_comment(&self) -> String {
self.as_str().to_toml_comment()
}
}
pub static MAIN_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}
listen_address = ""
# The path to a TOML file in the following format:
#
# ```
# [[client]]
# username = "a"
# password = "b"
#
# [[client]]
# ...
# ```
credentials_file = "{}"
{}
ipv6_available = {}
{}
allow_private_network_connections = {}
{}
tls_handshake_timeout_secs = {}
{}
client_listener_timeout_secs = {}
{}
connection_establishment_timeout_secs = {}
{}
tcp_connections_timeout_secs = {}
{}
udp_connections_timeout_secs = {}
"#,
Settings::doc_listen_address().to_toml_comment(),
crate::library_settings::DEFAULT_CREDENTIALS_PATH,
Settings::doc_ipv6_available().to_toml_comment(),
Settings::default_ipv6_available(),
Settings::doc_allow_private_network_connections().to_toml_comment(),
Settings::default_allow_private_network_connections(),
format!("{}. In seconds.", Settings::doc_tls_handshake_timeout()).to_toml_comment(),
Settings::default_tls_handshake_timeout().as_secs(),
format!("{}. In seconds.", Settings::doc_client_listener_timeout()).to_toml_comment(),
Settings::default_client_listener_timeout().as_secs(),
format!("{} In seconds.", Settings::doc_connection_establishment_timeout()).to_toml_comment(),
Settings::default_connection_establishment_timeout().as_secs(),
format!("{}. In seconds.", Settings::doc_tcp_connections_timeout()).to_toml_comment(),
Settings::default_tcp_connections_timeout().as_secs(),
format!("{}. In seconds.", Settings::doc_udp_connections_timeout()).to_toml_comment(),
Settings::default_udp_connections_timeout().as_secs(),
));
pub static FORWARD_PROTOCOL_COMMON_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}.
# Possible values:
# * direct: a direct forwarder routes a connection directly to its target host,
# * socks5: a SOCKS5 forwarder routes a connection though a SOCKS5 proxy.
# Default is direct
[forward_protocol]
"#,
ForwardProtocolSettings::doc().to_toml_comment(),
));
pub static DIRECT_FORWARDER_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}.
[forward_protocol.direct]"#,
ForwardProtocolSettings::doc_direct().to_toml_comment(),
));
pub static SOCKS_FORWARDER_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}.
[forward_protocol.socks5]
{}
address = "127.0.0.1:1080"
{}
extended_auth = false"#,
ForwardProtocolSettings::doc_socks5().to_toml_comment(),
Socks5ForwarderSettings::doc_address().to_toml_comment(),
Socks5ForwarderSettings::doc_extended_auth().to_toml_comment(),
));
pub static LISTENER_COMMON_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}.
# Possible values:
# * http1: enables HTTP1 codec,
# * http2: enables HTTP2 codec,
# * quic: enables QUIC/HTTP3 codec.
# At least one listener codec MUST be specified.
[listen_protocols]
"#,
ListenProtocolSettings::doc().to_toml_comment(),
));
pub static HTTP1_LISTENER_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}.
[listen_protocols.http1]
{}
upload_buffer_size = {}
"#,
Http1Settings::doc().to_toml_comment(),
Http1Settings::doc_upload_buffer_size().to_toml_comment(),
Http1Settings::default_upload_buffer_size(),
));
pub static HTTP2_LISTENER_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}.
[listen_protocols.http2]
{}
initial_connection_window_size = {}
{}
initial_stream_window_size = {}
{}
max_concurrent_streams = {}
{}
max_frame_size = {}
{}
header_table_size = {}
"#,
Http2Settings::doc().to_toml_comment(),
Http2Settings::doc_initial_connection_window_size().to_toml_comment(),
Http2Settings::default_initial_connection_window_size(),
Http2Settings::doc_initial_stream_window_size().to_toml_comment(),
Http2Settings::default_initial_stream_window_size(),
Http2Settings::doc_max_concurrent_streams().to_toml_comment(),
Http2Settings::default_max_concurrent_streams(),
Http2Settings::doc_max_frame_size().to_toml_comment(),
Http2Settings::default_max_frame_size(),
Http2Settings::doc_header_table_size().to_toml_comment(),
Http2Settings::default_header_table_size(),
));
pub static QUIC_LISTENER_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}.
[listen_protocols.quic]
{}
recv_udp_payload_size = {}
{}
send_udp_payload_size = {}
{}
initial_max_data = {}
{}
max_stream_data_bidi_local = {}
{}
max_stream_data_bidi_remote = {}
{}
max_stream_data_uni = {}
{}
max_streams_bidi = {}
{}
max_streams_uni = {}
{}
max_connection_window = {}
{}
max_stream_window = {}
{}
disable_active_migration = {}
{}
enable_early_data = {}
{}
message_queue_capacity = {}
"#,
QuicSettings::doc().to_toml_comment(),
QuicSettings::doc_recv_udp_payload_size().to_toml_comment(),
QuicSettings::default_recv_udp_payload_size(),
QuicSettings::doc_send_udp_payload_size().to_toml_comment(),
QuicSettings::default_send_udp_payload_size(),
QuicSettings::doc_initial_max_data().to_toml_comment(),
QuicSettings::default_initial_max_data(),
QuicSettings::doc_initial_max_stream_data_bidi_local().to_toml_comment(),
QuicSettings::default_initial_max_stream_data_bidi_local(),
QuicSettings::doc_initial_max_stream_data_bidi_remote().to_toml_comment(),
QuicSettings::default_initial_max_stream_data_bidi_remote(),
QuicSettings::doc_initial_max_stream_data_uni().to_toml_comment(),
QuicSettings::default_initial_max_stream_data_uni(),
QuicSettings::doc_initial_max_streams_bidi().to_toml_comment(),
QuicSettings::default_initial_max_streams_bidi(),
QuicSettings::doc_initial_max_streams_uni().to_toml_comment(),
QuicSettings::default_initial_max_streams_uni(),
QuicSettings::doc_max_connection_window().to_toml_comment(),
QuicSettings::default_max_connection_window(),
QuicSettings::doc_max_stream_window().to_toml_comment(),
QuicSettings::default_max_stream_window(),
QuicSettings::doc_disable_active_migration().to_toml_comment(),
QuicSettings::default_disable_active_migration(),
QuicSettings::doc_enable_early_data().to_toml_comment(),
QuicSettings::default_enable_early_data(),
QuicSettings::doc_message_queue_capacity().to_toml_comment(),
QuicSettings::default_message_queue_capacity(),
));
pub static ICMP_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}
[icmp]
{}
interface_name = "{}"
{}
request_timeout_secs = {}
{}
recv_message_queue_capacity = {}
"#,
IcmpSettings::doc().to_toml_comment(),
IcmpSettings::doc_interface_name().to_toml_comment(),
IcmpSettings::default_interface_name(),
IcmpSettings::doc_request_timeout().to_toml_comment(),
IcmpSettings::default_request_timeout().as_secs(),
IcmpSettings::doc_recv_message_queue_capacity().to_toml_comment(),
IcmpSettings::default_message_queue_capacity(),
));
pub static METRICS_TABLE: Lazy<String> = Lazy::new(|| format!(
r#"{}
[metrics]
{}
address = "{}"
{}
request_timeout_secs = {}
"#,
MetricsSettings::doc().to_toml_comment(),
MetricsSettings::doc_address().to_toml_comment(),
MetricsSettings::default_listen_address(),
MetricsSettings::doc_request_timeout().to_toml_comment(),
MetricsSettings::default_request_timeout().as_secs(),
));

View File

@@ -0,0 +1,194 @@
use std::fs;
use std::io::Write;
use std::path::Path;
use chrono::Datelike;
use rcgen::DnType;
use x509_parser::extensions::GeneralName;
use vpn_libs_endpoint::settings::{TlsHostInfo, TlsHostsSettings};
use vpn_libs_endpoint::utils;
use vpn_libs_endpoint::utils::Either;
use crate::Mode;
use crate::user_interaction::{ask_for_agreement, ask_for_input, checked_overwrite};
const DEFAULT_CERTIFICATE_DURATION_DAYS: u64 = 365;
const DEFAULT_CERTIFICATE_FOLDER: &str = "certs";
const DEFAULT_HOSTNAME: &str = "vpn.endpoint";
pub fn build() -> TlsHostsSettings {
let cert = lookup_existent_cert()
.and_then(|x| (crate::get_mode() != Mode::NonInteractive
&& ask_for_agreement(&format!("Use an existent certificate? {:?}", x)))
.then_some(x))
.or_else(|| (crate::get_mode() == Mode::NonInteractive
|| ask_for_agreement("Generate a self-signed certificate?"))
.then(generate_cert).flatten())
.or_else(|| {
let pair = ask_for_input::<String>(
"Path to key/certificate pair. Divide by space if they are in separate files.\n",
None,
);
let mut iter = pair.splitn(2, char::is_whitespace);
let x = match (iter.next().unwrap(), iter.next()) {
(a, None) => Either::Left(a),
(a, Some(b)) => Either::Right((a, b)),
};
let x = parse_cert(x);
if x.is_none() {
println!("Couldn't parse the provided key/certificate pair");
}
x
});
let hostname = cert.as_ref().map(|x| x.common_name.clone())
.unwrap_or_else(|| ask_for_input::<String>(
"Endpoint hostname (used for serving TLS connections)",
Some(crate::get_predefined_params().hostname.clone()
.unwrap_or_else(|| DEFAULT_HOSTNAME.into())),
));
TlsHostsSettings::builder()
.main_hosts(vec![TlsHostInfo {
hostname: hostname.clone(),
cert_chain_path: cert.as_ref().unwrap().cert_path.clone(),
private_key_path: cert.as_ref().unwrap().key_path.clone(),
}])
.ping_hosts(vec![TlsHostInfo {
hostname: format!("ping.{}", hostname),
cert_chain_path: cert.as_ref().unwrap().cert_path.clone(),
private_key_path: cert.as_ref().unwrap().key_path.clone(),
}])
.speedtest_hosts(vec![TlsHostInfo {
hostname: format!("speed.{}", hostname),
cert_chain_path: cert.as_ref().unwrap().cert_path.clone(),
private_key_path: cert.as_ref().unwrap().key_path.clone(),
}])
.build().expect("Couldn't build TLS hosts settings")
}
#[derive(Debug)]
struct Cert {
common_name: String,
#[allow(dead_code)] // needed only for logging
alt_names: Vec<String>,
#[allow(dead_code)] // needed only for logging
expiration_date: String,
cert_path: String,
key_path: String,
}
fn lookup_existent_cert() -> Option<Cert> {
let files = fs::read_dir(DEFAULT_CERTIFICATE_FOLDER).ok()?
.filter_map(Result::ok)
.filter(|entry| entry.metadata().map(|meta| meta.is_file()).unwrap_or_default())
.filter_map(|entry| entry.path().to_str().map(String::from))
.collect::<Vec<_>>();
let cert_key_pair = match files.as_slice() {
[a] => Either::Left(a.as_str()),
[a, b] => Either::Right((a.as_str(), b.as_str())),
_ => return None,
};
parse_cert(cert_key_pair)
}
fn parse_cert(cert: Either<&str, (&str, &str)>) -> Option<Cert> {
let (chain, cert_path, key_path) = cert.map(
|pair| Some((
utils::load_private_key(pair).and_then(|_| utils::load_certs(pair)).ok()?,
pair,
pair,
)),
|(a, b)|
match (
utils::load_certs(a), utils::load_private_key(b),
utils::load_certs(b), utils::load_private_key(a),
) {
(Ok(chain), Ok(_), _, _) => Some((chain, a, b)),
(_, _, Ok(chain), Ok(_)) => Some((chain, b, a)),
_ => None,
},
)?;
let cert = x509_parser::parse_x509_certificate(chain.first()?.0.as_slice()).ok()?.1;
Some(Cert {
common_name: cert.validity.is_valid()
.then(|| {
let x = cert.subject.to_string();
x.as_str()
.strip_prefix("CN=")
.map(String::from)
.unwrap_or(x)
})?,
alt_names: cert.subject_alternative_name().ok().flatten()
.map(|x| x.value.general_names.iter().map(GeneralName::to_string).collect())
.unwrap_or_default(),
expiration_date: cert.validity.not_after.to_string(),
cert_path: cert_path.into(),
key_path: key_path.into(),
})
}
fn generate_cert() -> Option<Cert> {
let (common_name, alt_names) = {
println!("Let's generate a self-signed certificate.");
let name = ask_for_input::<String>(
"Endpoint hostname (used for serving TLS connections)",
Some(crate::get_predefined_params().hostname.clone()
.unwrap_or_else(|| DEFAULT_HOSTNAME.into())),
);
(name.clone(), vec![name.clone(), format!("*.{}", name)])
};
let mut params = rcgen::CertificateParams::new(alt_names.clone());
params.alg = &rcgen::PKCS_ECDSA_P256_SHA256;
let now = chrono::Local::now();
let end_date = now.checked_add_days(
chrono::Days::new(DEFAULT_CERTIFICATE_DURATION_DAYS)
).unwrap();
params.not_before = rcgen::date_time_ymd(now.year(), now.month() as u8, now.day() as u8);
params.not_after = rcgen::date_time_ymd(end_date.year(), end_date.month() as u8, end_date.day() as u8);
params.distinguished_name.push(DnType::CommonName, &common_name);
let cert = rcgen::Certificate::from_params(params).unwrap();
let cert_path = format!("{DEFAULT_CERTIFICATE_FOLDER}/cert.pem");
if !checked_overwrite(&cert_path, "Overwrite the existing certificate file?") {
return None;
}
let key_path = format!("{DEFAULT_CERTIFICATE_FOLDER}/key.pem");
if !checked_overwrite(&cert_path, "Overwrite the existing private key file?") {
return None;
}
fs::create_dir_all(Path::new(&cert_path).parent().unwrap())
.expect("Couldn't create certificate directory path");
fs::write(&cert_path, cert.serialize_pem().unwrap())
.expect("Couldn't write the certificate into a file");
println!("The generated certificate is stored in file: {}", cert_path);
fs::create_dir_all(Path::new(&cert_path).parent().unwrap())
.expect("Couldn't create private key directory path");
if key_path != cert_path {
fs::write(key_path.clone(), cert.serialize_private_key_pem())
.expect("Couldn't write the private key into a file");
} else {
fs::OpenOptions::new()
.write(true)
.append(true)
.open(key_path.clone())
.expect("Couldn't open a file for writing the private key")
.write_all(cert.serialize_private_key_pem().as_bytes())
.expect("Couldn't write the private key into a file");
}
println!("The generated private key is stored in file: {}", key_path);
Some(Cert {
common_name,
alt_names,
expiration_date: end_date.to_string(),
cert_path,
key_path,
})
}

View File

@@ -0,0 +1,85 @@
use std::fs;
use std::ops::Deref;
use std::path::Path;
use std::str::FromStr;
use dialoguer::{Confirm, Input, Password, Select};
use dialoguer::theme::ColorfulTheme;
use once_cell::sync::Lazy;
use crate::Mode;
pub static THEME: Lazy<ColorfulTheme> = Lazy::new(ColorfulTheme::default);
/// Ask user to enter a value.
/// If [`default`] is [`Some`], suggest the value in the prompt.
pub fn ask_for_input<T>(message: &str, default: Option<T>) -> T
where
T: Clone + Default + FromStr + ToString,
<T as FromStr>::Err: ToString,
{
if crate::get_mode() == Mode::NonInteractive {
return default.expect("Expecting a user input in non-interactive mode");
}
if default.is_some() {
Input::<T>::with_theme(THEME.deref())
.with_prompt(message)
.show_default(default.is_some())
.default(default.unwrap_or_default())
.interact().unwrap()
} else {
Input::<T>::with_theme(THEME.deref())
.with_prompt(message)
.interact().unwrap()
}
}
/// Ask if one wants to do something (yes/no)
pub fn ask_for_agreement(message: &str) -> bool {
assert_ne!(crate::get_mode(), Mode::NonInteractive, "Expecting a user input in non-interactive mode");
Confirm::with_theme(THEME.deref())
.with_prompt(message)
.default(false)
.show_default(true)
.interact()
.unwrap()
}
/// Ask user to enter a password in a secure way
pub fn ask_for_password(message: &str) -> String {
assert_ne!(crate::get_mode(), Mode::NonInteractive, "Expecting a user input in non-interactive mode");
Password::with_theme(THEME.deref())
.with_prompt(message)
.interact()
.unwrap()
}
/// Check if a file exists and if it does, ask if one wants to overwrite it
pub fn checked_overwrite(path: &str, message: &str) -> bool {
crate::get_mode() == Mode::NonInteractive
|| !fs::metadata(Path::new(&path)).as_ref()
.map(fs::Metadata::is_file)
.unwrap_or_default()
|| ask_for_agreement(message)
}
/// Ask user to select a variant. Returns index of the selected variant.
pub fn select_index<S: Into<String>>(prompt: S, variants: &[&str], default: Option<usize>) -> usize {
if crate::get_mode() == Mode::NonInteractive {
return default.expect("Expecting a user input in non-interactive mode");
}
Select::with_theme(THEME.deref())
.with_prompt(prompt)
.items(variants)
.report(true)
.default(default.unwrap_or_default())
.interact_opt().expect("Interaction failure")
.expect("None selected")
}
/// Ask user to select a variant. Returns the selected variant.
pub fn select_variant<'a, S>(prompt: S, variants: &[&'a str], default: Option<usize>) -> &'a str
where S: Into<String>
{
variants[select_index(prompt, variants, default)]
}