-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathset-irc-password.sh
More file actions
169 lines (152 loc) · 6.43 KB
/
Copy pathset-irc-password.sh
File metadata and controls
169 lines (152 loc) · 6.43 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
#!/usr/bin/env python3
# set-irc-password.sh — set or rotate a member's AgentBBS IRC password.
#
# Writes a pbkdf2-sha256 hash to the Ergo password store
# (/var/lib/ergo/irc-passwd, ergo:ergo 0600) that ergo-auth-member verifies on
# SASL login. When a The Lounge user file exists for the member it ALSO sets two
# distinct Lounge credentials to the same password:
# - the IRC network saslPassword (so the web client authenticates to Ergo), and
# - the Lounge WEB LOGIN password (the bcrypt field used to sign in to
# chat.<domain> itself), via `thelounge reset` (AGENTBBS_LOUNGE_RESET_CMD).
# Without the web-login sync a member who reset via passwd@ could connect to IRC
# but not log into the web client — the bug this guards against.
#
# Usage:
# set-irc-password.sh <member> [password] # password generated if omitted
# set-irc-password.sh <member> - # read the password from stdin
# set-irc-password.sh --all # provision any member missing one
#
# The "-" form is how the (non-root) BBS passwd@ flow calls this under sudo: the
# password arrives on stdin so it never lands in the process table or sudo's log.
#
# Run as root on the BBS box. The member must already be a BBS member; this only
# sets the secret — membership itself is still gated by /irc-auth.
import sys, os, json, glob, secrets, hashlib, pwd, grp, subprocess
PASSWD_FILE = os.environ.get("ERGO_IRC_PASSWD", "/var/lib/ergo/irc-passwd")
LOUNGE_USERS = os.environ.get("AGENTBBS_LOUNGE_USERS", "/var/lib/thelounge/users")
# Command that resets a member's The Lounge *web login* password — distinct from
# the IRC saslPassword. We feed the new password to it on STDIN (so it never
# lands on a command line) and append the member name. Default targets the
# dockerized The Lounge (`thelounge reset <member>` reads the password from
# stdin). Set AGENTBBS_LOUNGE_RESET_CMD="" to skip the web-login sync (e.g. when
# The Lounge isn't deployed), or override it for a native/other install.
LOUNGE_RESET_CMD = os.environ.get(
"AGENTBBS_LOUNGE_RESET_CMD", "docker exec -i thelounge thelounge reset"
)
ITERS = 200_000
def hash_pw(pw):
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, ITERS)
return f"pbkdf2_sha256${ITERS}${salt.hex()}${dk.hex()}"
def load_store():
store = {}
if os.path.exists(PASSWD_FILE):
for ln in open(PASSWD_FILE):
ln = ln.strip()
if ln and not ln.startswith("#"):
name, sep, h = ln.partition(":")
if sep:
store[name] = h
return store
def write_store(store):
uid = pwd.getpwnam("ergo").pw_uid
gid = grp.getgrnam("ergo").gr_gid
os.makedirs(os.path.dirname(PASSWD_FILE), exist_ok=True)
tmp = PASSWD_FILE + ".tmp"
with open(tmp, "w") as f:
f.write("# <account>:pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>\n")
for name in sorted(store):
f.write(f"{name}:{store[name]}\n")
os.chmod(tmp, 0o600)
os.chown(tmp, uid, gid)
os.replace(tmp, PASSWD_FILE)
def set_lounge_web_password(member, pw):
"""Set The Lounge WEB LOGIN password (its own bcrypt field), distinct from the
IRC saslPassword. Pipes the new password to `thelounge reset <member>` on
stdin so it never appears on a command line. Best-effort: warns but never
fails the run (the Ergo SASL credential is the primary IRC secret)."""
cmd = LOUNGE_RESET_CMD.strip()
if not cmd:
return
try:
r = subprocess.run(
cmd.split() + [member],
input=(pw + "\n").encode(),
capture_output=True,
timeout=30,
)
if r.returncode != 0:
print(
f"WARN: Lounge web-login reset for {member} failed (rc={r.returncode}): "
f"{r.stderr.decode(errors='replace').strip()[:200]}",
file=sys.stderr,
)
else:
print(f" (updated The Lounge web-login password for {member})")
except Exception as e:
print(f"WARN: Lounge web-login reset for {member}: {e}", file=sys.stderr)
def sync_lounge(member, pw):
p = os.path.join(LOUNGE_USERS, member + ".json")
if not os.path.exists(p):
return
# Web login password first (rewrites the user file via the Lounge CLI), then
# the saslPassword edit below reads that fresh file and preserves it.
set_lounge_web_password(member, pw)
try:
d = json.load(open(p))
except Exception as e:
print(f"WARN: could not update Lounge config for {member}: {e}", file=sys.stderr)
return
changed = False
for n in d.get("networks", []):
if "saslPassword" in n or n.get("saslAccount"):
n["saslPassword"] = pw
changed = True
if changed:
st = os.stat(p)
with open(p, "w") as f:
json.dump(d, f, indent=2)
os.chown(p, st.st_uid, st.st_gid)
os.chmod(p, 0o600)
print(f" (updated The Lounge saslPassword for {member}; restart thelounge to apply)")
def set_one(member, pw, store):
store[member] = hash_pw(pw)
sync_lounge(member, pw)
def main(argv):
if not argv:
print(__doc__.strip())
return 2
store = load_store()
if argv[0] == "--all":
members = sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(LOUNGE_USERS, "*.json")))
done = []
for m in members:
if m not in store:
pw = secrets.token_urlsafe(9)
set_one(m, pw, store)
done.append((m, pw))
write_store(store)
for m, pw in done:
print(f"{m}\t{pw}")
print(f"provisioned {len(done)} member(s) that were missing a password")
return 0
member = argv[0]
from_stdin = len(argv) > 1 and argv[1] == "-"
if from_stdin:
pw = sys.stdin.readline().rstrip("\n")
if not pw:
print("empty password on stdin", file=sys.stderr)
return 2
elif len(argv) > 1:
pw = argv[1]
else:
pw = secrets.token_urlsafe(9)
set_one(member, pw, store)
write_store(store)
# When the password came from stdin (BBS passwd@ flow) the caller already
# knows it — don't echo it back into their captured output. Otherwise print
# it so an operator running this by hand sees the generated/set value.
print(member if from_stdin else f"{member}\t{pw}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))