remoteRF-server-testing 0.0.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.
Files changed (44) hide show
  1. remoteRF_server/__init__.py +0 -0
  2. remoteRF_server/common/__init__.py +0 -0
  3. remoteRF_server/common/grpc/__init__.py +1 -0
  4. remoteRF_server/common/grpc/grpc_host_pb2.py +63 -0
  5. remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +97 -0
  6. remoteRF_server/common/grpc/grpc_pb2.py +59 -0
  7. remoteRF_server/common/grpc/grpc_pb2_grpc.py +97 -0
  8. remoteRF_server/common/idl/__init__.py +1 -0
  9. remoteRF_server/common/idl/device_schema.py +39 -0
  10. remoteRF_server/common/idl/pluto_schema.py +174 -0
  11. remoteRF_server/common/idl/schema.py +358 -0
  12. remoteRF_server/common/utils/__init__.py +6 -0
  13. remoteRF_server/common/utils/ansi_codes.py +120 -0
  14. remoteRF_server/common/utils/api_token.py +21 -0
  15. remoteRF_server/common/utils/db_connection.py +35 -0
  16. remoteRF_server/common/utils/db_location.py +24 -0
  17. remoteRF_server/common/utils/list_string.py +5 -0
  18. remoteRF_server/common/utils/process_arg.py +80 -0
  19. remoteRF_server/drivers/__init__.py +0 -0
  20. remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  21. remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +105 -0
  22. remoteRF_server/host/__init__.py +0 -0
  23. remoteRF_server/host/host_auth_token.py +292 -0
  24. remoteRF_server/host/host_directory_store.py +142 -0
  25. remoteRF_server/host/host_tunnel_server.py +1388 -0
  26. remoteRF_server/server/__init__.py +0 -0
  27. remoteRF_server/server/acc_perms.py +317 -0
  28. remoteRF_server/server/cert_provider.py +184 -0
  29. remoteRF_server/server/device_manager.py +688 -0
  30. remoteRF_server/server/grpc_server.py +1023 -0
  31. remoteRF_server/server/reservation.py +811 -0
  32. remoteRF_server/server/rpc_manager.py +104 -0
  33. remoteRF_server/server/user_group_cli.py +723 -0
  34. remoteRF_server/server/user_group_handler.py +1120 -0
  35. remoteRF_server/serverrf_cli.py +1377 -0
  36. remoteRF_server/tools/__init__.py +191 -0
  37. remoteRF_server/tools/gen_certs.py +274 -0
  38. remoteRF_server/tools/gist_status.py +139 -0
  39. remoteRF_server/tools/gist_status_testing.py +67 -0
  40. remoterf_server_testing-0.0.0.dist-info/METADATA +612 -0
  41. remoterf_server_testing-0.0.0.dist-info/RECORD +44 -0
  42. remoterf_server_testing-0.0.0.dist-info/WHEEL +5 -0
  43. remoterf_server_testing-0.0.0.dist-info/entry_points.txt +2 -0
  44. remoterf_server_testing-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import shutil
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+
13
+ def _run(cmd: list[str]) -> None:
14
+ try:
15
+ subprocess.run(cmd, check=True)
16
+ except subprocess.CalledProcessError as e:
17
+ print(f"\nCommand failed:\n {' '.join(cmd)}\n", file=sys.stderr)
18
+ raise SystemExit(e.returncode)
19
+
20
+
21
+ def _ensure_openssl() -> None:
22
+ if shutil.which("openssl") is None:
23
+ raise SystemExit("openssl not found on PATH. Install OpenSSL and try again.")
24
+
25
+
26
+ def _default_config_dir() -> Path:
27
+ # ~/.config/remoterf (matches your cert_fetcher convention)
28
+ return Path.home() / ".config" / "remoterf"
29
+
30
+
31
+ def _detect_local_ip() -> str:
32
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
33
+ try:
34
+ # No packets need to actually leave; this forces route selection
35
+ s.connect(("8.8.8.8", 80))
36
+ return s.getsockname()[0]
37
+ finally:
38
+ s.close()
39
+
40
+
41
+ def main() -> None:
42
+ _ensure_openssl()
43
+
44
+ ap = argparse.ArgumentParser(
45
+ prog="RRRFcerts",
46
+ description="Generate a local CA and server TLS cert/key with proper SANs into ~/.config/remoterf/certs",
47
+ )
48
+ ap.add_argument("--config-dir", default=str(_default_config_dir()), help="Base config dir (default: ~/.config/remoterf)")
49
+ ap.add_argument("--out-subdir", default="certs", help="Subdir under config-dir (default: certs)")
50
+ ap.add_argument("--ip", action="append", default=[], help="IP to include in SAN (repeatable)")
51
+ ap.add_argument("--dns", action="append", default=[], help="DNS name to include in SAN (repeatable)")
52
+ ap.add_argument("--cn", default=None, help="Common Name (defaults to first SAN entry)")
53
+ ap.add_argument("--days", type=int, default=365, help="Server cert validity in days")
54
+ ap.add_argument("--ca-days", type=int, default=3650, help="CA cert validity in days")
55
+ ap.add_argument("--bits", type=int, default=2048, help="RSA key size")
56
+ ap.add_argument("--force", action="store_true", help="Overwrite existing certs/keys")
57
+ ap.add_argument("--no-detect-ip", action="store_true", help="Do not auto-detect IP when none provided")
58
+ args = ap.parse_args()
59
+
60
+ base_dir = Path(args.config_dir).expanduser().resolve()
61
+ out_dir = (base_dir / args.out_subdir).resolve()
62
+ out_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ # If no SANs specified, auto-detect local IP unless disabled
65
+ ips = [x.strip() for x in args.ip if x and x.strip()]
66
+ dns = [x.strip() for x in args.dns if x and x.strip()]
67
+
68
+ if not ips and not dns and not args.no_detect_ip:
69
+ ips = [_detect_local_ip()]
70
+
71
+ if not ips and not dns:
72
+ raise SystemExit("Provide at least one --ip and/or --dns (or omit --no-detect-ip).")
73
+
74
+ sans: list[str] = [f"IP:{ip}" for ip in ips] + [f"DNS:{d}" for d in dns]
75
+ san_line = ",".join(sans)
76
+ cn = args.cn or (dns[0] if dns else ips[0])
77
+
78
+ # Filenames
79
+ ca_key = out_dir / "ca.key"
80
+ ca_crt = out_dir / "ca.crt"
81
+
82
+ srv_key = out_dir / "server.key"
83
+ srv_csr = out_dir / "server.csr"
84
+ srv_crt = out_dir / "server.crt"
85
+
86
+ targets = [ca_key, ca_crt, srv_key, srv_csr, srv_crt]
87
+
88
+ if not args.force:
89
+ existing = [p for p in targets if p.exists()]
90
+ if existing:
91
+ raise SystemExit(
92
+ "Refusing to overwrite existing files:\n "
93
+ + "\n ".join(str(p) for p in existing)
94
+ + "\nRe-run with --force to replace them."
95
+ )
96
+ else:
97
+ for p in targets:
98
+ if p.exists():
99
+ p.unlink()
100
+
101
+ # openssl may have left-over serial files from previous runs
102
+ for extra in (out_dir / "ca.srl",):
103
+ if extra.exists():
104
+ extra.unlink()
105
+
106
+ # 1) CA key + self-signed cert
107
+ _run(["openssl", "genrsa", "-out", str(ca_key), str(args.bits)])
108
+ _run([
109
+ "openssl", "req", "-x509", "-new", "-nodes",
110
+ "-key", str(ca_key),
111
+ "-sha256",
112
+ "-days", str(args.ca_days),
113
+ "-subj", "/CN=remoteRF Local CA",
114
+ "-out", str(ca_crt),
115
+ ])
116
+
117
+ # 2) Server key
118
+ _run(["openssl", "genrsa", "-out", str(srv_key), str(args.bits)])
119
+
120
+ # 3) CSR with SANs, then sign with CA including v3 extensions
121
+ with tempfile.TemporaryDirectory() as td:
122
+ td_path = Path(td)
123
+
124
+ csr_cnf = td_path / "csr.cnf"
125
+ csr_cnf.write_text(
126
+ f"""\
127
+ [ req ]
128
+ default_bits = {args.bits}
129
+ prompt = no
130
+ default_md = sha256
131
+ distinguished_name = dn
132
+ req_extensions = req_ext
133
+
134
+ [ dn ]
135
+ CN = {cn}
136
+
137
+ [ req_ext ]
138
+ subjectAltName = {san_line}
139
+ keyUsage = critical, digitalSignature, keyEncipherment
140
+ extendedKeyUsage = serverAuth
141
+ """,
142
+ encoding="utf-8",
143
+ )
144
+
145
+ _run([
146
+ "openssl", "req", "-new",
147
+ "-key", str(srv_key),
148
+ "-out", str(srv_csr),
149
+ "-config", str(csr_cnf),
150
+ ])
151
+
152
+ sign_cnf = td_path / "sign.cnf"
153
+ sign_cnf.write_text(
154
+ f"""\
155
+ [ v3_req ]
156
+ basicConstraints = CA:FALSE
157
+ subjectAltName = {san_line}
158
+ keyUsage = critical, digitalSignature, keyEncipherment
159
+ extendedKeyUsage = serverAuth
160
+ """,
161
+ encoding="utf-8",
162
+ )
163
+
164
+ _run([
165
+ "openssl", "x509", "-req",
166
+ "-in", str(srv_csr),
167
+ "-CA", str(ca_crt),
168
+ "-CAkey", str(ca_key),
169
+ "-CAcreateserial",
170
+ "-out", str(srv_crt),
171
+ "-days", str(args.days),
172
+ "-sha256",
173
+ "-extfile", str(sign_cnf),
174
+ "-extensions", "v3_req",
175
+ ])
176
+
177
+ print("\nGenerated certs/keys:")
178
+ print(f" Output dir: {out_dir}")
179
+ print(f" CA cert: {ca_crt}")
180
+ print(f" Server cert:{srv_crt}")
181
+ print(f" Server key: {srv_key}")
182
+ print("\nSANs:")
183
+ for s in sans:
184
+ print(f" - {s}")
185
+ print("\nClient trust:")
186
+ print(" Clients should trust ca.crt (NOT server.crt).")
187
+ print(" Clients must dial an address that matches one of the SANs.\n")
188
+
189
+
190
+ if __name__ == "__main__":
191
+ main()
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import socket
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import time
11
+ from datetime import datetime, timezone
12
+
13
+ def _x509_dates(cert_path: Path) -> tuple[datetime | None, datetime | None]:
14
+ # openssl prints:
15
+ # notBefore=Feb 16 23:12:02 2026 GMT
16
+ # notAfter=Feb 16 23:12:02 2027 GMT
17
+ p = subprocess.run(
18
+ ["openssl", "x509", "-in", str(cert_path), "-noout", "-startdate", "-enddate"],
19
+ stdout=subprocess.PIPE,
20
+ stderr=subprocess.STDOUT,
21
+ text=True,
22
+ check=False,
23
+ )
24
+ if p.returncode != 0:
25
+ return None, None
26
+
27
+ nb = na = None
28
+ for line in p.stdout.splitlines():
29
+ line = line.strip()
30
+ if line.startswith("notBefore="):
31
+ s = line.split("=", 1)[1].strip()
32
+ s = s.removesuffix(" GMT").strip()
33
+ try:
34
+ nb = datetime.strptime(s, "%b %d %H:%M:%S %Y").replace(tzinfo=timezone.utc)
35
+ except Exception:
36
+ nb = None
37
+ elif line.startswith("notAfter="):
38
+ s = line.split("=", 1)[1].strip()
39
+ s = s.removesuffix(" GMT").strip()
40
+ try:
41
+ na = datetime.strptime(s, "%b %d %H:%M:%S %Y").replace(tzinfo=timezone.utc)
42
+ except Exception:
43
+ na = None
44
+
45
+ return nb, na
46
+
47
+
48
+ def _run(cmd: list[str]) -> None:
49
+ try:
50
+ subprocess.run(cmd, check=True)
51
+ except subprocess.CalledProcessError as e:
52
+ print(f"\nCommand failed:\n {' '.join(cmd)}\n", file=sys.stderr)
53
+ raise SystemExit(e.returncode)
54
+
55
+
56
+ def _ensure_openssl() -> None:
57
+ if shutil.which("openssl") is None:
58
+ raise SystemExit("openssl not found on PATH. Install OpenSSL and try again.")
59
+
60
+
61
+ def _default_config_dir() -> Path:
62
+ return Path.home() / ".config" / "remoterf"
63
+
64
+
65
+ def _detect_local_ip() -> str:
66
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
67
+ try:
68
+ s.connect(("8.8.8.8", 80))
69
+ return s.getsockname()[0]
70
+ finally:
71
+ s.close()
72
+
73
+
74
+ def generate_certs(
75
+ *,
76
+ static_ip: str | None,
77
+ days: int = 365,
78
+ ca_days: int = 3650,
79
+ bits: int = 2048,
80
+ config_dir: Path | None = None,
81
+ out_subdir: str = "certs",
82
+ dns: list[str] | None = None,
83
+ cn: str | None = None,
84
+ force: bool = False,
85
+ ) -> None:
86
+ _ensure_openssl()
87
+
88
+ base_dir = (config_dir or _default_config_dir()).expanduser().resolve()
89
+ out_dir = (base_dir / out_subdir).resolve()
90
+ out_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ ips: list[str] = []
93
+ if static_ip and static_ip.strip():
94
+ ips.append(static_ip.strip())
95
+ else:
96
+ ips.append(_detect_local_ip())
97
+
98
+ dns = [d.strip() for d in (dns or []) if d and d.strip()]
99
+ sans: list[str] = [f"IP:{ip}" for ip in ips] + [f"DNS:{d}" for d in dns]
100
+ san_line = ",".join(sans)
101
+ cn = cn or (dns[0] if dns else ips[0])
102
+
103
+ ca_key = out_dir / "ca.key"
104
+ ca_crt = out_dir / "ca.crt"
105
+ srv_key = out_dir / "server.key"
106
+ srv_csr = out_dir / "server.csr"
107
+ srv_crt = out_dir / "server.crt"
108
+
109
+ targets = [ca_key, ca_crt, srv_key, srv_csr, srv_crt]
110
+
111
+ if not force:
112
+ existing = [p for p in targets if p.exists()]
113
+ if existing:
114
+ raise SystemExit(
115
+ "Refusing to overwrite existing files:\n "
116
+ + "\n ".join(str(p) for p in existing)
117
+ + "\nRe-run with --force to replace them."
118
+ )
119
+ else:
120
+ for p in targets:
121
+ if p.exists():
122
+ p.unlink()
123
+ extra = out_dir / "ca.srl"
124
+ if extra.exists():
125
+ extra.unlink()
126
+
127
+ _run(["openssl", "genrsa", "-out", str(ca_key), str(bits)])
128
+ _run([
129
+ "openssl", "req", "-x509", "-new", "-nodes",
130
+ "-key", str(ca_key),
131
+ "-sha256",
132
+ "-days", str(ca_days),
133
+ "-subj", "/CN=remoteRF Local CA",
134
+ "-out", str(ca_crt),
135
+ ])
136
+
137
+ _run(["openssl", "genrsa", "-out", str(srv_key), str(bits)])
138
+
139
+ with tempfile.TemporaryDirectory() as td:
140
+ td_path = Path(td)
141
+
142
+ csr_cnf = td_path / "csr.cnf"
143
+ csr_cnf.write_text(
144
+ f"""\
145
+ [ req ]
146
+ default_bits = {bits}
147
+ prompt = no
148
+ default_md = sha256
149
+ distinguished_name = dn
150
+ req_extensions = req_ext
151
+
152
+ [ dn ]
153
+ CN = {cn}
154
+
155
+ [ req_ext ]
156
+ subjectAltName = {san_line}
157
+ keyUsage = critical, digitalSignature, keyEncipherment
158
+ extendedKeyUsage = serverAuth
159
+ """,
160
+ encoding="utf-8",
161
+ )
162
+
163
+ _run([
164
+ "openssl", "req", "-new",
165
+ "-key", str(srv_key),
166
+ "-out", str(srv_csr),
167
+ "-config", str(csr_cnf),
168
+ ])
169
+
170
+ sign_cnf = td_path / "sign.cnf"
171
+ sign_cnf.write_text(
172
+ f"""\
173
+ [ v3_req ]
174
+ basicConstraints = CA:FALSE
175
+ subjectAltName = {san_line}
176
+ keyUsage = critical, digitalSignature, keyEncipherment
177
+ extendedKeyUsage = serverAuth
178
+ """,
179
+ encoding="utf-8",
180
+ )
181
+
182
+ _run([
183
+ "openssl", "x509", "-req",
184
+ "-in", str(srv_csr),
185
+ "-CA", str(ca_crt),
186
+ "-CAkey", str(ca_key),
187
+ "-CAcreateserial",
188
+ "-out", str(srv_crt),
189
+ "-days", str(days),
190
+ "-sha256",
191
+ "-extfile", str(sign_cnf),
192
+ "-extensions", "v3_req",
193
+ ])
194
+
195
+ print("\nGenerated certs/keys:")
196
+ print(f" Output dir: {out_dir}")
197
+ print(f" CA cert: {ca_crt}")
198
+ print(f" Server cert:{srv_crt}")
199
+ print(f" Server key: {srv_key}")
200
+ print("\nSANs:")
201
+ for s in sans:
202
+ print(f" - {s}")
203
+ print("\nClient trust:")
204
+ print(" Clients should trust ca.crt (NOT server.crt).")
205
+ print(" Clients must dial an address that matches one of the SANs.\n")
206
+
207
+ ca_nb, ca_na = _x509_dates(ca_crt)
208
+ srv_nb, srv_na = _x509_dates(srv_crt)
209
+
210
+ if srv_nb and srv_na:
211
+ now = datetime.now(timezone.utc)
212
+ remaining = srv_na - now
213
+ # clamp negative (if clock skew or already expired)
214
+ if remaining.total_seconds() < 0:
215
+ rem_str = "EXPIRED"
216
+ else:
217
+ days_left = remaining.days
218
+ hours_left = (remaining.seconds // 3600)
219
+ mins_left = (remaining.seconds % 3600) // 60
220
+ rem_str = f"{days_left}d {hours_left}h {mins_left}m"
221
+
222
+ print(" Server:")
223
+ print(f" notBefore (UTC): {srv_nb.isoformat(timespec='seconds')}")
224
+ print(f" notAfter (UTC): {srv_na.isoformat(timespec='seconds')}")
225
+ print(f" expires (local): {srv_na.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')}")
226
+ print(f" expires in: {rem_str}")
227
+ else:
228
+ print(" Server: (failed to parse dates)")
229
+
230
+
231
+
232
+ import argparse
233
+
234
+ def main() -> int:
235
+ ap = argparse.ArgumentParser(prog="gen_certs.py", description="Generate RemoteRF TLS certs")
236
+ ap.add_argument("--ip", dest="static_ip", default=None, help="Static IP to include in SAN (if omitted, auto-detect unless --no-detect-ip)")
237
+ ap.add_argument("--no-detect-ip", action="store_true", help="Require --ip; do not auto-detect")
238
+
239
+ ap.add_argument("--days", type=int, default=365)
240
+ ap.add_argument("--ca-days", type=int, default=3650)
241
+ ap.add_argument("--bits", type=int, default=2048)
242
+
243
+ ap.add_argument("--config-dir", default=None, help="Base config dir (default: ~/.config/remoterf)")
244
+ ap.add_argument("--out-subdir", default="certs", help="Subdir under config dir (default: certs)")
245
+
246
+ ap.add_argument("--dns", action="append", default=[], help="DNS SAN entry (repeatable)")
247
+ ap.add_argument("--cn", default=None, help="Common Name (default: first SAN entry)")
248
+ ap.add_argument("--force", action="store_true", help="Overwrite existing certs/keys")
249
+
250
+ args = ap.parse_args()
251
+
252
+ if args.no_detect_ip and not (args.static_ip and args.static_ip.strip()):
253
+ print("ERROR: --no-detect-ip set but no --ip provided", file=sys.stderr)
254
+ return 2
255
+
256
+ cfg = Path(args.config_dir).expanduser().resolve() if args.config_dir else None
257
+
258
+ generate_certs(
259
+ static_ip=args.static_ip,
260
+ days=args.days,
261
+ ca_days=args.ca_days,
262
+ bits=args.bits,
263
+ config_dir=cfg,
264
+ out_subdir=args.out_subdir,
265
+ dns=args.dns,
266
+ cn=args.cn,
267
+ force=args.force,
268
+ )
269
+
270
+ return 0
271
+
272
+
273
+ if __name__ == "__main__":
274
+ raise SystemExit(main())
@@ -0,0 +1,139 @@
1
+ # remoteRF_server/tools/gist_status.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import Dict, Optional
12
+
13
+ import requests
14
+
15
+
16
+ def _xdg_config_home() -> Path:
17
+ return Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
18
+
19
+
20
+ def _cfg_dir() -> Path:
21
+ return Path(os.getenv("REMOTERF_CONFIG_DIR", _xdg_config_home() / "remoterf"))
22
+
23
+
24
+ def _read_env_kv(path: Path) -> Dict[str, str]:
25
+ out: Dict[str, str] = {}
26
+ if not path.exists():
27
+ return out
28
+ for raw in path.read_text(encoding="utf-8").splitlines():
29
+ line = raw.strip()
30
+ if not line or line.startswith("#") or "=" not in line:
31
+ continue
32
+ k, v = line.split("=", 1)
33
+ out[k.strip()] = v.strip().strip('"').strip("'")
34
+ return out
35
+
36
+
37
+ def _load_gist_env() -> tuple[str, str, str]:
38
+ p = _cfg_dir() / "gist.env"
39
+ kv = _read_env_kv(p)
40
+
41
+ gist_id = kv.get("STATUS_GIST_ID", "").strip()
42
+ token = kv.get("GITHUB_TOKEN", "").strip()
43
+ filename = kv.get("STATUS_GIST_FILENAME", "").strip()
44
+
45
+ missing = []
46
+ if not gist_id:
47
+ missing.append("STATUS_GIST_ID")
48
+ if not token:
49
+ missing.append("GITHUB_TOKEN")
50
+ if not filename:
51
+ missing.append("STATUS_GIST_FILENAME")
52
+
53
+ if missing:
54
+ print(f"[gist_status] Missing {', '.join(missing)} in {p}", file=sys.stderr)
55
+ print(
56
+ "[gist_status] Fix: run:\n"
57
+ " serverrf --gist --set --id <gist_id> --file <filename>\n"
58
+ "Or manually create gist.env with those keys.",
59
+ file=sys.stderr,
60
+ )
61
+ raise SystemExit(2)
62
+
63
+ return gist_id, token, filename
64
+
65
+
66
+ def build_status() -> dict:
67
+ payload = {
68
+ "updated_unix": int(time.time()),
69
+ "reservation_history_days": 90,
70
+ "reservations": [],
71
+ }
72
+
73
+ from ..server.reservation import reservation_handler
74
+ payload["reservations"] = reservation_handler.get_obfuscated_device_reservations_grouped_past_days(90)
75
+ payload["usage"] = reservation_handler.get_obfuscated_usage_summary()
76
+
77
+ return payload
78
+
79
+
80
+ def push_gist(*, gist_id: str, gh_token: str, filename: str, payload: dict) -> None:
81
+ url = f"https://api.github.com/gists/{gist_id}"
82
+ headers = {
83
+ "Authorization": f"Bearer {gh_token}",
84
+ "Accept": "application/vnd.github+json",
85
+ "X-GitHub-Api-Version": "2022-11-28",
86
+ }
87
+ body = {
88
+ "files": {
89
+ filename: {
90
+ "content": json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n"
91
+ }
92
+ }
93
+ }
94
+ r = requests.patch(url, headers=headers, json=body, timeout=10)
95
+ r.raise_for_status()
96
+
97
+
98
+ def _publisher_loop(period_sec: int) -> None:
99
+ gist_id, gh_token, filename = _load_gist_env()
100
+
101
+ try:
102
+ push_gist(gist_id=gist_id, gh_token=gh_token, filename=filename, payload=build_status())
103
+ print("[gist_status] initial publish ok", file=sys.stderr)
104
+ except Exception as e:
105
+ print(f"[gist_status] initial publish failed: {e}", file=sys.stderr)
106
+
107
+ while True:
108
+ try:
109
+ push_gist(gist_id=gist_id, gh_token=gh_token, filename=filename, payload=build_status())
110
+ except Exception as e:
111
+ print(f"[gist_status] publish failed: {e}", file=sys.stderr)
112
+ time.sleep(period_sec)
113
+
114
+
115
+ _publisher_thread: Optional[threading.Thread] = None
116
+ _publisher_lock = threading.Lock()
117
+
118
+
119
+ def start_status_publisher() -> None:
120
+ global _publisher_thread
121
+ with _publisher_lock:
122
+ if _publisher_thread is not None and _publisher_thread.is_alive():
123
+ return
124
+
125
+ t = threading.Thread(
126
+ target=_publisher_loop,
127
+ args=(int(120),),
128
+ name="gist-status-publisher",
129
+ daemon=True,
130
+ )
131
+ t.start()
132
+ _publisher_thread = t
133
+ print(f"[gist_status] started (period={120}s)", file=sys.stderr)
134
+
135
+
136
+ # if __name__ == "__main__":
137
+ # start_status_publisher(period_sec=120)
138
+ # while True:
139
+ # time.sleep(3600)
@@ -0,0 +1,67 @@
1
+ # remoteRF_server/tools/gist_status.py
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import threading
7
+ import requests
8
+
9
+ # Create public gist on your github. Keep note of the file name
10
+ # https://gist.github.com/ethange1/2a35e08a90bf88a70dfe7f42a55685ed
11
+
12
+ # The last one is the "GIST_ID" part of the URL, e.g. "2a35e08a90bf88a70dfe7f42a55685ed"
13
+
14
+ GIST_ID = "2a35e08a90bf88a70dfe7f42a55685ed"
15
+
16
+ # Create the GITHUB_TOKEN (so your server can update it)
17
+ # GitHub → Settings
18
+ # Developer settings
19
+ # Personal access tokens → Fine-grained tokens
20
+ # Generate new token
21
+ # Set repository access to "Public Respositories"
22
+ # Permissions: enable Gists: Read and write
23
+ # Generate + copy the token (you only see it once)
24
+
25
+ # LIMIT OF 1 per minute ! BE MINDFUL OF RATE LIMITS!
26
+
27
+ GH_TOKEN = ""
28
+
29
+ FILENAME = "ucla-wlab-remoterf-status.json"
30
+
31
+ # HOW TO PULL! https://gist.githubusercontent.com/ethange1/2a35e08a90bf88a70dfe7f42a55685ed/raw/ucla-wlab-remoterf-status.json
32
+
33
+ def build_status() -> dict:
34
+ return {
35
+ "updated_unix": int(time.time()),
36
+ }
37
+
38
+ def push_gist(payload: dict) -> None:
39
+ url = f"https://api.github.com/gists/{GIST_ID}"
40
+ headers = {
41
+ "Authorization": f"Bearer {GH_TOKEN}",
42
+ "Accept": "application/vnd.github+json",
43
+ }
44
+ body = {
45
+ "files": {
46
+ FILENAME: {
47
+ "content": json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n"
48
+ }
49
+ }
50
+ }
51
+ r = requests.patch(url, headers=headers, json=body, timeout=10)
52
+ print(r.status_code, r.text)
53
+ print("Retry-After:", r.headers.get("Retry-After"))
54
+ print("X-RateLimit-Remaining:", r.headers.get("X-RateLimit-Remaining"))
55
+ r.raise_for_status()
56
+
57
+ def status_publisher_loop(period_sec: int = 30) -> None:
58
+ while True:
59
+ try:
60
+ push_gist(build_status())
61
+ except Exception as e:
62
+ print(f"[status] publish failed: {e}")
63
+ time.sleep(period_sec)
64
+
65
+ status_publisher_loop(60)
66
+
67
+ # threading.Thread(target=status_publisher_loop, daemon=True).start()