From 054031c388e642229eb61b33c87efc28445bfc5e Mon Sep 17 00:00:00 2001 From: Jayce Birrell Date: Tue, 14 Oct 2025 16:00:14 +1030 Subject: [PATCH 1/2] New protocol command to allow quick switch between http and https --- mdk/commands/__init__.py | 1 + mdk/commands/protocol.py | 388 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 mdk/commands/protocol.py diff --git a/mdk/commands/__init__.py b/mdk/commands/__init__.py index 19d29c9..c651afd 100644 --- a/mdk/commands/__init__.py +++ b/mdk/commands/__init__.py @@ -46,6 +46,7 @@ def getCommand(cmd): 'phpunit', 'plugin', 'precheck', + 'protocol', 'pull', 'purge', 'push', diff --git a/mdk/commands/protocol.py b/mdk/commands/protocol.py new file mode 100644 index 0000000..8061c1b --- /dev/null +++ b/mdk/commands/protocol.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Moodle Development Kit + +Copyright (c) 2014 Frédéric Massart - FMCorz.net + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +http://github.com/FMCorz/mdk + +MDK - Protocol switcher: toggle a Moodle instance between HTTP and HTTPS +- HTTPS via localhost.run (currnetly only option) +- HTTP uses http:/// +- Updates $CFG->wwwroot and $CFG->sslproxy +""" + +import json +import logging +import os +import re +import signal +import subprocess +import time + +from urllib.parse import urlparse, urlunparse +from typing import Union + +from ..command import Command +from ..config import Conf +from ..tools import yesOrNo + +_TUNNEL_META = '.mdk_protocol_tunnel.json' +_LOG_FILE = '.mdk_protocol_tunnel.log' + +class ProtocolCommand(Command): + _arguments = [ + (['mode'], { + 'choices': ['http', 'https', 'toggle'], + 'help': 'Target protocol or "toggle"', + }), + (['-f'], { + 'action': 'store_true', + 'dest': 'force', + 'help': 'Force without confirmation (dangerous)', + }), + (['--port'], { + 'type': int, + 'default': 80, + 'help': 'Local HTTP port your instance is served on (for tunnelling).', + }), + (['--show-log'], { + 'action': 'store_true', + 'help': 'Stream tunnel output in this terminal and write it to a log file', + }), + (['name'], { + 'default': None, + 'help': 'Name of the instance to work on', + 'metavar': 'name', + 'nargs': '?' + }), + ] + _description = 'Switch (http <-> https) by launching a tunnel using localhost.run and rewriting $CFG->wwwroot and $CFG->sslproxy in config.php' + + def run(self, args): + M = self.Wp.resolve(args.name) + if not M: + raise Exception('No instance to work on.') + + instpath = M.get('path') + configphp = _find_config_php(instpath) + + src = _read(configphp) + wwwroot = _extract_wwwroot(src) + if not wwwroot: + raise Exception(f'Could not locate $CFG->wwwroot in {configphp}') + + cur_scheme = (urlparse(wwwroot).scheme or 'http').lower() + target = {'toggle': ('https' if cur_scheme == 'http' else 'http')}.get(args.mode, args.mode) + + # Decide new URL + sslproxy + if target == 'https': + label = M.get('identifier') + public_url, proc = _ensure_localhost_run(args.port, instpath, label, args.show_log) + new_wwwroot = _apply_base_to_path(public_url, urlparse(wwwroot).path) + sslproxy = True + _persist_tunnel_info(instpath, proc, kind='localhost.run') + else: + # HTTP: stop tunnels and build http:/// + _stop_localhost_run_if_any(instpath) + _delete_tunnel_meta(instpath) + + instname = (args.name or M.get('identifier')) or 'moodle' + C = Conf() + base_host = (C.get('host') or C.get('webserver.host') or '127.0.0.1') + new_wwwroot = _compose_url('http', base_host, f'/{instname}') + sslproxy = False + + if new_wwwroot == wwwroot and not args.force: + logging.info('wwwroot unchanged (%s). Use -f to rewrite anyway.', new_wwwroot) + return + + # Confirm + if not args.force: + if not yesOrNo(f'Change URL:\n {wwwroot}\n→ {new_wwwroot}\nProceed?'): + logging.info('Aborting...') + return + + # Write back atomically: wwwroot + sslproxy + newsrc = _set_sslproxy(_rewrite_wwwroot(src, new_wwwroot), sslproxy) + _atomic_write(configphp, newsrc) + + logging.info('Protocol switched to %s', target.upper()) + logging.info('New $CFG->wwwroot: %s', new_wwwroot) + logging.info('$CFG->sslproxy set to %s', 'true' if sslproxy else 'false') + + +# ------------ file helpers ------------ + +def _find_config_php(instpath: str) -> str: + guess = os.path.join(instpath, 'config.php') + if os.path.isfile(guess): + return guess + raise Exception('config.php not found under %s' % instpath) + +def _read(path: str) -> str: + return open(path, 'r', encoding='utf-8', errors='ignore').read() + +def _atomic_write(path: str, content: str) -> None: + """Write file atomically to avoid torn writes.""" + tmp = f"{path}.tmp.mdk" + with open(tmp, 'w', encoding='utf-8') as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + +# ------------ config.php mutation ------------ + +def _extract_wwwroot(src: str) -> Union[str, None]: + m = re.search(r"""\$CFG->wwwroot\s*=\s*(['"])(.*?)\1\s*;""", src, re.I | re.S) + return m.group(2) if m else None + +def _rewrite_wwwroot(src: str, new_url: str) -> str: + def repl(m): + q = m.group(1) + return f"$CFG->wwwroot = {q}{new_url}{q};" + return re.sub(r"""\$CFG->wwwroot\s*=\s*(['"])(.*?)\1\s*;""", repl, src, flags=re.I | re.S) + +def _set_sslproxy(src: str, enabled: bool) -> str: + patt = re.compile(r"""\$CFG->sslproxy\s*=\s*(true|false)\s*;""", re.I) + if patt.search(src): + return patt.sub(f"$CFG->sslproxy = {'true' if enabled else 'false'};", src) + www_patt = re.compile(r"""(\$CFG->wwwroot\s*=\s*['"].*?['"]\s*;)""", re.I | re.S) + if www_patt.search(src): + return www_patt.sub(rf"\1\n$CFG->sslproxy = {'true' if enabled else 'false'};", src, count=1) + return src.rstrip() + f"\n$CFG->sslproxy = {'true' if enabled else 'false'};\n" + + +# ------------ URL helpers ------------ + +def _compose_url(scheme: str, host: str, path: str) -> str: + return urlunparse((scheme, host, path or '/', '', '', '')) + +def _apply_base_to_path(base: str, path: str) -> str: + p = urlparse(base) + return urlunparse((p.scheme, p.netloc, path or '/', '', '', '')) + + +# ------------ tunnel metadata ------------ + +def _tunnel_meta_path(instpath: str) -> str: + """ + Return the absolute path to the metadata file used to remember the last + tunnel we spawned for this instance. + + We persist a tiny JSON blob alongside the Moodle codebase (under `instpath`): + { + "pid": , # OS process id of the tunnel process we spawned + "kind": "localhost.run" # which provider created the tunnel + } + + Why this exists: + - When switching back to HTTP, or switching providers, we need to cleanly + stop any *previous* tunnel we started. To do that reliably across runs, + we remember the PID and provider. + + Notes: + - This file is overwritten each time a new tunnel is created. + - If the process dies or is killed externally, the pid may be stale; our + stop routine tolerates errors and simply removes the file. + """ + return os.path.join(instpath, _TUNNEL_META) + + +def _persist_tunnel_info(instpath: str, proc: subprocess.Popen, kind: Union[str, None] = None) -> None: + """ + Write the current tunnel's PID and provider 'kind' to the metadata file. + + Called right after a tunnel has been successfully started (we have a + subprocess.Popen object and a public URL). This enables later cleanup. + + Parameters: + instpath: path to the Moodle instance root (where we store the file) + proc: the Popen object returned by the tunnel launcher + kind: 'localhost.run' (used to selectively stop) + If None, the stop routine will match any kind. + + Failure handling: + - Any exception here is ignored — not fatal for the main flow — but it + means we may not be able to auto-stop the tunnel later. + """ + try: + with open(_tunnel_meta_path(instpath), 'w', encoding='utf-8') as f: + json.dump({'pid': proc.pid, 'kind': kind}, f) + except Exception: + # Non-fatal: if persisting fails, we just won't be able to auto-stop. + pass + + +def _delete_tunnel_meta(instpath: str) -> None: + """ + Remove the metadata file unconditionally. + + Use this when: + - Tearing down tunnels (HTTP mode), after we have attempted to stop them. + - Resetting state before creating a new tunnel/provider. + + Safe to call even if the file does not exist. Any errors are ignored. + """ + try: + os.remove(_tunnel_meta_path(instpath)) + except Exception: + pass + + +# ------------ tunnel management: localhost.run ------------ + +def _stop_localhost_run_if_any(instpath: str) -> None: + _stop_tunnel_by_kind(instpath, 'localhost.run') + +def _ensure_localhost_run(local_port: int, instpath: str, label: str, show_log: bool): + """ + Start a localhost.run reverse SSH tunnel headlessly. + Prefer JSON lines if present; fallback to regex that matches *.lhr.life + """ + ssh_cmd = [ + 'ssh', + '-R', + f'80:localhost:{local_port}', + 'localhost.run', + '--', + '--output', + 'json' # structured output if supported + ] + proc = subprocess.Popen( + ssh_cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + # text=True, + universal_newlines=True, + bufsize=1 + ) + + # Accept e.g. https://44699e2cafddde.lhr.life + url_re = re.compile( + r'https:\/\/[a-z0-9]+\.1hr\.life\b', + re.IGNORECASE + ) + + def emit(line: str): + line = _to_str(line) + with open(_log_path(instpath), 'a', encoding='utf-8', errors='ignore') as lf: + lf.write(line) + if show_log: + print(f"[localhost.run] {line}", end='') + + def _to_str(s): + # robust across 3.6/3.12: convert bytes -> str + return s.decode('utf-8', 'ignore') if isinstance(s, (bytes, bytearray)) else s + + public_url = None + t0 = time.time() + timeout_sec = 25 + + while time.time() - t0 < timeout_sec: + line = proc.stdout.readline() + if not line: + if proc.poll() is not None: + break + time.sleep(0.05) + continue + + line = _to_str(line) + emit(line) + + # JSON-first path (some servers emit structured events) + if line.lstrip().startswith('{'): + try: + evt = json.loads(line) + host = evt.get('address') or evt.get('listen_host') + if host: + scheme = 'https' if evt.get('tls_termination', True) else 'http' + public_url = f"{scheme}://{host}" + break + msg = evt.get('message') or '' + m = url_re.search(msg) + if m: + public_url = m.group(0) + break + for key in ('url', 'public_url', 'domain'): + val = evt.get(key) + if isinstance(val, str) and val.startswith('http'): + public_url = val + break + if public_url: + break + except json.JSONDecodeError: + pass + + # Plaintext fallback + m = url_re.search(line) + if m: + public_url = m.group(0) + break + + if not public_url: + try: + proc.terminate() + except Exception: + pass + raise Exception(f"Failed to detect localhost.run URL. See log: {_log_path(instpath)}") + + logging.info('localhost.run tunnel up for %s → %s', label, public_url) + return public_url, proc + +# ------------ shared tunnel stop / log paths ------------ + + +def _stop_tunnel_by_kind(instpath: str, kind_expected: Union[str, None]) -> None: + """ + Best-effort stop of a previously-started tunnel process recorded in metadata. + + Reads /.mdk_protocol_tunnel.json for: + - pid: the process id to signal + - kind: the provider that created it (current only 'localhost.run') + + Behavior: + - If 'kind_expected' is None, any recorded tunnel is eligible to stop. + - If 'kind_expected' is provided, only stop when it matches the recorded 'kind'. + + Important: + - This only stops tunnels we spawned in this command (because we record + their PID). If a user started a separate manual tunnel, we won't touch it. + - We do not delete the metadata file here; higher-level callers decide + whether to keep or remove it (e.g., HTTP mode deletes it). + """ + meta = _tunnel_meta_path(instpath) + if not os.path.isfile(meta): + return + try: + with open(meta, 'r', encoding='utf-8') as f: + data = json.load(f) + pid = int(data.get('pid', 0)) + kind = data.get('kind') + if pid > 0 and (kind == kind_expected or kind_expected is None): + os.kill(pid, signal.SIGTERM) # polite stop; tunnels should exit + time.sleep(0.4) # brief grace period + except Exception: + # Non-fatal: metadata may be stale or PID already gone. + pass + +def _log_path(instpath: str) -> str: + return os.path.join(instpath, _LOG_FILE) From 92ac677cc187b481d16276ac521413a508433f50 Mon Sep 17 00:00:00 2001 From: Jayce Birrell Date: Tue, 14 Oct 2025 16:18:10 +1030 Subject: [PATCH 2/2] Added protocol to readme --- README.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.rst b/README.rst index 4467717..f2a61de 100644 --- a/README.rst +++ b/README.rst @@ -217,6 +217,7 @@ Command list * `phpunit`_ * `plugin`_ * `precheck`_ +* `protocol`_ * `purge`_ * `pull`_ * `push`_ @@ -413,6 +414,18 @@ Pre-checks a patch on the CI server. mdk precheck +protocol +-------- + +Allows user to toggle between http and https for an instance. + +**Example** + +:: + + mdk protocol https + + purge -----