Browse Source

Change the template engine to MiniJinja

pull/99/head
Jan Christian Grünhage 2 years ago
parent
commit
7567e7566c
No known key found for this signature in database GPG Key ID: EEC1170CE56FA2ED
  1. 1
      CHANGELOG.md
  2. 23
      Cargo.lock
  3. 2
      acme_common/Cargo.toml
  4. 4
      acme_common/src/error.rs
  5. 2
      acmed/Cargo.toml
  6. 2
      acmed/build.rs
  7. 38
      acmed/config/default_hooks.toml
  8. 42
      acmed/src/template.rs
  9. 48
      man/en/acmed.toml.5

1
CHANGELOG.md

@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The minimum supported Rust version (MSRV) is now 1.64. - The minimum supported Rust version (MSRV) is now 1.64.
- Manual (and badly designed) threads have been replaced by async. - Manual (and badly designed) threads have been replaced by async.
- Randomized early delay, for spacing out renewals when dealing with a lot of certificates. - Randomized early delay, for spacing out renewals when dealing with a lot of certificates.
- Replaced the template engine TinyTemplate with MiniJinja.
## [0.21.0] - 2022-12-19 ## [0.21.0] - 2022-12-19

23
Cargo.lock

@ -11,6 +11,7 @@ dependencies = [
"env_logger", "env_logger",
"glob", "glob",
"log", "log",
"minijinja",
"native-tls", "native-tls",
"nix", "nix",
"openssl", "openssl",
@ -19,7 +20,6 @@ dependencies = [
"reqwest", "reqwest",
"serde_json", "serde_json",
"syslog", "syslog",
"tinytemplate",
"toml", "toml",
] ]
@ -35,13 +35,13 @@ dependencies = [
"futures", "futures",
"glob", "glob",
"log", "log",
"minijinja",
"nix", "nix",
"nom", "nom",
"rand", "rand",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tinytemplate",
"tokio", "tokio",
"toml", "toml",
] ]
@ -797,6 +797,15 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minijinja"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40870a194358132836de5c67e5038c279de3bff7a05f5da201ed13f6064b979"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@ -1387,16 +1396,6 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.6.0" version = "1.6.0"

2
acme_common/Cargo.toml

@ -24,6 +24,7 @@ daemonize = "0.5"
env_logger = "0.10" env_logger = "0.10"
glob = "0.3" glob = "0.3"
log = "0.4" log = "0.4"
minijinja = "1.0.3"
native-tls = "0.2" native-tls = "0.2"
openssl = { version = "0.10", optional = true } openssl = { version = "0.10", optional = true }
openssl-sys = { version = "0.9", optional = true } openssl-sys = { version = "0.9", optional = true }
@ -31,7 +32,6 @@ punycode = "0.4"
reqwest = { version = "0.11.16", default-features = false } reqwest = { version = "0.11.16", default-features = false }
serde_json = "1.0" serde_json = "1.0"
syslog = "6.0" syslog = "6.0"
tinytemplate = "1.2"
toml = "0.7" toml = "0.7"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]

4
acme_common/src/error.rs

@ -105,8 +105,8 @@ impl From<glob::PatternError> for Error {
} }
} }
impl From<tinytemplate::error::Error> for Error {
fn from(error: tinytemplate::error::Error) -> Self {
impl From<minijinja::Error> for Error {
fn from(error: minijinja::Error) -> Self {
format!("template error: {error}").into() format!("template error: {error}").into()
} }
} }

2
acmed/Cargo.toml

@ -31,11 +31,11 @@ log = "0.4"
nom = { version = "7.0", default-features = false, features = [] } nom = { version = "7.0", default-features = false, features = [] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tinytemplate = "1.2"
toml = "0.7" toml = "0.7"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rand = "0.8.5" rand = "0.8.5"
reqwest = "0.11.16" reqwest = "0.11.16"
minijinja = "1.0.3"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
nix = "0.26" nix = "0.26"

2
acmed/build.rs

@ -117,7 +117,7 @@ fn set_default_values() {
set_data_path_if_absent!("ACMED_DEFAULT_CERT_DIR", "certs"); set_data_path_if_absent!("ACMED_DEFAULT_CERT_DIR", "certs");
set_env_var_if_absent!( set_env_var_if_absent!(
"ACMED_DEFAULT_CERT_FORMAT", "ACMED_DEFAULT_CERT_FORMAT",
"{ name }_{ key_type }.{ file_type }.{ ext }"
"{{ name }}_{{ key_type }}.{{ file_type }}.{{ ext }}"
); );
set_cfg_path_if_absent!("ACMED_DEFAULT_CONFIG_FILE", "acmed.toml"); set_cfg_path_if_absent!("ACMED_DEFAULT_CONFIG_FILE", "acmed.toml");
set_runstate_path_if_absent!("ACMED_DEFAULT_PID_FILE", "acmed.pid"); set_runstate_path_if_absent!("ACMED_DEFAULT_PID_FILE", "acmed.pid");

38
acmed/config/default_hooks.toml

@ -12,7 +12,7 @@
# #
# http-01 challenge in "/var/www/{ identifier }/"
# http-01 challenge in "/var/www/{{ identifier }}/"
# #
[[hook]] [[hook]]
@ -21,7 +21,7 @@ type = ["challenge-http-01"]
cmd = "mkdir" cmd = "mkdir"
args = [ args = [
"-m", "0755", "-m", "0755",
"-p", "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge"
"-p", "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge"
] ]
allow_failure = true allow_failure = true
@ -30,7 +30,7 @@ name = "http-01-echo-echo"
type = ["challenge-http-01"] type = ["challenge-http-01"]
cmd = "echo" cmd = "echo"
args = ["{ proof }"] args = ["{ proof }"]
stdout = "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
stdout = "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}"
[[hook]] [[hook]]
name = "http-01-echo-chmod" name = "http-01-echo-chmod"
@ -38,7 +38,7 @@ type = ["challenge-http-01"]
cmd = "chmod" cmd = "chmod"
args = [ args = [
"a+r", "a+r",
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
"{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}"
] ]
allow_failure = true allow_failure = true
@ -48,7 +48,7 @@ type = ["challenge-http-01-clean"]
cmd = "rm" cmd = "rm"
args = [ args = [
"-f", "-f",
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
"{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}"
] ]
allow_failure = true allow_failure = true
@ -71,10 +71,10 @@ name = "tls-alpn-01-tacd-start-tcp"
type = ["challenge-tls-alpn-01"] type = ["challenge-tls-alpn-01"]
cmd = "tacd" cmd = "tacd"
args = [ args = [
"--pid-file", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
"--pid-file", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid",
"--domain", "{ identifier_tls_alpn }", "--domain", "{ identifier_tls_alpn }",
"--acme-ext", "{ proof }", "--acme-ext", "{ proof }",
"--listen", "{{ if env.TACD_HOST }}{ env.TACD_HOST }{{ else }}{ identifier }{{ endif }}:{{ if env.TACD_PORT }}{ env.TACD_PORT }{{ else }}5001{{ endif }}"
"--listen", "{{ env.TACD_PORT | default('5001') }}"
] ]
[[hook]] [[hook]]
@ -82,10 +82,10 @@ name = "tls-alpn-01-tacd-start-unix"
type = ["challenge-tls-alpn-01"] type = ["challenge-tls-alpn-01"]
cmd = "tacd" cmd = "tacd"
args = [ args = [
"--pid-file", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
"--pid-file", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid",
"--domain", "{ identifier_tls_alpn }", "--domain", "{ identifier_tls_alpn }",
"--acme-ext", "{ proof }", "--acme-ext", "{ proof }",
"--listen", "unix:{{ if env.TACD_SOCK_ROOT }}{ env.TACD_SOCK_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.sock"
"--listen", "unix:{{ env.TACD_SOCK_ROOT | default('/run') }}/tacd_{{ identifier }}.sock"
] ]
[[hook]] [[hook]]
@ -93,7 +93,7 @@ name = "tls-alpn-01-tacd-kill"
type = ["challenge-tls-alpn-01-clean"] type = ["challenge-tls-alpn-01-clean"]
cmd = "pkill" cmd = "pkill"
args = [ args = [
"-F", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
"-F", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid",
] ]
allow_failure = true allow_failure = true
@ -102,7 +102,7 @@ name = "tls-alpn-01-tacd-rm"
type = ["challenge-tls-alpn-01-clean"] type = ["challenge-tls-alpn-01-clean"]
cmd = "rm" cmd = "rm"
args = [ args = [
"-f", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
"-f", "{{ env.TACD_PID_ROOT | default('/run') }}/tacd_{{ identifier }}.pid",
] ]
allow_failure = true allow_failure = true
@ -125,7 +125,7 @@ type = ["file-pre-create", "file-pre-edit"]
cmd = "git" cmd = "git"
args = [ args = [
"init", "init",
"{ file_directory }"
"{{ file_directory }}"
] ]
[[hook]] [[hook]]
@ -133,8 +133,8 @@ name = "git-add"
type = ["file-post-create", "file-post-edit"] type = ["file-post-create", "file-post-edit"]
cmd = "git" cmd = "git"
args = [ args = [
"-C", "{ file_directory }",
"add", "{ file_name }"
"-C", "{{ file_directory }}",
"add", "{{ file_name }}"
] ]
allow_failure = true allow_failure = true
@ -143,12 +143,12 @@ name = "git-commit"
type = ["file-post-create", "file-post-edit"] type = ["file-post-create", "file-post-edit"]
cmd = "git" cmd = "git"
args = [ args = [
"-C", "{ file_directory }",
"-c", "user.name='{{ if env.GIT_USERNAME }}{ env.GIT_USERNAME }{{ else }}ACMEd{{ endif }}'",
"-c", "user.email='{{ if env.GIT_EMAIL }}{ env.GIT_EMAIL }{{ else }}acmed@localhost{{ endif }}'",
"-C", "{{ file_directory }}",
"-c", "user.name='{{ env.GIT_USERNAME | default('ACMEd') }}'",
"-c", "user.email='{{ env.GIT_EMAIL | default('acmed@localhost') }}'",
"commit", "commit",
"-m", "{ file_name }",
"--only", "{ file_name }"
"-m", "{{ file_name }}",
"--only", "{{ file_name }}"
] ]
allow_failure = true allow_failure = true

42
acmed/src/template.rs

@ -1,37 +1,23 @@
use acme_common::error::Error;
use minijinja::{value::Value, Environment};
use serde::Serialize; use serde::Serialize;
use serde_json::Value;
use tinytemplate::TinyTemplate;
macro_rules! default_format {
($value: ident, $output: ident) => {{
$output.push_str(&$value.to_string());
Ok(())
}};
}
fn formatter_rev_labels(value: &Value, output: &mut String) -> tinytemplate::error::Result<()> {
match value {
Value::Null => Ok(()),
Value::Bool(v) => default_format!(v, output),
Value::Number(v) => default_format!(v, output),
Value::String(v) => {
let s = v.rsplit('.').collect::<Vec<&str>>().join(".");
output.push_str(&s);
Ok(())
}
_ => Ok(()),
fn formatter_rev_labels(value: Value) -> Result<Value, minijinja::Error> {
if let Some(value) = value.as_str() {
Ok(value.rsplit('.').collect::<Vec<&str>>().join(".").into())
} else {
Ok(value)
} }
} }
pub fn render_template<T>(template: &str, data: &T) -> Result<String, Error>
pub fn render_template<T>(template: &str, data: &T) -> Result<String, minijinja::Error>
where where
T: Serialize, T: Serialize,
{ {
let mut reg = TinyTemplate::new();
reg.add_formatter("rev_labels", formatter_rev_labels);
reg.add_template("reg", template)?;
Ok(reg.render("reg", data)?)
let mut environment = Environment::new();
environment.add_filter("rev_labels", formatter_rev_labels);
environment.add_template("template", template)?;
let template = environment.get_template("template")?;
Ok(template.render(data)?)
} }
#[cfg(test)] #[cfg(test)]
@ -51,7 +37,7 @@ mod tests {
foo: String::from("test"), foo: String::from("test"),
bar: 42, bar: 42,
}; };
let tpl = "This is { foo } { bar -} !";
let tpl = "This is {{ foo }} {{ bar -}} !";
let rendered = render_template(tpl, &c); let rendered = render_template(tpl, &c);
assert!(rendered.is_ok()); assert!(rendered.is_ok());
let rendered = rendered.unwrap(); let rendered = rendered.unwrap();
@ -64,7 +50,7 @@ mod tests {
foo: String::from("mx1.example.org"), foo: String::from("mx1.example.org"),
bar: 42, bar: 42,
}; };
let tpl = "{ foo } - { foo | rev_labels }";
let tpl = "{{ foo }} - {{ foo | rev_labels }}";
let rendered = render_template(tpl, &c); let rendered = render_template(tpl, &c);
assert!(rendered.is_ok()); assert!(rendered.is_ok());
let rendered = rendered.unwrap(); let rendered = rendered.unwrap();

48
man/en/acmed.toml.5

@ -122,17 +122,17 @@ Name of the endpoint to use.
Table of environment variables that will be accessible from hooks. Table of environment variables that will be accessible from hooks.
.It Ic file_name_format Ar string .It Ic file_name_format Ar string
Template used to build the file's name. The template syntax is Template used to build the file's name. The template syntax is
.Em TinyTemplate .
.Em MiniJinja .
See the See the
.Sx STANDARDS .Sx STANDARDS
section for a link to the section for a link to the
.Em TinyTemplate
.Em MiniJinja
specifications. If not specified, the value defined in the specifications. If not specified, the value defined in the
.Em endpoint .Em endpoint
element, and then the element, and then the
.Em global .Em global
element, is used. Default is element, is used. Default is
.Dq { name }_{ key_type }.{ file_type }.{ ext } .
.Dq {{ name }}_{{ key_type }}.{{ file_type }}.{{ ext }} .
Possible variables are: Possible variables are:
.Bl -tag .Bl -tag
.It Ic ext Ar string .It Ic ext Ar string
@ -424,11 +424,11 @@ and
are considered as template strings whereas are considered as template strings whereas
.Em cmd .Em cmd
is not. The template syntax is is not. The template syntax is
.Em TinyTemplate .
.Em MiniJinja .
See the See the
.Sx STANDARDS .Sx STANDARDS
section for a link to the section for a link to the
.Em TinyTemplate
.Em MiniJinja
specifications. specifications.
.Pp .Pp
The available types and the associated template variable are described below. The available types and the associated template variable are described below.
@ -604,10 +604,10 @@ and
environment variables. environment variables.
.It Pa http-01-echo .It Pa http-01-echo
This hook is designed to solve the http-01 challenge. For this purpose, it will write the proof into This hook is designed to solve the http-01 challenge. For this purpose, it will write the proof into
.Pa { env.HTTP_ROOT }/{ identifier }/.well-known/acme-challenge/{ file_name } .
.Pa {{ env.HTTP_ROOT }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }} .
.Pp .Pp
The web server must be configured so the file The web server must be configured so the file
.Pa http://{ identifier }/.well-known/acme-challenge/{ file_name }
.Pa http://{{ identifier }}/.well-known/acme-challenge/{{ file_name }}
can be accessed from the CA. can be accessed from the CA.
.Pp .Pp
If If
@ -632,7 +632,7 @@ environment variable (default is 5001).
.Pp .Pp
.Xr tacd 8 .Xr tacd 8
will store its pid into will store its pid into
.Pa { TACD_PID_ROOT }/tacd_{ identifier }.pid .
.Pa {{ TACD_PID_ROOT }}/tacd_{{ identifier }}.pid .
If If
.Ev TACD_PID_ROOT .Ev TACD_PID_ROOT
is not specified, it will be set to is not specified, it will be set to
@ -648,7 +648,7 @@ option.
.Pp .Pp
.Xr tacd 8 .Xr tacd 8
will listen on the unix socket will listen on the unix socket
.Pa { env.TACD_SOCK_ROOT }/tacd_{ identifier }.sock .
.Pa {{ env.TACD_SOCK_ROOT }}/tacd_{{ identifier }}.sock .
If If
.Ev TACD_SOCK_ROOT .Ev TACD_SOCK_ROOT
is not specified, it will be set to is not specified, it will be set to
@ -656,7 +656,7 @@ is not specified, it will be set to
.Pp .Pp
.Xr tacd 8 .Xr tacd 8
will store its pid into will store its pid into
.Pa { TACD_PID_ROOT }/tacd_{ identifier }.pid .
.Pa {{ TACD_PID_ROOT }}/tacd_{{ identifier }}.pid .
If If
.Ev TACD_PID_ROOT .Ev TACD_PID_ROOT
is not specified, it will be set to is not specified, it will be set to
@ -696,8 +696,8 @@ For example,
and and
.Dq 40s20h4h2s .Dq 40s20h4h2s
both represents a period of one day and forty-two seconds. both represents a period of one day and forty-two seconds.
.Sh TEMPLATE FORMATTERS
In addition the the formatters provided by default by TinyTemplate, ACMEd provides the following formatters:
.Sh TEMPLATE FILTERS
In addition the the filters provided by default by MiniJinja, ACMEd provides the following filters:
.Bl -tag .Bl -tag
.It Pa rev_labels .It Pa rev_labels
Reverts the labels of a domain name (eg: Reverts the labels of a domain name (eg:
@ -763,7 +763,7 @@ type = ["challenge-http-01"]
cmd = "mkdir" cmd = "mkdir"
args = [ args = [
"-m", "0755", "-m", "0755",
"-p", "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge"
"-p", "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge"
] ]
[[hook]] [[hook]]
@ -771,7 +771,7 @@ name = "http-01-echo-echo"
type = ["challenge-http-01"] type = ["challenge-http-01"]
cmd = "echo" cmd = "echo"
args = ["{ proof }"] args = ["{ proof }"]
stdout = "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
stdout = "{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}"
[[hook]] [[hook]]
name = "http-01-echo-chmod" name = "http-01-echo-chmod"
@ -779,7 +779,7 @@ type = ["challenge-http-01-clean"]
cmd = "chmod" cmd = "chmod"
args = [ args = [
"a+r", "a+r",
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
"{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}"
] ]
[[hook]] [[hook]]
@ -788,7 +788,7 @@ type = ["challenge-http-01-clean"]
cmd = "rm" cmd = "rm"
args = [ args = [
"-f", "-f",
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
"{{ env.HTTP_ROOT | default('/var/www') }}/{{ identifier }}/.well-known/acme-challenge/{{ file_name }}"
] ]
.Ed .Ed
.Pp .Pp
@ -821,12 +821,12 @@ args = [
"-f", "noreply.certs@example.net", "-f", "noreply.certs@example.net",
"contact@example.net" "contact@example.net"
] ]
stdin_str = """Subject: Certificate renewal {{ if is_success }}succeeded{{ else }}failed{{ endif }} for { identifiers.0 }
stdin_str = """Subject: Certificate renewal {{ 'succeeded' if is_success else 'failed' }} for {{ identifiers.0 }}
The following certificate has {{ if not is_success }}*not* {{ endif }}been renewed.
identifiers: {{ for ident in identifiers }}{{ if not @first }}, {{ endif }}{ ident }{{ endfor }}
key type: { key_type }
status: { status }"""
The following certificate has {{ '' if is_success else '*not* ' }}been renewed.
identifiers: {% for ident in identifiers %}{% if not loop.first %}, {% endif %}{{ ident }}{% endfor %}
key type: {{ key_type }}
status: {{ status }}"""
.Ed .Ed
.Sh SEE ALSO .Sh SEE ALSO
.Xr acmed 8 , .Xr acmed 8 ,
@ -842,9 +842,9 @@ status: { status }"""
.Re .Re
.It .It
.Rs .Rs
.%A Brook Heisler
.%T TinyTemplate
.%U https://docs.rs/tinytemplate/latest/tinytemplate/syntax/index.html
.%A Armin Ronacher
.%T MiniJinja
.%U https://docs.rs/minijinja/latest/minijinja/syntax/index.html
.Re .Re
.It .It
.Rs .Rs

Loading…
Cancel
Save