Add hook system
This commit is contained in:
@@ -72,3 +72,50 @@ dns-zone-list example.tld
|
|||||||
# Add an A record
|
# Add an A record
|
||||||
dns-record-add example.tld www 3600 A 192.0.2.1
|
dns-record-add example.tld www 3600 A 192.0.2.1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
Executable scripts placed in the hooks base directory (`hooks_dir`, default
|
||||||
|
`<etc_dir>/hooks`) are run around zone and record mutations. The tree narrows
|
||||||
|
progressively and every level is optional:
|
||||||
|
|
||||||
|
```
|
||||||
|
hooks/ -> every action and phase (zone+record, add+delete, pre+post)
|
||||||
|
zone/ -> all zone actions/phases
|
||||||
|
add/ -> zone add, both phases
|
||||||
|
pre/ -> zone add, pre only
|
||||||
|
post/ -> zone add, post only
|
||||||
|
delete/{pre,post}/
|
||||||
|
record/
|
||||||
|
add/{pre,post}/
|
||||||
|
delete/{pre,post}/
|
||||||
|
```
|
||||||
|
|
||||||
|
For a given event `(kind, action, phase)` all matching hooks across every
|
||||||
|
existing level along that event's path (`hooks/` → `hooks/<kind>/` →
|
||||||
|
`hooks/<kind>/<action>/` → `hooks/<kind>/<action>/<phase>/`) are collected and
|
||||||
|
run sorted by filename (run-parts style), so a numeric prefix (e.g. `50-`)
|
||||||
|
controls execution order regardless of directory depth. A hook placed shallower
|
||||||
|
in the tree fires for more events; distinguish the concrete event via the
|
||||||
|
environment variables. Each hook inherits the terminal, so its stdout and stderr
|
||||||
|
appear live and in order, and a hook may be interactive: it can print prompts and
|
||||||
|
read the user's input from stdin. In batch mode (`-b`) stdin is redirected to
|
||||||
|
`/dev/null`, so a hook that reads input gets an immediate EOF instead of blocking
|
||||||
|
(hooks that prompt should guard on a tty/EOF).
|
||||||
|
Only executable regular files are run; everything else (including the
|
||||||
|
`zone`/`record`/`add`/`delete`/`pre`/`post` subdirectories) is skipped, and
|
||||||
|
missing directories are no-ops.
|
||||||
|
|
||||||
|
Each hook receives context via environment variables:
|
||||||
|
|
||||||
|
* Common env: `DNSMGR_KIND` (zone|record), `DNSMGR_ACTION` (add|delete),
|
||||||
|
`DNSMGR_PHASE` (pre|post), `DNSMGR_ZONE` (origin, no trailing dot), `DNSMGR_VIEW`.
|
||||||
|
* Record-only env: `DNSMGR_RECORD_NAME`, `DNSMGR_RECORD_TTL`,
|
||||||
|
`DNSMGR_RECORD_TYPE`, `DNSMGR_RECORD_VALUE` (values joined by newlines).
|
||||||
|
* `DNSMGR_ZONE_FILE` (zone file path): set only for managed zones and not for zone add pre-hooks and not for zone delete post-hooks.
|
||||||
|
|
||||||
|
A `pre` hook exiting non-zero aborts the operation before any change is applied
|
||||||
|
(exit code 190); a `post` hook failure only prints an `ERROR:` and continues.
|
||||||
|
|
||||||
|
Pass `-n`/`--no-hooks` to `dns-zone-add`, `dns-zone-delete`, `dns-record-add` or
|
||||||
|
`dns-record-delete` to skip all hook execution for that run.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ python_install_all() {
|
|||||||
doins files/templates/*.template
|
doins files/templates/*.template
|
||||||
|
|
||||||
keepdir /etc/${PN}/default.zones
|
keepdir /etc/${PN}/default.zones
|
||||||
|
keepdir /etc/${PN}/hooks
|
||||||
|
|
||||||
distutils-r1_python_install_all
|
distutils-r1_python_install_all
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,27 @@
|
|||||||
|
|
||||||
#dns_ip: 127.0.0.1
|
#dns_ip: 127.0.0.1
|
||||||
|
|
||||||
|
#
|
||||||
|
# Optional path to the hooks base directory (default: <etc_dir>/hooks).
|
||||||
|
# Executable files placed in this tree are run around zone and record mutations. The tree narrows
|
||||||
|
# progressively; every level is optional and a hook fires for every event along its path:
|
||||||
|
#
|
||||||
|
# hooks/ -> every action and phase (zone+record, add+delete, pre+post)
|
||||||
|
# hooks/<kind>/ -> all actions/phases of <kind> (zone|record)
|
||||||
|
# hooks/<kind>/<action>/ -> both phases of <action> (add|delete)
|
||||||
|
# hooks/<kind>/<action>/<phase>/ -> single phase only (pre|post)
|
||||||
|
#
|
||||||
|
# All matching hooks across all levels are collected and run sorted by filename (run-parts style);
|
||||||
|
# only executable files are run. Each hook inherits the terminal: stdout/stderr appear live and it
|
||||||
|
# may be interactive (prompt and read stdin). In batch mode (-b) stdin is /dev/null, so reads get EOF.
|
||||||
|
# A pre-hook exiting non-zero aborts the operation (exit code 190); a post-hook failure prints an ERROR and continues.
|
||||||
|
# Hooks receive context via env vars (DNSMGR_KIND/ACTION/PHASE/ZONE/VIEW and, for records,
|
||||||
|
# DNSMGR_RECORD_NAME/TTL/TYPE/VALUE).
|
||||||
|
# DNSMGR_ZONE_FILE (zone file path) is set only for managed zones and and not for zone add pre-hooks nor for zone delete post-hooks.
|
||||||
|
#
|
||||||
|
|
||||||
|
#hooks_dir: /etc/dns-manager/hooks
|
||||||
|
|
||||||
#
|
#
|
||||||
# Dictionary of paths to key files (TSIG) per view used for zone transfers and DDNS updates.
|
# Dictionary of paths to key files (TSIG) per view used for zone transfers and DDNS updates.
|
||||||
# This option is mandatory when using views other than the default (_default) or if the usage of a key
|
# This option is mandatory when using views other than the default (_default) or if the usage of a key
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from hashlib import sha1
|
from hashlib import sha1
|
||||||
@@ -47,6 +48,35 @@ def printe(msg):
|
|||||||
print(f'ERROR: {msg}')
|
print(f'ERROR: {msg}')
|
||||||
|
|
||||||
|
|
||||||
|
def run_hooks(hooks_dir, kind, action, phase, env, batch=False):
|
||||||
|
dirs = [
|
||||||
|
hooks_dir,
|
||||||
|
os.path.join(hooks_dir, kind),
|
||||||
|
os.path.join(hooks_dir, kind, action),
|
||||||
|
os.path.join(hooks_dir, kind, action, phase),
|
||||||
|
]
|
||||||
|
|
||||||
|
hooks = []
|
||||||
|
for hook_dir in dirs:
|
||||||
|
if not os.path.isdir(hook_dir):
|
||||||
|
break
|
||||||
|
|
||||||
|
for entry in os.listdir(hook_dir):
|
||||||
|
path = os.path.join(hook_dir, entry)
|
||||||
|
if not os.path.isfile(path) or not os.access(path, os.X_OK):
|
||||||
|
continue
|
||||||
|
hooks.append(path)
|
||||||
|
|
||||||
|
stdin = subprocess.DEVNULL if batch else None
|
||||||
|
for path in sorted(hooks, key=os.path.basename):
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
res = subprocess.run([path], env={**os.environ, **env}, stdin=stdin)
|
||||||
|
if res.returncode != 0:
|
||||||
|
msg = f'hook failed (exit {res.returncode}): {path}'
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
def prettytable(field_names, rows, truncate=False):
|
def prettytable(field_names, rows, truncate=False):
|
||||||
t = PrettyTable()
|
t = PrettyTable()
|
||||||
|
|
||||||
@@ -346,6 +376,10 @@ class DNSManagerConfig:
|
|||||||
if not isinstance(self.etc_dir, str):
|
if not isinstance(self.etc_dir, str):
|
||||||
raise RuntimeError('etc_dir: value is not a string')
|
raise RuntimeError('etc_dir: value is not a string')
|
||||||
|
|
||||||
|
self.hooks_dir = config.get('hooks_dir', os.path.join(self.etc_dir, 'hooks'))
|
||||||
|
if not isinstance(self.hooks_dir, str):
|
||||||
|
raise RuntimeError('hooks_dir: value is not a string')
|
||||||
|
|
||||||
self.control_key = config.get('control_key')
|
self.control_key = config.get('control_key')
|
||||||
if not isinstance(self.control_key, (str, type(None))):
|
if not isinstance(self.control_key, (str, type(None))):
|
||||||
raise RuntimeError('control_key: value is not a string')
|
raise RuntimeError('control_key: value is not a string')
|
||||||
@@ -784,3 +818,37 @@ class DNSManager:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f'unable to delete zone file: {e}')
|
raise RuntimeError(f'unable to delete zone file: {e}')
|
||||||
|
|
||||||
|
def run_zone_hooks(self, action, phase, name, view, batch=False):
|
||||||
|
origin = name.to_text(omit_final_dot=True)
|
||||||
|
env = {
|
||||||
|
'DNSMGR_KIND': 'zone',
|
||||||
|
'DNSMGR_ACTION': action,
|
||||||
|
'DNSMGR_PHASE': phase,
|
||||||
|
'DNSMGR_ZONE': origin,
|
||||||
|
'DNSMGR_VIEW': view,
|
||||||
|
}
|
||||||
|
if view in self.config.zones_config and not (action == 'add' and phase == 'pre') \
|
||||||
|
and not (action == 'delete' and phase == 'post'):
|
||||||
|
env['DNSMGR_ZONE_FILE'] = os.path.join(self.config.zones_config[view].zone_dir, f'{origin}.zone')
|
||||||
|
run_hooks(self.config.hooks_dir, 'zone', action, phase, env, batch)
|
||||||
|
|
||||||
|
def run_record_hooks(self, action, phase, zone, name, rdataset, batch=False):
|
||||||
|
origin = zone.origin.to_text(omit_final_dot=True)
|
||||||
|
rdname = name.to_text(omit_final_dot=True)
|
||||||
|
rdtype = rdataset.rdtype.to_text(rdataset.rdtype)
|
||||||
|
values = [rdata.to_text(origin=zone.origin, relativize=False) for rdata in rdataset]
|
||||||
|
env = {
|
||||||
|
'DNSMGR_KIND': 'record',
|
||||||
|
'DNSMGR_ACTION': action,
|
||||||
|
'DNSMGR_PHASE': phase,
|
||||||
|
'DNSMGR_ZONE': origin,
|
||||||
|
'DNSMGR_VIEW': zone.view,
|
||||||
|
'DNSMGR_RECORD_NAME': rdname,
|
||||||
|
'DNSMGR_RECORD_TTL': str(rdataset.ttl),
|
||||||
|
'DNSMGR_RECORD_TYPE': rdtype,
|
||||||
|
'DNSMGR_RECORD_VALUE': '\n'.join(values),
|
||||||
|
}
|
||||||
|
if zone.cfgfile is not None:
|
||||||
|
env['DNSMGR_ZONE_FILE'] = zone.zonefile
|
||||||
|
run_hooks(self.config.hooks_dir, 'record', action, phase, env, batch)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def main():
|
|||||||
parser.add_argument('-A', '--all-types', help='allow unsupported record types', action='store_true')
|
parser.add_argument('-A', '--all-types', help='allow unsupported record types', action='store_true')
|
||||||
parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
|
parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
|
||||||
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
||||||
|
parser.add_argument('-n', '--no-hooks', action='store_true', help='skip hook execution')
|
||||||
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
||||||
parser.add_argument('name', metavar='NAME', nargs=nargs, help='DNS record name, @ refers to zone apex', default=None)
|
parser.add_argument('name', metavar='NAME', nargs=nargs, help='DNS record name, @ refers to zone apex', default=None)
|
||||||
parser.add_argument('ttl', metavar='TTL', nargs=nargs, help='DNS record TTL in seconds', type=int, default=None)
|
parser.add_argument('ttl', metavar='TTL', nargs=nargs, help='DNS record TTL in seconds', type=int, default=None)
|
||||||
@@ -92,6 +93,13 @@ def main():
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
|
if not args.no_hooks:
|
||||||
|
try:
|
||||||
|
manager.run_record_hooks('add', 'pre', zone, name, rdataset, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
sys.exit(190)
|
||||||
|
|
||||||
origin = zone.origin.to_text(omit_final_dot=True)
|
origin = zone.origin.to_text(omit_final_dot=True)
|
||||||
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
|
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
|
||||||
origin = f'{origin}@{zone.view}'
|
origin = f'{origin}@{zone.view}'
|
||||||
@@ -104,6 +112,12 @@ def main():
|
|||||||
printe(e)
|
printe(e)
|
||||||
sys.exit(160)
|
sys.exit(160)
|
||||||
|
|
||||||
|
if not args.no_hooks:
|
||||||
|
try:
|
||||||
|
manager.run_record_hooks('add', 'post', zone, name, rdataset, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ def main():
|
|||||||
parser.add_argument('-A', '--all-types', help='allow unsupported record types', action='store_true')
|
parser.add_argument('-A', '--all-types', help='allow unsupported record types', action='store_true')
|
||||||
parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
|
parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
|
||||||
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
||||||
|
parser.add_argument('-n', '--no-hooks', action='store_true', help='skip hook execution')
|
||||||
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
||||||
parser.add_argument('name', metavar='NAME', nargs=nargs, help='DNS record name, @ refers to zone apex', default=None)
|
parser.add_argument('name', metavar='NAME', nargs=nargs, help='DNS record name, @ refers to zone apex', default=None)
|
||||||
parser.add_argument('type', metavar='TYPE', nargs=nargs, help='DNS record type', default=None)
|
parser.add_argument('type', metavar='TYPE', nargs=nargs, help='DNS record type', default=None)
|
||||||
@@ -129,14 +130,21 @@ def main():
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
|
node = zone.find_node(name)
|
||||||
|
rdataset = node.find_rdataset(dns.rdataclass.IN, rdtype)
|
||||||
|
|
||||||
|
if not args.no_hooks:
|
||||||
|
try:
|
||||||
|
manager.run_record_hooks('delete', 'pre', zone, name, rdataset, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
sys.exit(190)
|
||||||
|
|
||||||
origin = zone.origin.to_text(omit_final_dot=True)
|
origin = zone.origin.to_text(omit_final_dot=True)
|
||||||
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
|
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
|
||||||
origin = f'{origin}@{zone.view}'
|
origin = f'{origin}@{zone.view}'
|
||||||
print(f"Sending DDNS updates for '{origin}'... ", end='')
|
print(f"Sending DDNS updates for '{origin}'... ", end='')
|
||||||
|
|
||||||
node = zone.find_node(name)
|
|
||||||
rdataset = node.find_rdataset(dns.rdataclass.IN, rdtype)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
manager.delete_zone_record(zone, name, rdataset)
|
manager.delete_zone_record(zone, name, rdataset)
|
||||||
print('OK')
|
print('OK')
|
||||||
@@ -144,6 +152,12 @@ def main():
|
|||||||
printe(e)
|
printe(e)
|
||||||
sys.exit(160)
|
sys.exit(160)
|
||||||
|
|
||||||
|
if not args.no_hooks:
|
||||||
|
try:
|
||||||
|
manager.run_record_hooks('delete', 'post', zone, name, rdataset, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def main():
|
|||||||
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
||||||
parser.add_argument('-t', '--config-template', help='config file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
|
parser.add_argument('-t', '--config-template', help='config file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
|
||||||
parser.add_argument('-z', '--zone-template', help='zone file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
|
parser.add_argument('-z', '--zone-template', help='zone file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
|
||||||
|
parser.add_argument('-n', '--no-hooks', action='store_true', help='skip hook execution')
|
||||||
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -83,6 +84,13 @@ def main():
|
|||||||
|
|
||||||
zones = []
|
zones = []
|
||||||
for view in views:
|
for view in views:
|
||||||
|
if not args.no_hooks:
|
||||||
|
try:
|
||||||
|
manager.run_zone_hooks('add', 'pre', name, view, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
sys.exit(190)
|
||||||
|
|
||||||
origin = name.to_text(omit_final_dot=True)
|
origin = name.to_text(omit_final_dot=True)
|
||||||
if len(views) > 1 or view != NAMED_DEFAULT_VIEW:
|
if len(views) > 1 or view != NAMED_DEFAULT_VIEW:
|
||||||
origin = f'{origin}@{view}'
|
origin = f'{origin}@{view}'
|
||||||
@@ -136,6 +144,13 @@ def main():
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
printe(e)
|
printe(e)
|
||||||
|
|
||||||
|
if not args.no_hooks:
|
||||||
|
for view in views:
|
||||||
|
try:
|
||||||
|
manager.run_zone_hooks('add', 'post', name, view, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description='Delete DNS zones.')
|
parser = argparse.ArgumentParser(description='Delete DNS zones.')
|
||||||
parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
|
parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
|
||||||
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
|
||||||
|
parser.add_argument('-n', '--no-hooks', action='store_true', help='skip hook execution')
|
||||||
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -46,6 +47,13 @@ def main():
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
|
if not args.no_hooks:
|
||||||
|
try:
|
||||||
|
manager.run_zone_hooks('delete', 'pre', zone.origin, zone.view, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
sys.exit(190)
|
||||||
|
|
||||||
origin = zone.origin.to_text(omit_final_dot=True)
|
origin = zone.origin.to_text(omit_final_dot=True)
|
||||||
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
|
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
|
||||||
origin = f'{origin}@{zone.view}'
|
origin = f'{origin}@{zone.view}'
|
||||||
@@ -105,6 +113,12 @@ def main():
|
|||||||
printe(e)
|
printe(e)
|
||||||
sys.exit(180)
|
sys.exit(180)
|
||||||
|
|
||||||
|
if not args.no_hooks:
|
||||||
|
try:
|
||||||
|
manager.run_zone_hooks('delete', 'post', zone.origin, zone.view, args.batch)
|
||||||
|
except RuntimeError as e:
|
||||||
|
printe(e)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user