Support so called conjunction filters
This introduces thee so called conjunction filters and therefore restructures the configuration file. The most obvious changes from an users perspective are that the `filters` field was renamed to `filter` and can, from now on, only support a single filter at first level. Thats why now different filter types are implemented, please consult the readme for further information on their usage. To reflect the changes the readme file is updated as well as the example config file contained in this repository. This is related to #8
This commit is contained in:
parent
891a8a70ae
commit
43b7fd5625
3 changed files with 185 additions and 96 deletions
62
README.md
62
README.md
|
@ -1,6 +1,6 @@
|
||||||
# Webhookey
|
# Webhookey
|
||||||
Webhookey is a webserver listening for requests as for example sent by
|
Webhookey is a web server listening for requests as for example sent by
|
||||||
gitea's webhooks. Further, Webhookey allows you to specifiy rules
|
gitea's webhooks. Further, Webhookey allows you to specify rules
|
||||||
which are matched against the data received to trigger certain
|
which are matched against the data received to trigger certain
|
||||||
actions.
|
actions.
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ Further, for Rocket we need to have the nightly toolchain installed:
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build Webhookey
|
### Build Webhookey
|
||||||
The webhookey project can be built for development:
|
The Webhookey project can be built for development:
|
||||||
``` sh
|
``` sh
|
||||||
cargo b
|
cargo b
|
||||||
```
|
```
|
||||||
|
@ -27,7 +27,7 @@ or for releasing:
|
||||||
|
|
||||||
### Install Webhookey
|
### Install Webhookey
|
||||||
When a Rust toolchain installed you can also install Webhookey
|
When a Rust toolchain installed you can also install Webhookey
|
||||||
directly without cloning it manualy:
|
directly without cloning it manually:
|
||||||
``` sh
|
``` sh
|
||||||
cargo install --git https://git.onders.org/finga/webhookey.git webhookey
|
cargo install --git https://git.onders.org/finga/webhookey.git webhookey
|
||||||
```
|
```
|
||||||
|
@ -51,7 +51,7 @@ you built.
|
||||||
Configuration syntax is YAML and it's paths as well as it's
|
Configuration syntax is YAML and it's paths as well as it's
|
||||||
configuration format is described in the following sections.
|
configuration format is described in the following sections.
|
||||||
|
|
||||||
### Configuration paths
|
### Configuration Paths
|
||||||
Following locations are checked for a configuration file:
|
Following locations are checked for a configuration file:
|
||||||
- `/etc/webhookey/config.yml`
|
- `/etc/webhookey/config.yml`
|
||||||
- `<config_dir>/webhookey/config.yml`
|
- `<config_dir>/webhookey/config.yml`
|
||||||
|
@ -72,7 +72,7 @@ consists of the following fields:
|
||||||
source addresses or ranges.
|
source addresses or ranges.
|
||||||
- signature: Name of the HTTP header field containing the signature.
|
- signature: Name of the HTTP header field containing the signature.
|
||||||
- secrets: List of secrets.
|
- secrets: List of secrets.
|
||||||
- filters: List of filters.
|
- filter: Tree of filters.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```yaml
|
```yaml
|
||||||
|
@ -87,10 +87,18 @@ hooks:
|
||||||
secrets:
|
secrets:
|
||||||
- secret_key_01
|
- secret_key_01
|
||||||
- secret_key_02
|
- secret_key_02
|
||||||
filters:
|
filter:
|
||||||
match_ref:
|
or:
|
||||||
pointer: /ref
|
- json:
|
||||||
regex: refs/heads/master
|
pointer: /ref
|
||||||
|
regex: refs/heads/master
|
||||||
|
- and:
|
||||||
|
- json:
|
||||||
|
pointer: /ref
|
||||||
|
regex: refs/heads/a_branch
|
||||||
|
- json:
|
||||||
|
pointer: /after
|
||||||
|
regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Command
|
##### Command
|
||||||
|
@ -137,17 +145,25 @@ Set the name of the HTTP header field containing the HMAC signature.
|
||||||
Configure a list of secrets to validate the hook.
|
Configure a list of secrets to validate the hook.
|
||||||
|
|
||||||
##### Filter
|
##### Filter
|
||||||
Each filter must have following fields:
|
Filter can be either a concrete filter or a conjunction
|
||||||
- pointer: pointer to the JSON field according to [RFC
|
filter. Concrete filters return either true or false on specific
|
||||||
6901](https://tools.ietf.org/html/rfc6901)
|
constraints. Conjunction filters contain lists of filters which are
|
||||||
- regex: regular expression which has to match the field pointed to by
|
evaluated and combined based on the type. The result is either used
|
||||||
the pointer
|
for parent conjunction filters or, if at the root, used to decide if a
|
||||||
|
hook should be executed.
|
||||||
|
|
||||||
# TODOs
|
###### Conjunction Filters
|
||||||
## Use `clap` to parse command line arguments
|
Conjunction filters contain lists of other filters.
|
||||||
## Configure rocket via config.yml
|
- `and`: Logical conjunction.
|
||||||
## Security
|
- `or`: Logical disjunction.
|
||||||
### https support
|
|
||||||
basically supported, but related to "Configure rocket via config.yml".
|
###### Concrete Filters
|
||||||
### Authentication features
|
- `json`:
|
||||||
## Use proptest or quickcheck for tests of parsers
|
|
||||||
|
The `json` filter matches a regular expression on a field from the
|
||||||
|
received JSON data.
|
||||||
|
|
||||||
|
- pointer: Pointer to the JSON field according to [RFC
|
||||||
|
6901](https://tools.ietf.org/html/rfc6901).
|
||||||
|
- regex: Regular expression which has to match the field pointed to
|
||||||
|
by the pointer.
|
||||||
|
|
31
config.yml
31
config.yml
|
@ -9,8 +9,8 @@ hooks:
|
||||||
secrets:
|
secrets:
|
||||||
- secret_key_01
|
- secret_key_01
|
||||||
- secret_key_02
|
- secret_key_02
|
||||||
filters:
|
filter:
|
||||||
match_ref:
|
json:
|
||||||
pointer: /ref
|
pointer: /ref
|
||||||
regex: refs/heads/master
|
regex: refs/heads/master
|
||||||
hook2:
|
hook2:
|
||||||
|
@ -22,19 +22,24 @@ hooks:
|
||||||
secrets:
|
secrets:
|
||||||
- secret_key_01
|
- secret_key_01
|
||||||
- secret_key_02
|
- secret_key_02
|
||||||
filters:
|
filter:
|
||||||
match_ref:
|
and:
|
||||||
pointer: /ref
|
- json:
|
||||||
regex: refs/heads/master
|
pointer: /ref
|
||||||
|
regex: refs/heads/master
|
||||||
|
- json:
|
||||||
|
pointer: /after
|
||||||
|
regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e
|
||||||
hook3:
|
hook3:
|
||||||
command: /usr/bin/local/script_xyz.sh
|
command: /usr/bin/local/script_xyz.sh
|
||||||
signature: X-Gitea-Signature
|
signature: X-Gitea-Signature
|
||||||
secrets:
|
secrets:
|
||||||
- secret_key03
|
- secret_key03
|
||||||
filters:
|
filter:
|
||||||
match_ref:
|
or:
|
||||||
pointer: /ref
|
- json:
|
||||||
regex: refs/heads/master
|
pointer: /ref
|
||||||
match_after:
|
regex: refs/heads/master
|
||||||
pointer: /after
|
- json:
|
||||||
regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e
|
pointer: /after
|
||||||
|
regex: f6e5fe4fe37df76629112d55cc210718b6a55e7e
|
||||||
|
|
188
src/main.rs
188
src/main.rs
|
@ -71,6 +71,95 @@ struct Config {
|
||||||
hooks: HashMap<String, Hook>,
|
hooks: HashMap<String, Hook>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct JsonFilter {
|
||||||
|
pointer: String,
|
||||||
|
regex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonFilter {
|
||||||
|
fn evaluate(&self, data: &serde_json::Value) -> Result<bool, WebhookeyError> {
|
||||||
|
trace!(
|
||||||
|
"Matching `{}` on `{}` from received json",
|
||||||
|
&self.regex,
|
||||||
|
&self.pointer,
|
||||||
|
);
|
||||||
|
|
||||||
|
let regex = Regex::new(&self.regex).map_err(WebhookeyError::Regex)?;
|
||||||
|
|
||||||
|
if let Some(value) = data.pointer(&self.pointer) {
|
||||||
|
let value = get_string(value)?;
|
||||||
|
|
||||||
|
if regex.is_match(&value) {
|
||||||
|
debug!("Regex `{}` for `{}` matches", &self.regex, &self.pointer);
|
||||||
|
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Regex `{}` for `{}` does not match",
|
||||||
|
&self.regex, &self.pointer
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields, rename_all = "lowercase")]
|
||||||
|
enum FilterType {
|
||||||
|
And(Vec<FilterType>),
|
||||||
|
Or(Vec<FilterType>),
|
||||||
|
#[serde(rename = "json")]
|
||||||
|
JsonFilter(JsonFilter),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilterType {
|
||||||
|
fn evaluate(
|
||||||
|
&self,
|
||||||
|
request: &Request,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> Result<bool, WebhookeyError> {
|
||||||
|
match self {
|
||||||
|
FilterType::And(filters) => {
|
||||||
|
let (results, errors): (Vec<_>, Vec<_>) = filters
|
||||||
|
.iter()
|
||||||
|
.map(|filter| filter.evaluate(request, data))
|
||||||
|
.partition(Result::is_ok);
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(results.iter().all(|r| *r.as_ref().unwrap())) // should never fail
|
||||||
|
} else {
|
||||||
|
errors.iter().for_each(|e| {
|
||||||
|
error!("Could not evaluate Filter: {}", e.as_ref().unwrap_err())
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(WebhookeyError::InvalidFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilterType::Or(filters) => {
|
||||||
|
let (results, errors): (Vec<_>, Vec<_>) = filters
|
||||||
|
.iter()
|
||||||
|
.map(|filter| filter.evaluate(request, data))
|
||||||
|
.partition(Result::is_ok);
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(results.iter().any(|r| *r.as_ref().unwrap())) // should never fail
|
||||||
|
} else {
|
||||||
|
errors.iter().for_each(|e| {
|
||||||
|
error!("Could not evaluate Filter: {}", e.as_ref().unwrap_err())
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(WebhookeyError::InvalidFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilterType::JsonFilter(filter) => filter.evaluate(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct Hook {
|
struct Hook {
|
||||||
|
@ -78,14 +167,7 @@ struct Hook {
|
||||||
signature: String,
|
signature: String,
|
||||||
ip_filter: Option<IpFilter>,
|
ip_filter: Option<IpFilter>,
|
||||||
secrets: Vec<String>,
|
secrets: Vec<String>,
|
||||||
filters: HashMap<String, Filter>,
|
filter: FilterType,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct Filter {
|
|
||||||
pointer: String,
|
|
||||||
regex: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
|
@ -96,10 +178,16 @@ enum WebhookeyError {
|
||||||
Unauthorized(IpAddr),
|
Unauthorized(IpAddr),
|
||||||
#[error("Unmatched hook from `{0}`")]
|
#[error("Unmatched hook from `{0}`")]
|
||||||
UnmatchedHook(IpAddr),
|
UnmatchedHook(IpAddr),
|
||||||
|
#[error("Could not find field refered to in parameter `{0}`")]
|
||||||
|
InvalidParameterPointer(String),
|
||||||
|
#[error("Could not evaluate filter request")]
|
||||||
|
InvalidFilter,
|
||||||
#[error("IO Error")]
|
#[error("IO Error")]
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
#[error("Serde Error")]
|
#[error("Serde Error")]
|
||||||
Serde(serde_json::Error),
|
Serde(serde_json::Error),
|
||||||
|
#[error("Regex Error")]
|
||||||
|
Regex(regex::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -198,7 +286,7 @@ fn replace_parameters(
|
||||||
Ok(result.join(""))
|
Ok(result.join(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_string(value: &serde_json::Value) -> Result<String> {
|
fn get_string(value: &serde_json::Value) -> Result<String, WebhookeyError> {
|
||||||
match &value {
|
match &value {
|
||||||
serde_json::Value::Bool(bool) => Ok(bool.to_string()),
|
serde_json::Value::Bool(bool) => Ok(bool.to_string()),
|
||||||
serde_json::Value::Number(number) => Ok(number.to_string()),
|
serde_json::Value::Number(number) => Ok(number.to_string()),
|
||||||
|
@ -210,50 +298,29 @@ fn get_string(value: &serde_json::Value) -> Result<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filter_match(
|
fn get_command(
|
||||||
hook_name: &str,
|
hook_name: &str,
|
||||||
hook: &Hook,
|
hook: &Hook,
|
||||||
filter_name: &str,
|
|
||||||
filter: &Filter,
|
|
||||||
request: &Request,
|
request: &Request,
|
||||||
data: &mut serde_json::Value,
|
data: &mut serde_json::Value,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<String> {
|
||||||
trace!("Matching filter `{}` of hook `{}`", filter_name, hook_name);
|
trace!("Replacing parameters for command of hook `{}`", hook_name);
|
||||||
|
|
||||||
let regex = Regex::new(&filter.regex)?;
|
|
||||||
|
|
||||||
for parameter in get_parameter(&hook.command)? {
|
for parameter in get_parameter(&hook.command)? {
|
||||||
let parameter = parameter.trim();
|
let parameter = parameter.trim();
|
||||||
|
|
||||||
if let Some(json_value) = data.pointer(parameter) {
|
if let Some(json_value) = data.pointer(parameter) {
|
||||||
*data.pointer_mut(parameter).unwrap() =
|
*data
|
||||||
|
.pointer_mut(parameter)
|
||||||
|
.ok_or_else(|| WebhookeyError::InvalidParameterPointer(parameter.to_string()))? =
|
||||||
serde_json::Value::String(get_string(json_value)?);
|
serde_json::Value::String(get_string(json_value)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(value) = data.pointer(&filter.pointer) {
|
replace_parameters(&hook.command, &request.headers(), data)
|
||||||
let value = get_string(value)?;
|
|
||||||
|
|
||||||
if regex.is_match(&value) {
|
|
||||||
debug!("Filter `{}` of hook `{}` matched", filter_name, hook_name);
|
|
||||||
|
|
||||||
return Ok(Some(replace_parameters(
|
|
||||||
&hook.command,
|
|
||||||
&request.headers(),
|
|
||||||
data,
|
|
||||||
)?));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Filter `{}` of hook `{}` did not match",
|
|
||||||
filter_name, hook_name
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_hooks(request: &Request, data: Data) -> Result<Hooks, WebhookeyError> {
|
fn get_commands(request: &Request, data: Data) -> Result<Hooks, WebhookeyError> {
|
||||||
let mut buffer = Vec::new();
|
let mut buffer = Vec::new();
|
||||||
let size = data
|
let size = data
|
||||||
.open()
|
.open()
|
||||||
|
@ -277,7 +344,7 @@ fn execute_hooks(request: &Request, data: Data) -> Result<Hooks, WebhookeyError>
|
||||||
let signature = request
|
let signature = request
|
||||||
.headers()
|
.headers()
|
||||||
.get_one(&hook.signature)
|
.get_one(&hook.signature)
|
||||||
.ok_or_else(|| WebhookeyError::InvalidSignature)?;
|
.ok_or(WebhookeyError::InvalidSignature)?;
|
||||||
|
|
||||||
let secrets = hook
|
let secrets = hook
|
||||||
.secrets
|
.secrets
|
||||||
|
@ -294,21 +361,17 @@ fn execute_hooks(request: &Request, data: Data) -> Result<Hooks, WebhookeyError>
|
||||||
let mut data: serde_json::Value =
|
let mut data: serde_json::Value =
|
||||||
serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?;
|
serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?;
|
||||||
|
|
||||||
for (filter_name, filter) in &hook.filters {
|
match hook.filter.evaluate(request, &data) {
|
||||||
match filter_match(
|
Ok(true) => match get_command(&hook_name, &hook, &request, &mut data) {
|
||||||
&hook_name,
|
Ok(command) => {
|
||||||
&hook,
|
|
||||||
&filter_name,
|
|
||||||
&filter,
|
|
||||||
&request,
|
|
||||||
&mut data,
|
|
||||||
) {
|
|
||||||
Ok(Some(command)) => {
|
|
||||||
result.insert(hook_name.to_string(), command);
|
result.insert(hook_name.to_string(), command);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
|
||||||
Err(e) => error!("{}", e),
|
Err(e) => error!("{}", e),
|
||||||
|
},
|
||||||
|
Ok(false) => info!("Filter for `{}` did not match", &hook_name),
|
||||||
|
Err(error) => {
|
||||||
|
error!("Could not match filter for `{}`: {}", &hook_name, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -328,7 +391,7 @@ impl FromDataSimple for Hooks {
|
||||||
type Error = WebhookeyError;
|
type Error = WebhookeyError;
|
||||||
|
|
||||||
fn from_data(request: &Request, data: Data) -> data::Outcome<Self, Self::Error> {
|
fn from_data(request: &Request, data: Data) -> data::Outcome<Self, Self::Error> {
|
||||||
match execute_hooks(&request, data) {
|
match get_commands(&request, data) {
|
||||||
Ok(hooks) => {
|
Ok(hooks) => {
|
||||||
if hooks.inner.is_empty() {
|
if hooks.inner.is_empty() {
|
||||||
let client_ip = &request
|
let client_ip = &request
|
||||||
|
@ -357,7 +420,7 @@ impl FromDataSimple for Hooks {
|
||||||
fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result<Response<'a>> {
|
fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result<Response<'a>> {
|
||||||
info!("Post request received from: {}", address);
|
info!("Post request received from: {}", address);
|
||||||
|
|
||||||
for (name, command) in hooks.inner {
|
hooks.inner.iter().for_each(|(name, command)| {
|
||||||
info!("Execute `{}` from hook `{}`", &command, &name);
|
info!("Execute `{}` from hook `{}`", &command, &name);
|
||||||
|
|
||||||
match run_script::run(&command, &vec![], &ScriptOptions::new()) {
|
match run_script::run(&command, &vec![], &ScriptOptions::new()) {
|
||||||
|
@ -370,15 +433,15 @@ fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result<Response<'a>> {
|
||||||
error!("Execution of `{}` failed: {}", &command, e);
|
error!("Execution of `{}` failed: {}", &command, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
Ok(Response::new())
|
Ok(Response::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_config() -> Result<File> {
|
fn get_config() -> Result<File> {
|
||||||
// Look for systemwide config..
|
// Look for config in CWD..
|
||||||
if let Ok(config) = File::open("/etc/webhookey/config.yml") {
|
if let Ok(config) = File::open("config.yml") {
|
||||||
info!("Loading configuration from `/etc/webhookey/config.yml`");
|
info!("Loading configuration from `./config.yml`");
|
||||||
|
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
@ -397,9 +460,9 @@ fn get_config() -> Result<File> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ..look for config in CWD..
|
// ..look for systemwide config..
|
||||||
if let Ok(config) = File::open("config.yml") {
|
if let Ok(config) = File::open("/etc/webhookey/config.yml") {
|
||||||
info!("Loading configuration from `./config.yml`");
|
info!("Loading configuration from `/etc/webhookey/config.yml`");
|
||||||
|
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
@ -437,6 +500,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn secret() {
|
fn secret() {
|
||||||
let mut hooks = HashMap::new();
|
let mut hooks = HashMap::new();
|
||||||
|
|
||||||
hooks.insert(
|
hooks.insert(
|
||||||
"test_hook".to_string(),
|
"test_hook".to_string(),
|
||||||
Hook {
|
Hook {
|
||||||
|
@ -444,9 +508,13 @@ mod tests {
|
||||||
signature: "X-Gitea-Signature".to_string(),
|
signature: "X-Gitea-Signature".to_string(),
|
||||||
ip_filter: None,
|
ip_filter: None,
|
||||||
secrets: vec!["valid".to_string()],
|
secrets: vec!["valid".to_string()],
|
||||||
filters: HashMap::new(),
|
filter: FilterType::JsonFilter(JsonFilter {
|
||||||
|
pointer: "*".to_string(),
|
||||||
|
regex: "*".to_string(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let config = Config { hooks: hooks };
|
let config = Config { hooks: hooks };
|
||||||
|
|
||||||
let rocket = rocket::ignite()
|
let rocket = rocket::ignite()
|
||||||
|
|
Loading…
Reference in a new issue