Compare commits
15 commits
master
...
dns_challe
Author | SHA1 | Date | |
---|---|---|---|
![]() |
dbf6298cba | ||
![]() |
be7580aca5 | ||
![]() |
684c2b46e1 | ||
![]() |
4f633eab8e | ||
![]() |
fd15227637 | ||
![]() |
e13f493c22 | ||
![]() |
64a7f3d03c | ||
![]() |
3859db1146 | ||
![]() |
c252ae4734 | ||
![]() |
7d68291762 | ||
![]() |
d2ea2ccd9c | ||
![]() |
28a2b53a9d | ||
![]() |
7cbc8e1029 | ||
![]() |
9035f8c445 | ||
![]() |
f87d58bb41 |
19 changed files with 240 additions and 85 deletions
36
bundles/bind-acme/metadata.py
Normal file
36
bundles/bind-acme/metadata.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
@metadata_reactor.provides(
|
||||
'dns',
|
||||
)
|
||||
def acme_records(metadata):
|
||||
domains = set()
|
||||
|
||||
for other_node in repo.nodes:
|
||||
for domain, conf in other_node.metadata.get('letsencrypt/domains', {}).items():
|
||||
domains.add(domain)
|
||||
domains.update(conf.get('aliases', []))
|
||||
|
||||
return {
|
||||
'dns': {
|
||||
f'_acme-challenge.{domain}': {
|
||||
'CNAME': {f"{domain}.{metadata.get('bind/acme_zone')}."},
|
||||
}
|
||||
for domain in domains
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@metadata_reactor.provides(
|
||||
'bind/zones',
|
||||
)
|
||||
def acme_zone(metadata):
|
||||
return {
|
||||
'bind': {
|
||||
'zones': {
|
||||
metadata.get('bind/acme_zone'): {
|
||||
'dynamic': True,
|
||||
'views': ['external'],
|
||||
'records': set(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
|
@ -11,8 +11,8 @@ $TTL 600
|
|||
900 ;Negative response caching TTL
|
||||
)
|
||||
|
||||
% for record in sorted(records, key=lambda r: (r['name'], r['type'], r['value'])):
|
||||
${(record['name'] or '@').ljust(column_width('name', records))} \
|
||||
% for record in sorted(records, key=lambda r: (tuple(reversed(r['name'].split('.'))), r['type'], r['value'])):
|
||||
(${(record['name'] or '@').rjust(column_width('name', records))}) \
|
||||
IN \
|
||||
${record['type'].ljust(column_width('type', records))} \
|
||||
% if record['type'] == 'TXT':
|
||||
|
|
|
@ -4,6 +4,13 @@ acl "${view['name']}" {
|
|||
};
|
||||
% endfor
|
||||
|
||||
% for name, token in keys.items():
|
||||
key "${name}" {
|
||||
algorithm hmac-sha512;
|
||||
secret "${token}";
|
||||
};
|
||||
% endfor
|
||||
|
||||
% for view in views:
|
||||
view "${view['name']}" {
|
||||
match-clients { ${view['name']}; };
|
||||
|
@ -25,12 +32,16 @@ view "${view['name']}" {
|
|||
8.8.8.8;
|
||||
};
|
||||
|
||||
% for zone in zones:
|
||||
% for zone, conf in sorted(zones.items()):
|
||||
<% if view['name'] not in conf.get('views', ['internal', 'external']): continue %>
|
||||
zone "${zone}" {
|
||||
type ${type};
|
||||
% if type == 'slave':
|
||||
% if type == 'slave':
|
||||
masters { ${master_ip}; };
|
||||
% endif
|
||||
% endif
|
||||
% if type == 'master' and zone in keys:
|
||||
allow-update { key "${zone}"; };
|
||||
% endif
|
||||
file "/var/lib/bind/${view['name']}/db.${zone}";
|
||||
};
|
||||
% endfor
|
||||
|
|
|
@ -3,7 +3,7 @@ options {
|
|||
dnssec-validation auto;
|
||||
|
||||
listen-on-v6 { any; };
|
||||
allow-query { any; };
|
||||
allow-query { any; };
|
||||
|
||||
max-cache-size 30%;
|
||||
querylog yes;
|
||||
|
|
|
@ -14,6 +14,8 @@ else:
|
|||
slave_ips = []
|
||||
|
||||
directories[f'/var/lib/bind'] = {
|
||||
'owner': 'bind',
|
||||
'group': 'bind',
|
||||
'purge': True,
|
||||
'needed_by': [
|
||||
'svc_systemd:bind9',
|
||||
|
@ -51,6 +53,7 @@ files['/etc/bind/named.conf.options'] = {
|
|||
'context': {
|
||||
'type': node.metadata.get('bind/type'),
|
||||
'slave_ips': sorted(slave_ips),
|
||||
'master_ip': master_ip,
|
||||
},
|
||||
'owner': 'root',
|
||||
'group': 'bind',
|
||||
|
@ -92,7 +95,9 @@ files['/etc/bind/named.conf.local'] = {
|
|||
'type': node.metadata.get('bind/type'),
|
||||
'master_ip': master_ip,
|
||||
'views': views,
|
||||
'zones': sorted(zones),
|
||||
'zones': zones,
|
||||
'hostname': node.metadata.get('bind/hostname'),
|
||||
'keys': node.metadata.get('bind/keys'),
|
||||
},
|
||||
'owner': 'root',
|
||||
'group': 'bind',
|
||||
|
@ -127,6 +132,8 @@ def record_matches_view(record, records, view):
|
|||
|
||||
for view in views:
|
||||
directories[f"/var/lib/bind/{view['name']}"] = {
|
||||
'owner': 'bind',
|
||||
'group': 'bind',
|
||||
'purge': True,
|
||||
'needed_by': [
|
||||
'svc_systemd:bind9',
|
||||
|
@ -136,7 +143,11 @@ for view in views:
|
|||
],
|
||||
}
|
||||
|
||||
for zone, records in zones.items():
|
||||
for zone, conf in zones.items():
|
||||
if view['name'] not in conf.get('views', ['internal', 'external']):
|
||||
continue
|
||||
|
||||
records = conf['records']
|
||||
unique_records = [
|
||||
dict(record_tuple)
|
||||
for record_tuple in set(
|
||||
|
@ -145,18 +156,8 @@ for view in views:
|
|||
]
|
||||
|
||||
files[f"/var/lib/bind/{view['name']}/db.{zone}"] = {
|
||||
'owner': 'bind',
|
||||
'group': 'bind',
|
||||
'source': 'db',
|
||||
'content_type': 'mako',
|
||||
'context': {
|
||||
'view': view['name'],
|
||||
'serial': datetime.now().strftime('%Y%m%d%H'),
|
||||
'records': list(filter(
|
||||
lambda record: record_matches_view(record, records, view['name']),
|
||||
unique_records
|
||||
)),
|
||||
'hostname': node.metadata.get('bind/hostname'),
|
||||
},
|
||||
'needs': [
|
||||
f"directory:/var/lib/bind/{view['name']}",
|
||||
],
|
||||
|
@ -167,6 +168,23 @@ for view in views:
|
|||
'svc_systemd:bind9:restart',
|
||||
],
|
||||
}
|
||||
if True or node.metadata.get('bind/type') == 'master': #FIXME: slave doesnt get updated if db doesnt get rewritten on each apply
|
||||
files[f"/var/lib/bind/{view['name']}/db.{zone}"].update({
|
||||
'source': 'db',
|
||||
'content_type': 'mako',
|
||||
'unless': f"test -f /var/lib/bind/{view['name']}/db.{zone}" if conf.get('dynamic', False) else 'false',
|
||||
'context': {
|
||||
'view': view['name'],
|
||||
'serial': datetime.now().strftime('%Y%m%d%H'),
|
||||
'records': list(filter(
|
||||
lambda record: record_matches_view(record, records, view['name']),
|
||||
unique_records
|
||||
)),
|
||||
'hostname': node.metadata.get('bind/hostname'),
|
||||
'type': node.metadata.get('bind/type'),
|
||||
'keys': node.metadata.get('bind/keys'),
|
||||
},
|
||||
})
|
||||
|
||||
svc_systemd['bind9'] = {}
|
||||
|
||||
|
@ -175,5 +193,6 @@ actions['named-checkconf'] = {
|
|||
'unless': 'named-checkconf -z',
|
||||
'needs': [
|
||||
'svc_systemd:bind9',
|
||||
'svc_systemd:bind9:restart',
|
||||
]
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ defaults = {
|
|||
'bind': {
|
||||
'zones': {},
|
||||
'slaves': {},
|
||||
'keys': {},
|
||||
},
|
||||
'telegraf': {
|
||||
'config': {
|
||||
|
@ -76,7 +77,8 @@ def collect_records(metadata):
|
|||
for type, values in records.items():
|
||||
for value in values:
|
||||
zones\
|
||||
.setdefault(zone, set())\
|
||||
.setdefault(zone, {})\
|
||||
.setdefault('records', set())\
|
||||
.add(
|
||||
h({'name': name, 'type': type, 'value': value})
|
||||
)
|
||||
|
@ -106,10 +108,13 @@ def ns_records(metadata):
|
|||
'bind': {
|
||||
'zones': {
|
||||
zone: {
|
||||
# FIXME: bw currently cant handle lists of dicts :(
|
||||
h({'name': '@', 'type': 'NS', 'value': f"{nameserver}."})
|
||||
for nameserver in nameservers
|
||||
} for zone in metadata.get('bind/zones').keys()
|
||||
'records': {
|
||||
# FIXME: bw currently cant handle lists of dicts :(
|
||||
h({'name': '@', 'type': 'NS', 'value': f"{nameserver}."})
|
||||
for nameserver in nameservers
|
||||
}
|
||||
}
|
||||
for zone in metadata.get('bind/zones').keys()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -131,3 +136,24 @@ def slaves(metadata):
|
|||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@metadata_reactor.provides(
|
||||
'bind/keys',
|
||||
)
|
||||
def generate_keys(metadata):
|
||||
return {
|
||||
'bind': {
|
||||
'keys': {
|
||||
zone: repo.libs.hmac.hmac_sha512(
|
||||
zone,
|
||||
str(repo.vault.random_bytes_as_base64_for(
|
||||
f"{metadata.get('id')} bind key {zone}",
|
||||
length=32,
|
||||
)),
|
||||
)
|
||||
for zone, conf in metadata.get('bind/zones').items()
|
||||
if conf.get('dynamic', False)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
9
bundles/letsencrypt/README.md
Normal file
9
bundles/letsencrypt/README.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
https://github.com/dehydrated-io/dehydrated/wiki/example-dns-01-nsupdate-script
|
||||
|
||||
```
|
||||
printf "server 127.0.0.1
|
||||
zone acme.resolver.name.
|
||||
update add _acme-challenge.ckn.li.acme.resolver.name. 600 IN TXT "hello"
|
||||
send
|
||||
" | nsupdate -y hmac-sha512:acme:Y9BHl85l352BGZDXa/vg90hh2+5PYe4oJxpkq/oQvIODDkW8bAyQSFr0gKQQxjyIOyYlTjf0MGcdWFv46G/3Rg==
|
||||
```
|
|
@ -3,3 +3,4 @@ BASEDIR=/var/lib/dehydrated
|
|||
WELLKNOWN="${BASEDIR}/acme-challenges"
|
||||
DOMAINS_TXT="/etc/dehydrated/domains.txt"
|
||||
HOOK="/etc/dehydrated/hook.sh"
|
||||
CA="https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
% for domain, aliases in sorted(node.metadata.get('letsencrypt/domains', {}).items()):
|
||||
${domain} ${' '.join(sorted(aliases))}
|
||||
% for domain, conf in sorted(domains.items()):
|
||||
${domain} ${' '.join(sorted(conf.get('aliases', [])))}
|
||||
% endfor
|
||||
|
|
|
@ -1,37 +1,57 @@
|
|||
deploy_cert() {<%text>
|
||||
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"</%text>
|
||||
% for service, config in node.metadata.get('letsencrypt/concat_and_deploy', {}).items():
|
||||
set -e
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
# concat_and_deploy ${service}
|
||||
if [ "$DOMAIN" = "${config['match_domain']}" ]; then
|
||||
cat $KEYFILE > ${config['target']}
|
||||
cat $FULLCHAINFILE >> ${config['target']}
|
||||
% if 'chown' in config:
|
||||
chown ${config['chown']} ${config['target']}
|
||||
% endif
|
||||
% if 'chmod' in config:
|
||||
chmod ${config['chmod']} ${config['target']}
|
||||
% endif
|
||||
% if 'commands' in config:
|
||||
% for command in config['commands']:
|
||||
${command}
|
||||
% endfor
|
||||
% endif
|
||||
fi
|
||||
% endfor
|
||||
deploy_challenge() {
|
||||
echo "
|
||||
server ${server}
|
||||
zone ${zone}.
|
||||
update add $1.${zone}. 60 IN TXT \"$3\"
|
||||
send
|
||||
" | tee | nsupdate -y hmac-sha512:${zone}:${acme_key}
|
||||
|
||||
sleep 10
|
||||
}
|
||||
|
||||
|
||||
exit_hook() {<%text>
|
||||
local ERROR="${1:-}"</%text>
|
||||
|
||||
% for service in sorted(node.metadata.get('letsencrypt/reload_after', set())):
|
||||
systemctl reload-or-restart ${service}
|
||||
% endfor
|
||||
clean_challenge() {
|
||||
echo "
|
||||
server ${server}
|
||||
zone ${zone}.
|
||||
update delete $1.${zone}. TXT
|
||||
send
|
||||
" | tee | nsupdate -y hmac-sha512:${zone}:${acme_key}
|
||||
}
|
||||
|
||||
deploy_cert() {
|
||||
DOMAIN="$1"
|
||||
KEYFILE="$2"
|
||||
CERTFILE="$3"
|
||||
FULLCHAINFILE="$4"
|
||||
CHAINFILE="$5"
|
||||
|
||||
case $DOMAIN in
|
||||
% for domain, conf in sorted(domains.items()):
|
||||
<% if not conf: continue %>\
|
||||
${domain})
|
||||
% if conf.get('location', None):
|
||||
cat "$KEYFILE" > "${conf['location']}/privkey.pem"
|
||||
cat "$CERTFILE" > "${conf['location']}/cert.pem"
|
||||
cat "$FULLCHAINFILE" > "${conf['location']}/fullchain.pem"
|
||||
cat "$CHAINFILE" > "${conf['location']}/chain.pem"
|
||||
% endif
|
||||
% if conf.get('owner', None):
|
||||
chown ${conf['owner']} "${conf['location']}/privkey.pem" "${conf['location']}/cert.pem" "${conf['location']}/fullchain.pem" "${conf['location']}/chain.pem"
|
||||
% endif
|
||||
% for service in sorted(conf.get('reload', [])):
|
||||
systemctl reload-or-restart ${service}
|
||||
% endfor
|
||||
;;
|
||||
% endfor
|
||||
esac
|
||||
}
|
||||
|
||||
<%text>
|
||||
HANDLER="$1"; shift
|
||||
if [[ "${HANDLER}" =~ ^(deploy_cert|exit_hook)$ ]]; then
|
||||
if [[ $HANDLER =~ ^(deploy_cert|deploy_challenge|clean_challenge)$ ]]
|
||||
then
|
||||
"$HANDLER" "$@"
|
||||
fi</%text>
|
||||
fi
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
assert node.has_bundle('nginx')
|
||||
|
||||
from ipaddress import ip_interface
|
||||
|
||||
delegated = 'delegate_to_node' in node.metadata.get('letsencrypt')
|
||||
acme_node = repo.get_node(node.metadata.get('letsencrypt/acme_node'))
|
||||
|
||||
directories = {
|
||||
'/etc/dehydrated/conf.d': {},
|
||||
|
@ -10,6 +13,9 @@ directories = {
|
|||
files = {
|
||||
'/etc/dehydrated/domains.txt': {
|
||||
'content_type': 'mako',
|
||||
'context': {
|
||||
'domains': node.metadata.get('letsencrypt/domains'),
|
||||
},
|
||||
'triggers': {
|
||||
'action:letsencrypt_update_certificates',
|
||||
},
|
||||
|
@ -21,6 +27,15 @@ files = {
|
|||
},
|
||||
'/etc/dehydrated/hook.sh': {
|
||||
'content_type': 'mako',
|
||||
'context': {
|
||||
'server': ip_interface(acme_node.metadata.get('network/external/ipv4')).ip,
|
||||
'zone': acme_node.metadata.get('bind/acme_zone'),
|
||||
'acme_key': acme_node.metadata.get('bind/keys/' + acme_node.metadata.get('bind/acme_zone')),
|
||||
'domains': node.metadata.get('letsencrypt/domains'),
|
||||
},
|
||||
'mode': '0755',
|
||||
},
|
||||
'/etc/dehydrated/letsencrypt-ensure-some-certificate': {
|
||||
'mode': '0755',
|
||||
},
|
||||
'/etc/dehydrated/letsencrypt-ensure-some-certificate': {
|
||||
|
@ -29,7 +44,7 @@ files = {
|
|||
}
|
||||
|
||||
actions['letsencrypt_update_certificates'] = {
|
||||
'command': 'dehydrated --cron --accept-terms --challenge http-01',
|
||||
'command': 'dehydrated --cron --accept-terms --challenge dns-01',
|
||||
'triggered': True,
|
||||
'skip': delegated,
|
||||
'needs': {
|
||||
|
@ -48,6 +63,6 @@ for domain in node.metadata.get('letsencrypt/domains').keys():
|
|||
'svc_systemd:nginx',
|
||||
},
|
||||
'triggers': {
|
||||
'action:letsencrypt_update_certificates',
|
||||
'action:letsencrypt_update_certificates',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -4,16 +4,17 @@ defaults = {
|
|||
'apt': {
|
||||
'packages': {
|
||||
'dehydrated': {},
|
||||
'dnsutils': {},
|
||||
},
|
||||
},
|
||||
'letsencrypt': {
|
||||
'domains': {
|
||||
# 'example.com': {'alias1.example.com', 'alias2.example.com'},
|
||||
},
|
||||
},
|
||||
'pacman': {
|
||||
'packages': {
|
||||
'dehydrated': {},
|
||||
# 'example.com': {
|
||||
# 'aliases': {'www.example.com'},
|
||||
# 'reload': {'nginx'},
|
||||
# 'owner': 'www-data',
|
||||
# 'location': '/opt/app/certs',
|
||||
# },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -40,7 +41,7 @@ def renew(metadata):
|
|||
return {
|
||||
'systemd-timers': {
|
||||
'letsencrypt': {
|
||||
'command': '/bin/bash -c "/usr/bin/dehydrated --cron --accept-terms --challenge http-01 && /usr/bin/dehydrated --cleanup"',
|
||||
'command': '/bin/bash -c "/usr/bin/dehydrated --cron --accept-terms --challenge dns-01 && /usr/bin/dehydrated --cleanup"',
|
||||
'when': 'daily',
|
||||
},
|
||||
},
|
||||
|
@ -53,22 +54,19 @@ def renew(metadata):
|
|||
)
|
||||
def delegated_domains(metadata):
|
||||
delegated_domains = {
|
||||
domain
|
||||
domain: conf
|
||||
for other_node in repo.nodes
|
||||
if other_node.has_bundle('letsencrypt')
|
||||
and other_node.metadata.get('letsencrypt/delegate_to_node', None) == node.name
|
||||
for domain in other_node.metadata.get('letsencrypt/domains').keys()
|
||||
and other_node.metadata.get('letsencrypt/delegate_to_node', None) == node.name
|
||||
for domain, conf in other_node.metadata.get('letsencrypt/domains').items()
|
||||
}
|
||||
|
||||
return {
|
||||
'letsencrypt': {
|
||||
'domains': {
|
||||
domain: set()
|
||||
for domain in delegated_domains
|
||||
},
|
||||
'domains': delegated_domains,
|
||||
},
|
||||
'dns': {
|
||||
domain: repo.libs.dns.get_a_records(metadata, internal=False)
|
||||
for domain in delegated_domains
|
||||
for domain in delegated_domains.keys()
|
||||
},
|
||||
}
|
||||
|
|
|
@ -60,7 +60,9 @@ def letsencrypt(metadata):
|
|||
return {
|
||||
'letsencrypt': {
|
||||
'domains': {
|
||||
metadata.get('mailserver/hostname'): set(),
|
||||
metadata.get('mailserver/hostname'): {
|
||||
'reload': {'dovecot', 'postfix'},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ def letsencrypt(metadata):
|
|||
return {
|
||||
'letsencrypt': {
|
||||
'domains': {
|
||||
metadata.get('mosquitto/hostname'): set(),
|
||||
metadata.get('mosquitto/hostname'): {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -104,10 +104,10 @@ def letsencrypt(metadata):
|
|||
return {
|
||||
'letsencrypt': {
|
||||
'domains': {
|
||||
domain: set() for domain in metadata.get('nginx/vhosts').keys()
|
||||
},
|
||||
'reload_after': {
|
||||
'nginx',
|
||||
domain: {
|
||||
'reload': {'nginx'},
|
||||
}
|
||||
for domain in metadata.get('nginx/vhosts').keys()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -17,5 +17,8 @@
|
|||
},
|
||||
},
|
||||
},
|
||||
'letsencrypt': {
|
||||
'acme_node': 'htz.mails',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
10
libs/hmac.py
Normal file
10
libs/hmac.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
import hmac, hashlib, base64
|
||||
|
||||
def hmac_sha512(secret, iv):
|
||||
return base64.b64encode(
|
||||
hmac.new(
|
||||
bytes(iv , 'latin-1'),
|
||||
msg=bytes(secret , 'latin-1'),
|
||||
digestmod=hashlib.sha512
|
||||
).digest()
|
||||
).decode()
|
|
@ -30,9 +30,6 @@
|
|||
'gateway4': '10.0.0.1',
|
||||
},
|
||||
},
|
||||
'letsencrypt': {
|
||||
'delegate_to_node': 'htz.mails',
|
||||
},
|
||||
'nginx': {
|
||||
'vhosts': {
|
||||
'openhab.ckn.li': {
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
'dnsserver',
|
||||
],
|
||||
'bundles': [
|
||||
'bind-acme',
|
||||
'islamicstate.eu',
|
||||
'wireguard',
|
||||
'zfs',
|
||||
|
@ -31,6 +32,7 @@
|
|||
},
|
||||
'bind': {
|
||||
'hostname': 'resolver.name',
|
||||
'acme_zone': 'acme.sublimity.de',
|
||||
'zones': {
|
||||
'sublimity.de': {},
|
||||
'freibrief.net': {},
|
||||
|
@ -61,9 +63,15 @@
|
|||
},
|
||||
'letsencrypt': {
|
||||
'domains': {
|
||||
'ckn.li': set(),
|
||||
'sublimity.de': set(),
|
||||
'freibrief.net': set(),
|
||||
'ckn.li': {},
|
||||
'test6.ckn.li': {
|
||||
'aliases': {'www.test6.ckn.li'},
|
||||
'location': '/root/temp',
|
||||
'owner': 'telegraf',
|
||||
'reload': {'telegraf'},
|
||||
},
|
||||
'sublimity.de': {},
|
||||
'freibrief.net': {},
|
||||
},
|
||||
},
|
||||
'mailserver': {
|
||||
|
|
Loading…
Reference in a new issue