Repository navigation
Expand file tree
/
Copy pathcli.py
More file actions
executable file
·314 lines (268 loc) · 11.7 KB
/
Copy pathcli.py
File metadata and controls
executable file
·314 lines (268 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
#!/usr/bin/env python3
"""TC Lab command-line administration.
Emergency access for an administrator with shell access — used when the
dashboard admin password has been lost. It can only *reset* the admin
password; there is deliberately no way to read or display an existing
password, because passwords are stored only as one-way bcrypt hashes.
sudo tc-lab reset-admin-password
sudo tc-lab list-users
sudo tc-lab set-port 5000
tc-lab --help
tc-lab --version
Run from the install directory, or set TC_LAB_STATE_DIR to point at the
directory holding users.json.
"""
import argparse
import getpass
import json
import os
import shutil
import sys
from pathlib import Path
# Import the app's own auth module so the password policy and hashing are
# identical to the dashboard's — they can never drift apart.
sys.path.insert(0, str(Path(__file__).parent.resolve()))
try:
import auth
import ports
except ImportError as e: # pragma: no cover
sys.exit(f"error: cannot import the application (auth.py): {e}\n"
"Run this from the TC Lab install directory, e.g.\n"
" cd /opt/tc_lab && sudo TC_LAB_STATE_DIR=/var/lib/tc_lab "
"venv/bin/python cli.py reset-admin-password")
ADMIN_USER = "admin"
EXIT_OK, EXIT_ERR, EXIT_PERM = 0, 1, 2
def _err(msg):
print(f"error: {msg}", file=sys.stderr)
def require_root():
"""The user store is private to the service (0600); refuse early with a
clear message rather than failing later on a confusing permission error."""
if os.geteuid() != 0:
_err("this command must be run as root (try: sudo tc-lab ...)")
sys.exit(EXIT_PERM)
def _getpass(prompt):
"""getpass, but a closed stdin or Ctrl-C exits cleanly instead of dumping a
traceback — this tool is used when things are already going wrong."""
try:
return getpass.getpass(prompt)
except EOFError:
print()
_err("no input received — password unchanged")
sys.exit(EXIT_ERR)
except KeyboardInterrupt:
print()
_err("cancelled — password unchanged")
sys.exit(EXIT_ERR)
def _state_owner():
"""(uid, gid) of the state directory — the account the service runs as."""
st = os.stat(auth._SD)
return st.st_uid, st.st_gid
def _give_to_service(*paths):
"""This command runs as root, but the service runs as its own user (v9.3+).
A file root creates here — users.json when it had been deleted, or the
backup — would be root-owned and unreadable to the service, and nobody
could sign in. Hand every file written back to the state directory's
owner. A no-op on an install where root owns the state (before v9.3)."""
uid, gid = _state_owner()
if uid == 0:
return
for p in paths:
if os.path.exists(p):
os.chown(p, uid, gid)
def _prompt_new_password():
"""Read a password twice, without echoing it. Never logged or printed."""
sys.stdout.flush() # keep prompts after the context lines when piped
for _ in range(3):
pw = _getpass("New admin password: ")
problem = auth.validate_password(pw)
if problem:
_err(problem)
continue
if pw != _getpass("Retype new admin password: "):
_err("passwords do not match")
continue
return pw
_err("too many failed attempts — password unchanged")
sys.exit(EXIT_ERR)
def cmd_reset_admin_password(args):
require_root()
users_file = auth.USERS_FILE
print(f"User store: {users_file}")
users = auth._load_users()
if users_file.exists() and not users:
_err(f"{users_file} exists but could not be parsed — refusing to "
"overwrite it. Fix or move the file, then retry.")
return EXIT_ERR
existing = ADMIN_USER in users
if existing:
print(f"Resetting the password for '{ADMIN_USER}'. "
f"{len(users) - 1} other account(s) will be left untouched.")
else:
print(f"No '{ADMIN_USER}' account found — it will be recreated "
f"with the admin role. {len(users)} other account(s) preserved.")
password = args.password or _prompt_new_password()
problem = auth.validate_password(password)
if problem:
_err(problem)
return EXIT_ERR
# Back up the store before touching it, so a bad write is recoverable.
if users_file.exists():
backup = users_file.with_suffix(".json.bak")
try:
shutil.copy2(users_file, backup)
os.chmod(backup, 0o600)
print(f"Backup written: {backup}")
except OSError as e:
_err(f"could not write backup: {e}")
return EXIT_ERR
# Update only this account's hash; every other user and their role is
# carried across untouched, and no other application config is read or
# written by this command.
users.setdefault(ADMIN_USER, {})
users[ADMIN_USER]["hash"] = auth.hash_password(password)
users[ADMIN_USER]["role"] = "admin" # recovery must restore admin rights
try:
auth._save_users(users)
_give_to_service(users_file, users_file.with_suffix(".json.bak"))
except OSError as e:
_err(f"could not write {users_file}: {e}")
return EXIT_ERR
# Read back and verify before claiming success.
written = auth._load_users()
if ADMIN_USER not in written or not auth.check_password(
password, written[ADMIN_USER]["hash"]):
_err("verification failed — the password was NOT changed")
return EXIT_ERR
print()
print(f"SUCCESS: password for '{ADMIN_USER}' has been reset.")
print(f" role: admin accounts in store: {len(written)}")
print(" Sign in at the dashboard with the new password.")
return EXIT_OK
def cmd_list_users(args):
require_root()
users = auth.list_users() # never includes hashes
if not users:
print("No accounts found. Start the service once to create the "
"default admin account.")
return EXIT_OK
if args.json:
print(json.dumps(users, indent=2))
return EXIT_OK
width = max(len(u["username"]) for u in users)
print(f"{'USERNAME':<{max(width, 8)}} ROLE")
for u in users:
print(f"{u['username']:<{max(width, 8)}} {u['role']}")
print(f"\n{len(users)} account(s) in {auth.USERS_FILE}")
return EXIT_OK
def cmd_set_port(args):
"""Recovery for a dashboard that cannot be reached on its current port —
for example after moving it to a port a firewall blocks. Writes config.json
only; the service picks it up when restarted."""
require_root()
try:
port = ports.parse_port(args.port)
except ValueError as e:
_err(str(e))
return EXIT_ERR
cfg_file = auth._SD / "config.json"
try:
cfg = json.loads(cfg_file.read_text()) if cfg_file.exists() else {}
if not isinstance(cfg, dict):
raise ValueError("not a JSON object")
except (OSError, ValueError) as e:
_err(f"cannot read {cfg_file} ({e}) — fix or remove it, then retry")
return EXIT_ERR
old = cfg.get("port", ports.DEFAULT_PORT)
cfg["port"] = port
try:
cfg_file.write_text(json.dumps(cfg, indent=2))
_give_to_service(cfg_file)
except OSError as e:
_err(f"could not write {cfg_file}: {e}")
return EXIT_ERR
print(f"Port set to {port} (was {old}) in {cfg_file}")
if port != old and not ports.port_available(cfg.get("bind_address", "0.0.0.0"), port):
print(f"warning: something is already listening on port {port}; "
"TC Lab will not be able to start there until it is free")
print("Restart the service to use it: sudo systemctl restart tc_lab")
return EXIT_OK
def _version():
"""The version string lives in app.py's first line ("app.py v9.2"); read it
rather than keep a second copy that could drift."""
try:
first = (Path(__file__).parent / "app.py").read_text().splitlines()[0]
return first.strip('"').split()[-1]
except (OSError, IndexError):
return "unknown"
HELP = """\
TC Lab command-line administration.
For an administrator with a shell on the host, mainly for the case where the
dashboard admin password has been lost. Passwords are stored only as one-way
bcrypt hashes, so this tool can *reset* the admin password but can never read
or display any password.
"""
EPILOG = """\
examples:
sudo tc-lab reset-admin-password set a new admin password (prompted, not echoed)
sudo tc-lab list-users show accounts and their roles
sudo tc-lab list-users --json the same, machine-readable
sudo tc-lab set-port 5000 move the dashboard back to port 5000
(then: sudo systemctl restart tc_lab)
tc-lab --version show the installed version
related:
sudo systemctl status tc_lab is the service running?
journalctl -u tc_lab -n 100 recent service log
sudo bash setup.sh --list-backups snapshots taken before each upgrade
sudo bash setup.sh --rollback undo the last upgrade (code and state)
Accounts are managed day to day from the dashboard's Users section (admin only).
Documentation: docs/deployment.md, docs/upgrading.md, docs/users-and-security.md
"""
def main(argv=None):
p = argparse.ArgumentParser(
prog="tc-lab", description=HELP, epilog=EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("-V", "--version", action="version",
version=f"tc-lab (TC Lab) {_version()}")
sub = p.add_subparsers(dest="command", metavar="<command>")
r = sub.add_parser(
"reset-admin-password",
help="reset the dashboard admin password (root only)",
description=(
"Set a new password for the 'admin' account.\n\n"
"Must run as root. Prompts twice without echoing and applies the same\n"
"password rules as the dashboard (8 characters minimum, 72 bytes maximum).\n"
"Backs up users.json first, changes ONLY the admin account (recreating it\n"
"if it was deleted), and verifies the new password before reporting\n"
"success. Other accounts and their passwords are not touched."),
formatter_class=argparse.RawDescriptionHelpFormatter)
# Hidden, for automated provisioning; interactive use should omit it so the
# password never lands in the shell history or the process list.
r.add_argument("--password", help=argparse.SUPPRESS)
r.set_defaults(func=cmd_reset_admin_password)
l = sub.add_parser(
"list-users", help="list accounts and roles (no hashes)",
description="List every dashboard account and its role. Password hashes are never shown.")
l.add_argument("--json", action="store_true", help="machine-readable output")
l.set_defaults(func=cmd_list_users)
sp = sub.add_parser(
"set-port", help="set the dashboard's port (root only)",
description=(
f"Set the port the dashboard listens on ({ports.PORT_MIN}-{ports.PORT_MAX}).\n\n"
"Must run as root. Changes only 'port' in config.json; restart the\n"
"service to apply it. Use it when the dashboard cannot be reached on\n"
"its current port — day to day, change the port in Settings."),
formatter_class=argparse.RawDescriptionHelpFormatter)
sp.add_argument("port", help="the new port, e.g. 5000")
sp.set_defaults(func=cmd_set_port)
args = p.parse_args(argv)
if not args.command:
p.print_help()
return EXIT_OK
return args.func(args)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print()
_err("cancelled")
sys.exit(EXIT_ERR)