|
15 | 15 | # You should have received a copy of the GNU General Public License
|
16 | 16 | # along with this program. If not, see <http://www.gnu.org/licenses/>.
|
17 | 17 |
|
18 |
| -""" CLI base functions and settings |
| 18 | +""" CLI root-level commands; Subcommands are imported at the bottom of file |
19 | 19 | """
|
20 | 20 |
|
21 |
| -import os |
22 | 21 | import sys
|
23 |
| -import logging |
24 |
| -import pprint |
25 |
| -import json |
26 | 22 | import click
|
27 |
| -import yaml |
28 |
| -import tabulate |
29 |
| -from urllib.parse import urlparse |
30 |
| -import dns.resolver |
31 |
| -import re |
32 |
| - |
33 |
| -from synadm import api |
34 |
| - |
35 | 23 |
|
36 | 24 | output_format_help = """The 'human' mode gives a tabular or list view depending
|
37 | 25 | on the fetched data, but often needs a lot of horizontal space to display
|
|
42 | 30 | and is the default on fresh installations."""
|
43 | 31 |
|
44 | 32 |
|
45 |
| -def humanize(data): |
46 |
| - """ Try to display data in a human-readable form: |
47 |
| - - Lists of dicts are displayed as tables. |
48 |
| - - Dicts are displayed as pivoted tables. |
49 |
| - - Lists are displayed as a simple list. |
50 |
| - """ |
51 |
| - if isinstance(data, list) and len(data): |
52 |
| - if isinstance(data[0], dict): |
53 |
| - headers = {header: header for header in data[0]} |
54 |
| - return tabulate.tabulate(data, tablefmt="simple", headers=headers) |
55 |
| - if isinstance(data, list): |
56 |
| - return "\n".join(data) |
57 |
| - if isinstance(data, dict): |
58 |
| - return tabulate.tabulate(data.items(), tablefmt="plain") |
59 |
| - return str(data) |
60 |
| - |
61 |
| - |
62 |
| -class APIHelper: |
63 |
| - """ API client enriched with CLI-level functions, used as a proxy to the |
64 |
| - client object. |
65 |
| - """ |
66 |
| - |
67 |
| - FORMATTERS = { |
68 |
| - "pprint": pprint.pformat, |
69 |
| - "json": lambda data: json.dumps(data, indent=4), |
70 |
| - "minified": lambda data: json.dumps(data, separators=(",", ":")), |
71 |
| - "yaml": yaml.dump, |
72 |
| - "human": humanize |
73 |
| - } |
74 |
| - |
75 |
| - CONFIG = { |
76 |
| - "user": "", |
77 |
| - "token": "", |
78 |
| - "base_url": "http://localhost:8008", |
79 |
| - "admin_path": "/_synapse/admin", |
80 |
| - "matrix_path": "/_matrix", |
81 |
| - "timeout": 30, |
82 |
| - "server_discovery": "well-known", |
83 |
| - "homeserver": "auto-retrieval", |
84 |
| - "ssl_verify": True |
85 |
| - } |
86 |
| - |
87 |
| - def __init__(self, config_path, verbose, no_confirm, output_format_cli): |
88 |
| - self.config = APIHelper.CONFIG.copy() |
89 |
| - self.config_path = os.path.expanduser(config_path) |
90 |
| - self.no_confirm = no_confirm |
91 |
| - self.api = None |
92 |
| - self.init_logger(verbose) |
93 |
| - self.requests_debug = False |
94 |
| - if verbose >= 3: |
95 |
| - self.requests_debug = True |
96 |
| - self.output_format_cli = output_format_cli # override from cli |
97 |
| - |
98 |
| - def init_logger(self, verbose): |
99 |
| - """ Log both to console (defaults to WARNING) and file (DEBUG). |
100 |
| - """ |
101 |
| - log_path = os.path.expanduser("~/.local/share/synadm/debug.log") |
102 |
| - os.makedirs(os.path.dirname(log_path), exist_ok=True) |
103 |
| - log = logging.getLogger("synadm") |
104 |
| - log.setLevel(logging.DEBUG) |
105 |
| - file_handler = logging.FileHandler(log_path, encoding="utf-8") |
106 |
| - file_handler.setLevel(logging.DEBUG) |
107 |
| - console_handler = logging.StreamHandler() |
108 |
| - console_handler.setLevel( |
109 |
| - logging.DEBUG if verbose > 1 else |
110 |
| - logging.INFO if verbose == 1 else |
111 |
| - logging.WARNING |
112 |
| - ) |
113 |
| - file_formatter = logging.Formatter( |
114 |
| - "%(asctime)s %(name)-8s %(levelname)-7s %(message)s", |
115 |
| - datefmt="%Y-%m-%d %H:%M:%S" |
116 |
| - ) |
117 |
| - console_formatter = logging.Formatter("%(levelname)-5s %(message)s") |
118 |
| - console_handler.setFormatter(console_formatter) |
119 |
| - file_handler.setFormatter(file_formatter) |
120 |
| - log.addHandler(console_handler) |
121 |
| - log.addHandler(file_handler) |
122 |
| - self.log = log |
123 |
| - |
124 |
| - def _set_formatter(self, _output_format): |
125 |
| - for name, formatter in APIHelper.FORMATTERS.items(): |
126 |
| - if name.startswith(_output_format): |
127 |
| - self.output_format = name |
128 |
| - self.formatter = formatter |
129 |
| - break |
130 |
| - self.log.debug("Formatter in use: %s - %s", self.output_format, |
131 |
| - self.formatter) |
132 |
| - return True |
133 |
| - |
134 |
| - def load(self): |
135 |
| - """ Load the configuration and initialize the client. |
136 |
| - """ |
137 |
| - try: |
138 |
| - with open(self.config_path) as handle: |
139 |
| - self.config.update(yaml.load(handle, Loader=yaml.SafeLoader)) |
140 |
| - except Exception as error: |
141 |
| - self.log.error("%s while reading configuration file", error) |
142 |
| - for key, value in self.config.items(): |
143 |
| - |
144 |
| - if key == "ssl_verify" and not isinstance(value, bool): |
145 |
| - self.log.error("Config value error: %s, %s must be boolean", |
146 |
| - key, value) |
147 |
| - |
148 |
| - if not value and not isinstance(value, bool): |
149 |
| - self.log.error("Config entry missing: %s, %s", key, value) |
150 |
| - return False |
151 |
| - else: |
152 |
| - if key == "token": |
153 |
| - self.log.debug("Config entry read. %s: REDACTED", key) |
154 |
| - else: |
155 |
| - self.log.debug("Config entry read. %s: %s", key, value) |
156 |
| - if self.output_format_cli: # we have a cli output format override |
157 |
| - self._set_formatter(self.output_format_cli) |
158 |
| - else: # we use the configured default output format |
159 |
| - self._set_formatter(self.config["format"]) |
160 |
| - self.api = api.SynapseAdmin( |
161 |
| - self.log, |
162 |
| - self.config["user"], self.config["token"], |
163 |
| - self.config["base_url"], self.config["admin_path"], |
164 |
| - self.config["timeout"], self.requests_debug, |
165 |
| - self.config["ssl_verify"] |
166 |
| - ) |
167 |
| - self.matrix_api = api.Matrix( |
168 |
| - self.log, |
169 |
| - self.config["user"], self.config["token"], |
170 |
| - self.config["base_url"], self.config["matrix_path"], |
171 |
| - self.config["timeout"], self.requests_debug, |
172 |
| - self.config["ssl_verify"] |
173 |
| - ) |
174 |
| - self.misc_request = api.MiscRequest( |
175 |
| - self.log, |
176 |
| - self.config["timeout"], self.requests_debug, |
177 |
| - self.config["ssl_verify"] |
178 |
| - ) |
179 |
| - return True |
180 |
| - |
181 |
| - def write_config(self, config): |
182 |
| - """ Write a new version of the configuration to file. |
183 |
| - """ |
184 |
| - try: |
185 |
| - os.makedirs(os.path.dirname(self.config_path), exist_ok=True) |
186 |
| - with open(self.config_path, "w") as handle: |
187 |
| - yaml.dump(config, handle, default_flow_style=False, |
188 |
| - allow_unicode=True) |
189 |
| - if os.name == "posix": |
190 |
| - click.echo("Restricting access to config file to user only.") |
191 |
| - os.chmod(self.config_path, 0o600) |
192 |
| - else: |
193 |
| - click.echo(f"Unsupported OS, please adjust permissions of " |
194 |
| - f"{self.config_path} manually") |
195 |
| - |
196 |
| - return True |
197 |
| - except Exception as error: |
198 |
| - self.log.error("%s trying to write configuration", error) |
199 |
| - return False |
200 |
| - |
201 |
| - def output(self, data): |
202 |
| - """ Output data object using the configured formatter. |
203 |
| - """ |
204 |
| - click.echo(self.formatter(data)) |
205 |
| - |
206 |
| - def retrieve_homeserver_name(self, uri=None): |
207 |
| - """Try to retrieve the homeserver name. |
208 |
| -
|
209 |
| - When homeserver is set in the config already, it's just returned and |
210 |
| - nothing is tried to be fetched automatically. If not, either the |
211 |
| - location of the Federation API is looked up via a .well-known resource |
212 |
| - or a DNS SRV lookup. This depends on the server_discovery setting in |
213 |
| - the config. Finally the Federation API is used to retrieve the |
214 |
| - homeserver name. |
215 |
| -
|
216 |
| - Args: |
217 |
| - uri (string): proto://name:port or proto://fqdn:port |
218 |
| -
|
219 |
| - Returns: |
220 |
| - string: hostname, FQDN or DOMAIN; or None on errors. |
221 |
| - """ |
222 |
| - uri = uri if uri else self.config["base_url"] |
223 |
| - echo = self.log.info if self.no_confirm else click.echo |
224 |
| - if self.config["homeserver"] != "auto-retrieval": |
225 |
| - return self.config["homeserver"] |
226 |
| - |
227 |
| - if self.config["server_discovery"] == "well-known": |
228 |
| - if "localhost" in self.config["base_url"]: |
229 |
| - echo( |
230 |
| - "Trying to fetch homeserver name via localhost..." |
231 |
| - ) |
232 |
| - return self.matrix_api.server_name_keys_api( |
233 |
| - self.config["base_url"] |
234 |
| - ) |
235 |
| - else: |
236 |
| - echo( |
237 |
| - "Trying to fetch federation URI via well-known resource..." |
238 |
| - ) |
239 |
| - federation_uri = self.misc_request.federation_uri_well_known( |
240 |
| - uri |
241 |
| - ) |
242 |
| - if not federation_uri: |
243 |
| - return None |
244 |
| - return self.matrix_api.server_name_keys_api(federation_uri) |
245 |
| - elif self.config["server_discovery"] == "dns": |
246 |
| - echo( |
247 |
| - "Trying to fetch federation URI via DNS SRV record..." |
248 |
| - ) |
249 |
| - hostname = urlparse(uri).hostname |
250 |
| - try: |
251 |
| - record = dns.resolver.query( |
252 |
| - "_matrix._tcp.{}".format(hostname), |
253 |
| - "SRV" |
254 |
| - ) |
255 |
| - except Exception as error: |
256 |
| - self.log.error( |
257 |
| - "resolving Matrix delegation for %s: %s: %s", |
258 |
| - hostname, type(error).__name__, error |
259 |
| - ) |
260 |
| - else: |
261 |
| - federation_uri = "https://{}:{}".format( |
262 |
| - record[0].target, record[0].port |
263 |
| - ) |
264 |
| - return self.matrix_api.server_name_keys_api(federation_uri) |
265 |
| - else: |
266 |
| - self.log.error("Unknown server_discovery mode. " |
267 |
| - "Launch synadm config!") |
268 |
| - return None |
269 |
| - |
270 |
| - def generate_mxid(self, user_id): |
271 |
| - """ Checks whether the given user ID is an MXID already or else |
272 |
| - generates it from the passed string and the homeserver name fetched |
273 |
| - via the retrieve_homeserver_name method. |
274 |
| -
|
275 |
| - Args: |
276 |
| - user_id (string): User ID given by user as command argument. |
277 |
| -
|
278 |
| - Returns: |
279 |
| - string: the fully qualified Matrix User ID (MXID) or None if the |
280 |
| - user_id parameter is None or no regex matched. |
281 |
| - """ |
282 |
| - if user_id is None: |
283 |
| - self.log.debug("Missing input in generate_mxid().") |
284 |
| - return None |
285 |
| - elif re.match(r"^@[-./=\w]+:[-\[\].:\w]+$", user_id): |
286 |
| - self.log.debug("A proper MXID was passed.") |
287 |
| - return user_id |
288 |
| - elif re.match(r"^@?[-./=\w]+:?$", user_id): |
289 |
| - self.log.debug("A proper localpart was passed, generating MXID " |
290 |
| - "for local homeserver.") |
291 |
| - localpart = re.sub("[@:]", "", user_id) |
292 |
| - mxid = "@{}:{}".format(localpart, self.retrieve_homeserver_name()) |
293 |
| - return mxid |
294 |
| - else: |
295 |
| - self.log.error("Neither an MXID nor a proper localpart was " |
296 |
| - "passed.") |
297 |
| - return None |
298 |
| - |
299 |
| - |
300 | 33 | @click.group(
|
301 | 34 | invoke_without_command=False,
|
302 | 35 | context_settings=dict(help_option_names=["-h", "--help"]))
|
@@ -328,6 +61,7 @@ def generate_mxid(self, user_id):
|
328 | 61 | def root(ctx, verbose, no_confirm, output, config_file):
|
329 | 62 | """ the Matrix-Synapse admin CLI
|
330 | 63 | """
|
| 64 | + from synadm.cli._helper import APIHelper |
331 | 65 | ctx.obj = APIHelper(config_file, verbose, no_confirm, output)
|
332 | 66 | helper_loaded = ctx.obj.load()
|
333 | 67 | if ctx.invoked_subcommand != "config" and not helper_loaded:
|
|
0 commit comments