mt4ctl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mt4ctl/__init__.py +12 -0
- mt4ctl/__main__.py +8 -0
- mt4ctl/auth.py +57 -0
- mt4ctl/config.py +203 -0
- mt4ctl/errors.py +47 -0
- mt4ctl/login.py +171 -0
- mt4ctl/models.py +139 -0
- mt4ctl/operations.py +171 -0
- mt4ctl/py.typed +0 -0
- mt4ctl/scripts.py +168 -0
- mt4ctl/server.py +231 -0
- mt4ctl/ssh.py +120 -0
- mt4ctl-0.1.0.dist-info/METADATA +266 -0
- mt4ctl-0.1.0.dist-info/RECORD +17 -0
- mt4ctl-0.1.0.dist-info/WHEEL +4 -0
- mt4ctl-0.1.0.dist-info/entry_points.txt +2 -0
- mt4ctl-0.1.0.dist-info/licenses/LICENSE +21 -0
mt4ctl/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""mt4ctl — an MCP server for managing headless MetaTrader terminals.
|
|
2
|
+
|
|
3
|
+
Manage MetaTrader 4 terminals that run under Wine + systemd on remote hosts
|
|
4
|
+
(native Linux or WSL2) entirely over SSH: status, logs, screenshots, lifecycle
|
|
5
|
+
control, and headless first-login — exposed as Model Context Protocol tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
mt4ctl/__main__.py
ADDED
mt4ctl/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Credential resolution for terminal logins.
|
|
2
|
+
|
|
3
|
+
Resolution order (first hit wins), mirroring common CLI conventions:
|
|
4
|
+
|
|
5
|
+
1. an explicit ``password`` passed by the caller
|
|
6
|
+
2. ``MT4CTL_PASSWORD_<account>`` environment variable
|
|
7
|
+
3. the ``<account>`` key in a JSON secrets file
|
|
8
|
+
(``$MT4CTL_CREDENTIALS`` or ``$XDG_CONFIG_HOME/mt4ctl/credentials.json``)
|
|
9
|
+
|
|
10
|
+
Passwords are never logged, echoed, or written anywhere except the transient
|
|
11
|
+
remote login config, which the login flow shreds after use.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .errors import CredentialError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def secrets_file() -> Path:
|
|
24
|
+
"""Return the path of the JSON secrets file (may not exist)."""
|
|
25
|
+
if env := os.environ.get("MT4CTL_CREDENTIALS"):
|
|
26
|
+
return Path(env).expanduser()
|
|
27
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
28
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
29
|
+
return base / "mt4ctl" / "credentials.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_password(account: str, explicit: str | None = None) -> str:
|
|
33
|
+
"""Return the password for *account*, raising :class:`CredentialError` if absent."""
|
|
34
|
+
if explicit:
|
|
35
|
+
return explicit
|
|
36
|
+
|
|
37
|
+
if env := os.environ.get(f"MT4CTL_PASSWORD_{account}"):
|
|
38
|
+
return env
|
|
39
|
+
|
|
40
|
+
path = secrets_file()
|
|
41
|
+
if path.is_file():
|
|
42
|
+
if os.name == "posix" and (path.stat().st_mode & 0o077):
|
|
43
|
+
raise CredentialError(
|
|
44
|
+
f"secrets file {path} is readable by group/other; run: chmod 600 {path}"
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
49
|
+
raise CredentialError(f"could not read secrets file {path}: {exc}") from None
|
|
50
|
+
if isinstance(data, dict) and account in data:
|
|
51
|
+
return str(data[account])
|
|
52
|
+
|
|
53
|
+
raise CredentialError(
|
|
54
|
+
f"no password for account {account!r}. Provide it via the password "
|
|
55
|
+
f"argument, the MT4CTL_PASSWORD_{account} env var, or the "
|
|
56
|
+
f"{account!r} key in {path}."
|
|
57
|
+
)
|
mt4ctl/config.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Load and validate the terminal registry from YAML.
|
|
2
|
+
|
|
3
|
+
The registry path is resolved as:
|
|
4
|
+
|
|
5
|
+
1. an explicit ``path`` argument — must exist
|
|
6
|
+
2. ``MT4CTL_CONFIG`` — if set, must exist (it does *not* fall back to the search
|
|
7
|
+
paths, so a typo fails loudly instead of using a stale file)
|
|
8
|
+
3. otherwise the first existing of ``$XDG_CONFIG_HOME/mt4ctl/terminals.yaml``
|
|
9
|
+
(or ``~/.config/...``) then ``./terminals.yaml``
|
|
10
|
+
|
|
11
|
+
This keeps real infrastructure details out of the source tree: ship the
|
|
12
|
+
``examples/terminals.example.yaml`` template, keep the populated file private.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from .errors import ConfigError
|
|
25
|
+
from .models import Env, Host, HostKind, Registry, Terminal
|
|
26
|
+
|
|
27
|
+
# Ids become systemd targets, dict keys, and (sanitized) filenames, so keep them
|
|
28
|
+
# to a conservative, shell- and protocol-safe character set.
|
|
29
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
30
|
+
_DISTRO_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def candidate_paths() -> list[Path]:
|
|
34
|
+
"""Default registry search paths (XDG config dir, then CWD); env excluded."""
|
|
35
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
36
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
37
|
+
return [base / "mt4ctl" / "terminals.yaml", Path.cwd() / "terminals.yaml"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_path(path: str | os.PathLike[str] | None = None) -> Path:
|
|
41
|
+
"""Find the registry file, raising :class:`ConfigError` if none exists.
|
|
42
|
+
|
|
43
|
+
An explicit ``path`` or a set ``MT4CTL_CONFIG`` must point to an existing
|
|
44
|
+
file — neither falls back to the search paths, so a typo'd path fails loudly
|
|
45
|
+
instead of silently using a stale registry.
|
|
46
|
+
"""
|
|
47
|
+
if path is not None:
|
|
48
|
+
resolved = Path(path).expanduser()
|
|
49
|
+
if not resolved.is_file():
|
|
50
|
+
raise ConfigError(f"registry file not found: {resolved}")
|
|
51
|
+
return resolved
|
|
52
|
+
|
|
53
|
+
env = os.environ.get("MT4CTL_CONFIG")
|
|
54
|
+
if env:
|
|
55
|
+
resolved = Path(env).expanduser()
|
|
56
|
+
if not resolved.is_file():
|
|
57
|
+
raise ConfigError(
|
|
58
|
+
f"MT4CTL_CONFIG is set to {resolved}, but that file does not exist. "
|
|
59
|
+
"Create it, fix the path, or unset MT4CTL_CONFIG to use the default "
|
|
60
|
+
"search paths."
|
|
61
|
+
)
|
|
62
|
+
return resolved
|
|
63
|
+
|
|
64
|
+
for candidate in candidate_paths():
|
|
65
|
+
if candidate.is_file():
|
|
66
|
+
return candidate
|
|
67
|
+
|
|
68
|
+
searched = "\n ".join(str(p) for p in candidate_paths())
|
|
69
|
+
raise ConfigError(
|
|
70
|
+
"mt4ctl could not find a terminal registry.\n\n"
|
|
71
|
+
"Create one:\n"
|
|
72
|
+
" mkdir -p ~/.config/mt4ctl\n"
|
|
73
|
+
" cp examples/terminals.example.yaml ~/.config/mt4ctl/terminals.yaml\n\n"
|
|
74
|
+
"Or set MT4CTL_CONFIG=/absolute/path/to/terminals.yaml.\nSearched:\n " + searched
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _require(mapping: dict[str, Any], key: str, where: str) -> Any:
|
|
79
|
+
if key not in mapping:
|
|
80
|
+
raise ConfigError(f"{where}: missing required field {key!r}")
|
|
81
|
+
return mapping[key]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _require_mapping(value: Any, where: str) -> dict[str, Any]:
|
|
85
|
+
if not isinstance(value, dict):
|
|
86
|
+
raise ConfigError(f"{where}: expected a mapping, got {type(value).__name__}")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_id(kind: str, value: str) -> None:
|
|
91
|
+
if not _ID_RE.match(value):
|
|
92
|
+
raise ConfigError(
|
|
93
|
+
f"{kind} id {value!r} is invalid; use letters, digits, '.', '_', '-' only"
|
|
94
|
+
)
|
|
95
|
+
if kind == "terminal" and value == "all":
|
|
96
|
+
raise ConfigError(
|
|
97
|
+
"terminal id 'all' is reserved (mt4_status uses it for every terminal)"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _clean_field(value: str, field: str, where: str) -> str:
|
|
102
|
+
# These values are embedded in the '|'-delimited status protocol and in
|
|
103
|
+
# shell scripts; forbid separators and control characters outright.
|
|
104
|
+
if any(c in value for c in ("|", "\n", "\r")):
|
|
105
|
+
raise ConfigError(f"{where}: {field} must not contain '|' or newlines")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_host(host_id: str, raw: dict[str, Any]) -> Host:
|
|
110
|
+
where = f"host {host_id!r}"
|
|
111
|
+
try:
|
|
112
|
+
kind = HostKind(raw.get("kind", "native"))
|
|
113
|
+
except ValueError:
|
|
114
|
+
raise ConfigError(
|
|
115
|
+
f"{where}: invalid kind {raw.get('kind')!r} (use native or wsl)"
|
|
116
|
+
) from None
|
|
117
|
+
ssh = str(_require(raw, "ssh", where))
|
|
118
|
+
if ssh.startswith("-"):
|
|
119
|
+
raise ConfigError(f"{where}: ssh destination must not start with '-'")
|
|
120
|
+
distro = raw.get("wsl_distro")
|
|
121
|
+
if distro is not None and not _DISTRO_RE.match(str(distro)):
|
|
122
|
+
raise ConfigError(f"{where}: wsl_distro {distro!r} has invalid characters")
|
|
123
|
+
broker = raw.get("broker_host")
|
|
124
|
+
try:
|
|
125
|
+
return Host(
|
|
126
|
+
id=host_id,
|
|
127
|
+
ssh=ssh,
|
|
128
|
+
kind=kind,
|
|
129
|
+
wsl_distro=str(distro) if distro is not None else None,
|
|
130
|
+
broker_host=_clean_field(str(broker), "broker_host", where)
|
|
131
|
+
if broker is not None
|
|
132
|
+
else None,
|
|
133
|
+
)
|
|
134
|
+
except ValueError as exc: # invariant from Host.__post_init__
|
|
135
|
+
raise ConfigError(str(exc)) from None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _build_terminal(term_id: str, raw: dict[str, Any], hosts: dict[str, Host]) -> Terminal:
|
|
139
|
+
where = f"terminal {term_id!r}"
|
|
140
|
+
host_id = str(_require(raw, "host", where))
|
|
141
|
+
if host_id not in hosts:
|
|
142
|
+
raise ConfigError(f"{where}: references unknown host {host_id!r}")
|
|
143
|
+
try:
|
|
144
|
+
env = Env(raw.get("env", "demo"))
|
|
145
|
+
except ValueError:
|
|
146
|
+
raise ConfigError(
|
|
147
|
+
f"{where}: invalid env {raw.get('env')!r} (use demo or live)"
|
|
148
|
+
) from None
|
|
149
|
+
account = raw.get("account")
|
|
150
|
+
window_match = raw.get("window_match")
|
|
151
|
+
return Terminal(
|
|
152
|
+
id=term_id,
|
|
153
|
+
host=host_id,
|
|
154
|
+
service=_clean_field(str(_require(raw, "service", where)), "service", where),
|
|
155
|
+
data_dir=_clean_field(str(_require(raw, "data_dir", where)), "data_dir", where),
|
|
156
|
+
display=_clean_field(str(raw.get("display", ":0")), "display", where),
|
|
157
|
+
account=_clean_field(str(account), "account", where) if account is not None else None,
|
|
158
|
+
env=env,
|
|
159
|
+
window_match=(
|
|
160
|
+
_clean_field(str(window_match), "window_match", where)
|
|
161
|
+
if window_match is not None
|
|
162
|
+
else None
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_registry(data: dict[str, Any]) -> Registry:
|
|
168
|
+
"""Build a :class:`Registry` from already-parsed YAML data."""
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
raise ConfigError("registry root must be a mapping")
|
|
171
|
+
raw_hosts = data.get("hosts") or {}
|
|
172
|
+
raw_terminals = data.get("terminals") or {}
|
|
173
|
+
_require_mapping(raw_hosts, "hosts")
|
|
174
|
+
_require_mapping(raw_terminals, "terminals")
|
|
175
|
+
if not raw_hosts:
|
|
176
|
+
raise ConfigError("registry defines no hosts")
|
|
177
|
+
if not raw_terminals:
|
|
178
|
+
raise ConfigError("registry defines no terminals")
|
|
179
|
+
|
|
180
|
+
hosts: dict[str, Host] = {}
|
|
181
|
+
for hid, raw in raw_hosts.items():
|
|
182
|
+
_validate_id("host", str(hid))
|
|
183
|
+
hosts[str(hid)] = _build_host(str(hid), _require_mapping(raw, f"host {hid!r}"))
|
|
184
|
+
|
|
185
|
+
terminals: dict[str, Terminal] = {}
|
|
186
|
+
for tid, raw in raw_terminals.items():
|
|
187
|
+
_validate_id("terminal", str(tid))
|
|
188
|
+
terminals[str(tid)] = _build_terminal(
|
|
189
|
+
str(tid), _require_mapping(raw, f"terminal {tid!r}"), hosts
|
|
190
|
+
)
|
|
191
|
+
return Registry(hosts=hosts, terminals=terminals)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_registry(path: str | os.PathLike[str] | None = None) -> Registry:
|
|
195
|
+
"""Resolve, read, and validate the registry file."""
|
|
196
|
+
resolved = resolve_path(path)
|
|
197
|
+
try:
|
|
198
|
+
data = yaml.safe_load(resolved.read_text(encoding="utf-8"))
|
|
199
|
+
except yaml.YAMLError as exc:
|
|
200
|
+
raise ConfigError(f"{resolved}: invalid YAML: {exc}") from None
|
|
201
|
+
if data is None:
|
|
202
|
+
raise ConfigError(f"{resolved}: file is empty")
|
|
203
|
+
return parse_registry(data)
|
mt4ctl/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed errors with actionable messages.
|
|
2
|
+
|
|
3
|
+
Errors raised here are caught at the MCP boundary and surfaced to the calling
|
|
4
|
+
agent verbatim, so each message should tell the agent *how to recover* rather
|
|
5
|
+
than just *what went wrong*.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Mt4ctlError(Exception):
|
|
12
|
+
"""Base class for all expected, user-facing errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(Mt4ctlError):
|
|
16
|
+
"""The registry file is missing, malformed, or internally inconsistent."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnknownTargetError(Mt4ctlError):
|
|
20
|
+
"""A referenced terminal or host does not exist in the registry."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemoteCommandError(Mt4ctlError):
|
|
24
|
+
"""A command executed over SSH returned a non-zero exit status."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, host: str, returncode: int, stderr: str) -> None:
|
|
27
|
+
self.host = host
|
|
28
|
+
self.returncode = returncode
|
|
29
|
+
self.stderr = stderr.strip()
|
|
30
|
+
detail = self.stderr or "(no stderr)"
|
|
31
|
+
super().__init__(f"remote command on {host!r} failed (exit {returncode}): {detail}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfirmationRequiredError(Mt4ctlError):
|
|
35
|
+
"""A mutating operation targeted a live terminal without ``confirm=true``."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, terminal_id: str, action: str) -> None:
|
|
38
|
+
self.terminal_id = terminal_id
|
|
39
|
+
self.action = action
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"refusing to {action} live terminal {terminal_id!r} without "
|
|
42
|
+
f"confirmation; pass confirm=true to proceed"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CredentialError(Mt4ctlError):
|
|
47
|
+
"""A credential could not be resolved for a login operation."""
|
mt4ctl/login.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Headless first-login for a terminal.
|
|
2
|
+
|
|
3
|
+
MetaTrader stores its password encrypted with a machine-bound key, so a terminal
|
|
4
|
+
copied to a new host cannot auto-login until it authenticates once on that host.
|
|
5
|
+
This module automates that bootstrap:
|
|
6
|
+
|
|
7
|
+
1. stop the systemd unit (frees the terminal slot)
|
|
8
|
+
2. write a transient startup config (login/password/server), created fresh by
|
|
9
|
+
``mktemp`` with mode 600, inside the unit's working directory
|
|
10
|
+
3. launch the terminal once, in its own process group, reusing the unit's
|
|
11
|
+
``WorkingDirectory`` / ``WINEPREFIX`` / ``DISPLAY``
|
|
12
|
+
4. wait for ``config/accounts.ini`` to become newer than a marker stamped right
|
|
13
|
+
before launch — MetaTrader's signal that authentication succeeded and
|
|
14
|
+
credentials were re-encrypted for this host
|
|
15
|
+
5. an ``EXIT``/signal trap *always* kills that process group (siblings share the
|
|
16
|
+
Wine prefix, so a blanket ``wineserver -k`` is wrong) and shreds the config,
|
|
17
|
+
even if an earlier step fails
|
|
18
|
+
6. restart the unit, which now auto-logins from the saved file
|
|
19
|
+
|
|
20
|
+
After this runs once, the terminal reconnects on its own across restarts.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from . import auth, scripts, ssh
|
|
26
|
+
from .errors import ConfirmationRequiredError, Mt4ctlError, RemoteCommandError
|
|
27
|
+
from .models import Env, Registry
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_login_script(
|
|
31
|
+
service: str,
|
|
32
|
+
data_dir: str,
|
|
33
|
+
account: str,
|
|
34
|
+
server: str,
|
|
35
|
+
password: str,
|
|
36
|
+
*,
|
|
37
|
+
wait_seconds: int = 120,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Build the self-contained user-side bootstrap script (see module docstring)."""
|
|
40
|
+
# Credentials are written into a quoted heredoc below (no shell expansion),
|
|
41
|
+
# but a newline could still smuggle in a heredoc terminator or corrupt the
|
|
42
|
+
# ini. MT4 login/server/password are single-line tokens, so reject newlines.
|
|
43
|
+
for field, value in (("account", account), ("server", server), ("password", password)):
|
|
44
|
+
if "\n" in value or "\r" in value:
|
|
45
|
+
raise Mt4ctlError(f"{field} must not contain newlines")
|
|
46
|
+
svc = scripts.sh_quote(service)
|
|
47
|
+
ddir = scripts.sh_quote(data_dir)
|
|
48
|
+
return f"""\
|
|
49
|
+
set +e
|
|
50
|
+
SVC={svc}
|
|
51
|
+
DDIR={ddir}
|
|
52
|
+
WORKDIR=$(systemctl show -p WorkingDirectory --value "$SVC" 2>/dev/null)
|
|
53
|
+
ENV=$(systemctl show -p Environment --value "$SVC" 2>/dev/null)
|
|
54
|
+
WP=$(echo "$ENV" | tr ' ' '\\n' | sed -n 's/^WINEPREFIX=//p' | head -1)
|
|
55
|
+
DISP=$(echo "$ENV" | tr ' ' '\\n' | sed -n 's/^DISPLAY=//p' | head -1)
|
|
56
|
+
[ -z "$WORKDIR" ] && WORKDIR="$DDIR"
|
|
57
|
+
[ -z "$DISP" ] && DISP=:0
|
|
58
|
+
|
|
59
|
+
INI=""
|
|
60
|
+
MARKER=""
|
|
61
|
+
PGID=""
|
|
62
|
+
cleanup() {{
|
|
63
|
+
[ -n "$PGID" ] && kill -TERM -"$PGID" 2>/dev/null
|
|
64
|
+
sleep 1
|
|
65
|
+
[ -n "$PGID" ] && kill -KILL -"$PGID" 2>/dev/null
|
|
66
|
+
[ -n "$INI" ] && {{ shred -u "$INI" 2>/dev/null || rm -f "$INI"; }}
|
|
67
|
+
[ -n "$MARKER" ] && rm -f "$MARKER" 2>/dev/null
|
|
68
|
+
}}
|
|
69
|
+
trap cleanup EXIT HUP INT TERM
|
|
70
|
+
|
|
71
|
+
INI=$(mktemp "$WORKDIR/mt4ctl-login.XXXXXX.ini" 2>/dev/null) || {{ echo "LOGIN|error=mktemp"; exit 1; }}
|
|
72
|
+
chmod 600 "$INI"
|
|
73
|
+
INIBASE=$(basename "$INI")
|
|
74
|
+
ACCFILE="$DDIR/config/accounts.ini"
|
|
75
|
+
MARKER=$(mktemp 2>/dev/null) || MARKER="/tmp/mt4ctl-marker.$$"
|
|
76
|
+
|
|
77
|
+
cat > "$INI" <<'INICFG'
|
|
78
|
+
[Common]
|
|
79
|
+
Login={account}
|
|
80
|
+
Password={password}
|
|
81
|
+
Server={server}
|
|
82
|
+
KeepPrivate=1
|
|
83
|
+
NewsEnable=false
|
|
84
|
+
CertInstall=false
|
|
85
|
+
[Experts]
|
|
86
|
+
AllowLiveTrading=false
|
|
87
|
+
Enabled=false
|
|
88
|
+
INICFG
|
|
89
|
+
|
|
90
|
+
cd "$WORKDIR" || {{ echo "LOGIN|error=workdir"; exit 1; }}
|
|
91
|
+
: > "$MARKER"
|
|
92
|
+
sleep 1
|
|
93
|
+
WINEPREFIX="$WP" DISPLAY="$DISP" setsid wine terminal.exe /portable "$INIBASE" >/dev/null 2>&1 &
|
|
94
|
+
PGID=$!
|
|
95
|
+
|
|
96
|
+
ok=0
|
|
97
|
+
for _ in $(seq 1 {wait_seconds}); do
|
|
98
|
+
if [ "$ACCFILE" -nt "$MARKER" ]; then ok=1; break; fi
|
|
99
|
+
sleep 1
|
|
100
|
+
done
|
|
101
|
+
echo "LOGIN|ok=$ok"
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def login(
|
|
106
|
+
registry: Registry,
|
|
107
|
+
terminal_id: str,
|
|
108
|
+
*,
|
|
109
|
+
account: str | None = None,
|
|
110
|
+
server: str,
|
|
111
|
+
password: str | None = None,
|
|
112
|
+
confirm: bool = False,
|
|
113
|
+
wait_seconds: int = 120,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Perform a one-time headless login, then restart the unit for auto-reconnect.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
terminal_id: terminal to log in.
|
|
119
|
+
account: login number; defaults to the terminal's configured account.
|
|
120
|
+
server: broker server name, e.g. ``ExampleBroker-Demo``.
|
|
121
|
+
password: explicit password; otherwise resolved via :mod:`mt4ctl.auth`.
|
|
122
|
+
confirm: required (``True``) for live terminals.
|
|
123
|
+
wait_seconds: how long to wait for authentication to land.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A short human-readable summary of the outcome.
|
|
127
|
+
"""
|
|
128
|
+
term = registry.terminal(terminal_id)
|
|
129
|
+
host = registry.host_of(term)
|
|
130
|
+
if term.env is Env.LIVE and not confirm:
|
|
131
|
+
raise ConfirmationRequiredError(terminal_id, "login")
|
|
132
|
+
|
|
133
|
+
login_account = account or term.account
|
|
134
|
+
if not login_account:
|
|
135
|
+
raise Mt4ctlError(f"terminal {terminal_id!r} has no account configured; pass account=")
|
|
136
|
+
secret = auth.resolve_password(login_account, password)
|
|
137
|
+
|
|
138
|
+
# Stop the unit so the one-shot owns the terminal slot; abort if it fails.
|
|
139
|
+
await ssh.run(
|
|
140
|
+
host, scripts.build_control_script(term.service, "stop"), root=True, check=True
|
|
141
|
+
)
|
|
142
|
+
script = build_login_script(
|
|
143
|
+
term.service, term.data_dir, login_account, server, secret, wait_seconds=wait_seconds
|
|
144
|
+
)
|
|
145
|
+
result = await ssh.run(host, script, timeout=wait_seconds + 30, check=False)
|
|
146
|
+
|
|
147
|
+
# Bring the unit back; it auto-logins from the now-saved accounts.ini.
|
|
148
|
+
try:
|
|
149
|
+
await ssh.run(
|
|
150
|
+
host, scripts.build_control_script(term.service, "start"), root=True, check=True
|
|
151
|
+
)
|
|
152
|
+
restarted = True
|
|
153
|
+
except RemoteCommandError:
|
|
154
|
+
restarted = False
|
|
155
|
+
|
|
156
|
+
if "LOGIN|ok=1" in result.stdout:
|
|
157
|
+
msg = (
|
|
158
|
+
f"{terminal_id}: logged in to account {login_account} on {server}; "
|
|
159
|
+
f"credentials saved."
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
msg = (
|
|
163
|
+
f"{terminal_id}: login did NOT confirm within {wait_seconds}s "
|
|
164
|
+
f"(account {login_account} on {server}); check `mt4_logs` and verify the "
|
|
165
|
+
f"server name and password."
|
|
166
|
+
)
|
|
167
|
+
# The restart outcome is reported on every path so a stopped unit is never
|
|
168
|
+
# hidden behind a login message.
|
|
169
|
+
if restarted:
|
|
170
|
+
return msg + " Unit restarted for auto-reconnect."
|
|
171
|
+
return msg + " WARNING: restarting the unit failed — run `mt4_control` start."
|
mt4ctl/models.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Domain models for the terminal registry.
|
|
2
|
+
|
|
3
|
+
The registry is intentionally plain data: a set of *hosts* (machines reachable
|
|
4
|
+
over SSH) and a set of *terminals* (MetaTrader instances managed by ``systemd``
|
|
5
|
+
on those hosts). Everything else in :mod:`mt4ctl` operates on these models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
from .errors import UnknownTargetError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HostKind(StrEnum):
|
|
17
|
+
"""How commands are dispatched on a host.
|
|
18
|
+
|
|
19
|
+
``NATIVE``
|
|
20
|
+
A plain Linux box. Commands run directly; root is obtained via ``sudo``.
|
|
21
|
+
``WSL``
|
|
22
|
+
A Windows host running WSL2. Commands are wrapped in
|
|
23
|
+
``wsl -d <distro> -- ...`` and root is obtained via ``wsl -u root``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
NATIVE = "native"
|
|
27
|
+
WSL = "wsl"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Env(StrEnum):
|
|
31
|
+
"""Risk tier of a terminal.
|
|
32
|
+
|
|
33
|
+
``LIVE`` terminals trade real money, so mutating operations on them require
|
|
34
|
+
an explicit ``confirm=true`` from the caller.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
DEMO = "demo"
|
|
38
|
+
LIVE = "live"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Host:
|
|
43
|
+
"""A machine reachable over SSH that runs one or more terminals."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
ssh: str
|
|
47
|
+
"""SSH destination — an alias from ``~/.ssh/config`` or ``user@host``."""
|
|
48
|
+
kind: HostKind = HostKind.NATIVE
|
|
49
|
+
wsl_distro: str | None = None
|
|
50
|
+
"""Required when :attr:`kind` is :data:`HostKind.WSL`."""
|
|
51
|
+
broker_host: str | None = None
|
|
52
|
+
"""Optional broker hostname used to detect live connections via ``ss``."""
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if self.kind is HostKind.WSL and not self.wsl_distro:
|
|
56
|
+
raise ValueError(f"host {self.id!r} is kind=wsl but has no wsl_distro")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class Terminal:
|
|
61
|
+
"""A single MetaTrader terminal managed by ``systemd``."""
|
|
62
|
+
|
|
63
|
+
id: str
|
|
64
|
+
host: str
|
|
65
|
+
"""Id of the owning :class:`Host`."""
|
|
66
|
+
service: str
|
|
67
|
+
"""The ``systemd`` unit name, e.g. ``mt4-demo3``."""
|
|
68
|
+
data_dir: str
|
|
69
|
+
"""Absolute path to the terminal data folder (parent of ``logs/``)."""
|
|
70
|
+
display: str = ":0"
|
|
71
|
+
"""X11 display used for screenshots, e.g. ``:99``."""
|
|
72
|
+
account: str | None = None
|
|
73
|
+
"""Login number — labels output and locates the window for screenshots."""
|
|
74
|
+
env: Env = Env.DEMO
|
|
75
|
+
window_match: str | None = None
|
|
76
|
+
"""Override window-search string; defaults to :attr:`account`."""
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def window_query(self) -> str | None:
|
|
80
|
+
"""The string used to find this terminal's window with ``xdotool``."""
|
|
81
|
+
return self.window_match or self.account
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class TerminalStatus:
|
|
86
|
+
"""Resolved runtime status of a terminal."""
|
|
87
|
+
|
|
88
|
+
id: str
|
|
89
|
+
host: str
|
|
90
|
+
env: Env
|
|
91
|
+
account: str | None
|
|
92
|
+
service_state: str
|
|
93
|
+
"""Raw ``systemctl is-active`` value: ``active`` / ``inactive`` / ``failed``…"""
|
|
94
|
+
connected: bool | None
|
|
95
|
+
"""True/False if a broker connection could be determined, else ``None``."""
|
|
96
|
+
log_age_seconds: int | None
|
|
97
|
+
"""Seconds since the newest log file was written, or ``None`` if no logs."""
|
|
98
|
+
last_event: str | None = None
|
|
99
|
+
"""Most recent connection-related log line, if any."""
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def healthy(self) -> bool:
|
|
103
|
+
"""A terminal is healthy when its service is active and it is connected."""
|
|
104
|
+
return self.service_state == "active" and self.connected is True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class Registry:
|
|
109
|
+
"""The full set of configured hosts and terminals."""
|
|
110
|
+
|
|
111
|
+
hosts: dict[str, Host] = field(default_factory=dict)
|
|
112
|
+
terminals: dict[str, Terminal] = field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
def terminal(self, terminal_id: str) -> Terminal:
|
|
115
|
+
try:
|
|
116
|
+
return self.terminals[terminal_id]
|
|
117
|
+
except KeyError:
|
|
118
|
+
raise UnknownTargetError(
|
|
119
|
+
f"unknown terminal {terminal_id!r}; known: "
|
|
120
|
+
f"{', '.join(sorted(self.terminals)) or '(none)'}"
|
|
121
|
+
) from None
|
|
122
|
+
|
|
123
|
+
def host(self, host_id: str) -> Host:
|
|
124
|
+
try:
|
|
125
|
+
return self.hosts[host_id]
|
|
126
|
+
except KeyError:
|
|
127
|
+
raise UnknownTargetError(
|
|
128
|
+
f"unknown host {host_id!r}; known: {', '.join(sorted(self.hosts)) or '(none)'}"
|
|
129
|
+
) from None
|
|
130
|
+
|
|
131
|
+
def host_of(self, terminal: Terminal) -> Host:
|
|
132
|
+
return self.host(terminal.host)
|
|
133
|
+
|
|
134
|
+
def by_host(self) -> dict[str, list[Terminal]]:
|
|
135
|
+
"""Group terminals by their host id (stable order)."""
|
|
136
|
+
grouped: dict[str, list[Terminal]] = {}
|
|
137
|
+
for term in self.terminals.values():
|
|
138
|
+
grouped.setdefault(term.host, []).append(term)
|
|
139
|
+
return grouped
|