Repository navigation
Expand file tree
/
Copy pathcreate_subdomain.py
More file actions
1081 lines (925 loc) · 44.6 KB
/
Copy pathcreate_subdomain.py
File metadata and controls
1081 lines (925 loc) · 44.6 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
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
create_subdomain.py
Automatizza la procedura descritta nella pagina Confluence "Subdomain":
1. cPanel -> Domains -> Subdomains: crea il sottodominio
2. Namecheap -> Advanced DNS: aggiunge un A Record che punta a SERVER_IP
(SALTATO DI DEFAULT: con un record wildcard "*" -> SERVER_IP creato una
tantum su Namecheap, ogni sottodominio risolve già da solo. Passa
--with-dns-api solo se vuoi comunque creare un record dedicato via API -
serve un account Namecheap che soddisfi i requisiti per l'API: saldo
>= $50, 20+ domini, o $50 di acquisti negli ultimi 2 anni)
3. cPanel -> SSL/TLS Status: avvia AutoSSL (basta il token utente cPanel)
4. cPanel -> Domains: forza il redirect HTTPS (via .htaccess, vedi nota in fondo al file)
Uso:
python3 create_subdomain.py lab
python3 create_subdomain.py lab --dry-run
python3 create_subdomain.py lab --skip-autossl --skip-https-redirect
python3 create_subdomain.py lab --with-dns-api # solo se serve un record dedicato
python3 create_subdomain.py lab --skip-posthog # pagina iniziale senza PostHog
Con POSTHOG_PERSONAL_API_KEY impostata, il sottodominio viene anche aggiunto
agli Authorized URLs (app_urls) del progetto PostHog, e tolto con --delete:
Web analytics mostra solo il traffico dei domini in quella lista.
Rimozione:
python3 create_subdomain.py lab --delete # solo il sottodominio
python3 create_subdomain.py lab --delete --with-files # + cartella nel cestino
python3 create_subdomain.py lab --delete --with-files --purge # + cartella cancellata
Le credenziali si passano tramite variabili d'ambiente (vedi .env.example
nella stessa cartella). Non salvare mai le API key nel codice o in git.
"""
import argparse
from pathlib import Path
import hashlib
import html
import os
import re
import secrets
import sys
import time
import xml.etree.ElementTree as ET
import requests
# --------------------------------------------------------------------------- #
# Configurazione da variabili d'ambiente
# --------------------------------------------------------------------------- #
CPANEL_HOST = os.environ.get("CPANEL_HOST") # es. server.leandroestrella.com
CPANEL_USER = os.environ.get("CPANEL_USER") # utente cPanel
CPANEL_API_TOKEN = os.environ.get("CPANEL_API_TOKEN") # Security -> Manage API Tokens
ROOT_DOMAIN = os.environ.get("ROOT_DOMAIN") # es. tuodominio.com
SERVER_IP = os.environ.get("SERVER_IP") # IP a cui deve puntare il sottodominio
NAMECHEAP_API_USER = os.environ.get("NAMECHEAP_API_USER")
NAMECHEAP_API_KEY = os.environ.get("NAMECHEAP_API_KEY")
NAMECHEAP_USERNAME = os.environ.get("NAMECHEAP_USERNAME", NAMECHEAP_API_USER)
NAMECHEAP_CLIENT_IP = os.environ.get("NAMECHEAP_CLIENT_IP") # IP whitelisted in Namecheap
NAMECHEAP_API_BASE = os.environ.get(
"NAMECHEAP_API_BASE", "https://api.namecheap.com/xml.response"
)
# Facoltativo: serve solo per aggiungere/togliere il sottodominio dagli
# Authorized URLs del progetto PostHog. E' una personal API key
# (phx_...) con scope project:write, NON la chiave phc_ pubblica dello snippet.
POSTHOG_PERSONAL_API_KEY = os.environ.get("POSTHOG_PERSONAL_API_KEY")
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", "https://eu.posthog.com")
POSTHOG_PROJECT_ID = os.environ.get("POSTHOG_PROJECT_ID", "139609")
def log(step, msg):
print(f"[{step}] {msg}")
def require(vars_dict, names):
missing = [n for n in names if not vars_dict.get(n)]
if missing:
sys.exit(
"Missing environment variables: "
+ ", ".join(missing)
+ "\nSee .env.example for the full list."
)
# --------------------------------------------------------------------------- #
# 1. cPanel: creazione sottodominio (UAPI SubDomain::addsubdomain)
# --------------------------------------------------------------------------- #
def cpanel_headers():
return {"Authorization": f"cpanel {CPANEL_USER}:{CPANEL_API_TOKEN}"}
# Nome di sottodominio valido: lettere, cifre e trattini, niente trattino
# iniziale/finale (RFC 1123), max 63 caratteri per label.
SUBDOMAIN_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
def valida_subdomain(subdomain):
"""Rifiuta i nomi non validi PRIMA di usarli in chiamate o path.
Non e' solo cosmetica: il nome finisce in un path di cancellazione file
e negli argomenti passati dal workflow, quindi un valore inatteso non
deve nemmeno arrivare a quel punto.
"""
if not SUBDOMAIN_RE.match(subdomain or ""):
raise ValueError(
f"Invalid subdomain name: {subdomain!r}. "
"Only lowercase letters, digits and hyphens (not leading or trailing), max 63 characters."
)
return subdomain
def api2_call(module, func, extra_params, dove_controllare, timeout=90):
"""Chiama la vecchia API2 (/json-api/cpanel).
Serve per le operazioni che la UAPI non espone affatto: la cancellazione
di un sottodominio e quella dei file. Su questo server l'endpoint e'
lento e puo' sforare il timeout ANCHE SE l'operazione va comunque a
buon fine lato server: per questo il timeout viene tradotto in un
messaggio che dice esplicitamente di controllare prima di riprovare,
invece di lasciar passare un traceback.
"""
url = f"https://{CPANEL_HOST}:2083/json-api/cpanel"
params = {
"cpanel_jsonapi_user": CPANEL_USER,
"cpanel_jsonapi_apiversion": 2,
"cpanel_jsonapi_module": module,
"cpanel_jsonapi_func": func,
}
params.update(extra_params)
try:
resp = requests.get(url, headers=cpanel_headers(), params=params, timeout=timeout)
except requests.exceptions.Timeout:
raise RuntimeError(
f"Timed out waiting for a response (this server's API2 endpoint is slow). "
f"The operation may well have completed anyway: check {dove_controllare} "
f"before retrying, so you don't act twice."
)
return parse_api2_result(resp.json(), dove_controllare)
def parse_api2_result(data, dove_controllare):
"""Estrae l'esito da una risposta API2, alzando un errore parlante."""
result = (data.get("cpanelresult", {}).get("data") or [{}])[0]
if result.get("result") not in (1, "1"):
motivo = result.get("reason") or data.get("cpanelresult", {}).get("error") or data
raise RuntimeError(
f"Operation failed: {motivo}. Otherwise do it by hand from {dove_controllare}."
)
return result
def create_subdomain(subdomain, dry_run=False):
require(
{
"CPANEL_HOST": CPANEL_HOST,
"CPANEL_USER": CPANEL_USER,
"CPANEL_API_TOKEN": CPANEL_API_TOKEN,
"ROOT_DOMAIN": ROOT_DOMAIN,
},
["CPANEL_HOST", "CPANEL_USER", "CPANEL_API_TOKEN", "ROOT_DOMAIN"],
)
url = f"https://{CPANEL_HOST}:2083/execute/SubDomain/addsubdomain"
params = {
"domain": subdomain,
"rootdomain": ROOT_DOMAIN,
# Document root un livello sopra public_html (~/<subdomain>, non
# ~/public_html/<subdomain>) — confermato dal setup reale dell'account.
"dir": subdomain,
}
log("cPanel", f"Creating subdomain {subdomain}.{ROOT_DOMAIN} -> {params['dir']}")
if dry_run:
log("cPanel", f"[dry-run] GET {url} params={params}")
return True
resp = requests.get(url, headers=cpanel_headers(), params=params, timeout=30)
data = resp.json()
if not data.get("status"):
errors = data.get("errors") or [data.get("error")]
raise RuntimeError(f"Subdomain creation failed: {errors}")
log("cPanel", "Subdomain created.")
return True
def delete_subdomain(subdomain, dry_run=False):
require(
{
"CPANEL_HOST": CPANEL_HOST,
"CPANEL_USER": CPANEL_USER,
"CPANEL_API_TOKEN": CPANEL_API_TOKEN,
"ROOT_DOMAIN": ROOT_DOMAIN,
},
["CPANEL_HOST", "CPANEL_USER", "CPANEL_API_TOKEN", "ROOT_DOMAIN"],
)
# NB: la UAPI (endpoint /execute/) NON espone una funzione di cancellazione
# nel modulo SubDomain (solo addsubdomain e changedocroot). L'unica via
# rimasta è la vecchia API2 (deprecata ma ancora attiva sulla maggior
# parte dei server) tramite l'endpoint /json-api/cpanel.
params = {"domain": f"{subdomain}.{ROOT_DOMAIN}"}
log("cPanel", f"Removing subdomain {subdomain}.{ROOT_DOMAIN} (via API2, legacy)")
if dry_run:
log("cPanel", f"[dry-run] API2 SubDomain::delsubdomain params={params}")
return True
api2_call("SubDomain", "delsubdomain", params, "cPanel -> Domains")
log("cPanel", "Subdomain removed. (Files in the document root are NOT deleted automatically.)")
return True
def get_docroot(subdomain, dry_run=False):
"""Legge (document root, home) REALI del sottodominio da DomainInfo::domains_data.
Non si assume ~/<subdomain>: cPanel permette qualsiasi document root, e
i sottodomini creati da versioni precedenti di questo script stanno
sotto public_html/<nome>. Cancellare una cartella basandosi su un path
presunto puo' colpire una directory scorrelata e lasciare i file veri
dove sono.
NB: va chiamata PRIMA di delete_subdomain - dopo la rimozione il
sottodominio non e' piu' elencato e la docroot non e' piu' recuperabile.
"""
if dry_run:
return None, None
url = f"https://{CPANEL_HOST}:2083/execute/DomainInfo/domains_data"
resp = requests.get(
url, headers=cpanel_headers(), params={"format": "list"}, timeout=30
)
data = resp.json()
if not data.get("status"):
raise RuntimeError(f"Could not read the account's domains: {data.get('errors')}")
fqdn = f"{subdomain}.{ROOT_DOMAIN}"
return estrai_docroot(data.get("data"), fqdn)
def estrai_docroot(righe, fqdn):
"""Cerca (documentroot, homedir) del dominio richiesto in domains_data."""
for row in righe or []:
if not isinstance(row, dict) or row.get("domain") != fqdn:
continue
docroot = row.get("documentroot")
home = row.get("homedir")
if not docroot or not home:
raise RuntimeError(f"cPanel reports no document root and home for {fqdn}.")
return docroot, home
raise RuntimeError(
f"Subdomain {fqdn} not found among the account's domains: "
f"cannot determine its document root."
)
def assert_docroot_sicura(docroot, home):
"""Impedisce che una docroot inattesa faccia cancellare la cosa sbagliata.
Qui si cancellano file veri: la docroot deve stare DENTRO la home e non
puo' essere la home stessa ne' public_html (che e' la document root del
dominio principale - cancellarla porterebbe giu' il sito primario).
"""
percorso = (docroot or "").rstrip("/")
home = (home or "").rstrip("/")
if not percorso or not home:
raise RuntimeError("Document root or home could not be determined: not proceeding.")
if not percorso.startswith(home + "/"):
raise RuntimeError(
f"Document root {percorso!r} is outside the home {home!r}: not proceeding."
)
relativo = percorso[len(home) + 1:]
if not relativo or relativo == "public_html" or ".." in relativo.split("/"):
raise RuntimeError(
f"Document root is not safe to delete: {percorso!r}."
)
return percorso
def delete_docroot(subdomain, docroot=None, home=None, purge=False, dry_run=False):
"""Cancella la cartella document root del sottodominio.
Come per delsubdomain, la UAPI non espone NESSUNA funzione di
cancellazione file (verificato: mkdir/delete_files/trash_files/unlink
non esistono nel modulo Fileman). L'unica via e' di nuovo la vecchia
API2 tramite /json-api/cpanel, con Fileman::fileop:
- op=trash -> sposta in ~/.trash, recuperabile da cPanel (default)
- op=unlink -> cancellazione definitiva (--purge)
"""
require(
{"CPANEL_HOST": CPANEL_HOST, "CPANEL_USER": CPANEL_USER, "CPANEL_API_TOKEN": CPANEL_API_TOKEN},
["CPANEL_HOST", "CPANEL_USER", "CPANEL_API_TOKEN"],
)
op = "unlink" if purge else "trash"
modo = "PERMANENT deletion" if purge else "move to the trash (~/.trash)"
if dry_run:
log(
"Docroot",
f"[dry-run] {modo} of the document root of {subdomain}.{ROOT_DOMAIN} "
f"(API2 Fileman::fileop op={op}; the real path is read from cPanel at run time)",
)
return True
if not docroot or not home:
raise RuntimeError("Document root or home not provided: not deleting anything.")
target = assert_docroot_sicura(docroot, home)
log("Docroot", f"{modo} of {target}")
api2_call(
"Fileman",
"fileop",
{"op": op, "sourcefiles": target, "doubledecode": 0},
"cPanel -> File Manager",
)
if purge:
log("Docroot", "Folder deleted permanently.")
else:
log("Docroot", "Folder moved to the trash (restore it from cPanel -> File Manager -> Trash).")
return True
# --------------------------------------------------------------------------- #
# 2. Namecheap: aggiunta record A (domains.dns.getHosts + domains.dns.setHosts)
# --------------------------------------------------------------------------- #
def namecheap_params(command, extra=None):
p = {
"ApiUser": NAMECHEAP_API_USER,
"ApiKey": NAMECHEAP_API_KEY,
"UserName": NAMECHEAP_USERNAME,
"ClientIp": NAMECHEAP_CLIENT_IP,
"Command": command,
}
if extra:
p.update(extra)
return p
def costruisci_set_hosts(hosts, sld, tld, email_type=None):
"""Costruisce i parametri di setHosts a partire dai record da riscrivere.
Estratta come funzione pura perche' e' il punto in cui un campo dimenticato
si traduce in record DNS persi: e' testabile senza toccare la rete.
"""
extra = {"SLD": sld, "TLD": tld}
if email_type:
extra["EmailType"] = email_type
for i, h in enumerate(hosts, start=1):
extra[f"HostName{i}"] = h["HostName"]
extra[f"RecordType{i}"] = h["RecordType"]
extra[f"Address{i}"] = h["Address"]
extra[f"TTL{i}"] = h["TTL"]
if h["RecordType"] == "MX":
extra[f"MXPref{i}"] = h.get("MXPref", "10")
if h["RecordType"] == "CAA":
# Senza Flags/Tag un record CAA rimandato indietro perde significato.
if h.get("Flags") is not None:
extra[f"Flags{i}"] = h["Flags"]
if h.get("Tag") is not None:
extra[f"Tag{i}"] = h["Tag"]
return extra
def add_dns_record(subdomain, dry_run=False):
require(
{
"NAMECHEAP_API_USER": NAMECHEAP_API_USER,
"NAMECHEAP_API_KEY": NAMECHEAP_API_KEY,
"NAMECHEAP_CLIENT_IP": NAMECHEAP_CLIENT_IP,
"ROOT_DOMAIN": ROOT_DOMAIN,
"SERVER_IP": SERVER_IP,
},
["NAMECHEAP_API_USER", "NAMECHEAP_API_KEY", "NAMECHEAP_CLIENT_IP", "ROOT_DOMAIN", "SERVER_IP"],
)
sld, _, tld = ROOT_DOMAIN.partition(".")
if dry_run:
log(
"Namecheap",
f"[dry-run] Would read and rewrite the DNS records of {ROOT_DOMAIN} "
f"to add {subdomain} -> {SERVER_IP} (no call made).",
)
return True
# NB: Namecheap non ha un "addHost": setHosts SOSTITUISCE l'intera lista
# di record del dominio. Quindi leggiamo prima quelli esistenti (getHosts)
# e li reinviamo tutti insieme al nuovo record.
log("Namecheap", f"Reading the existing DNS records for {ROOT_DOMAIN}")
get_resp = requests.get(
NAMECHEAP_API_BASE,
params=namecheap_params("namecheap.domains.dns.getHosts", {"SLD": sld, "TLD": tld}),
timeout=30,
)
get_resp.raise_for_status()
ns = {"nc": "http://api.namecheap.com/xml.response"}
root = ET.fromstring(get_resp.text)
if root.get("Status") != "OK":
errs = [e.text for e in root.findall(".//nc:Errors/nc:Error", ns)]
raise RuntimeError(f"getHosts failed: {errs}")
# setHosts riscrive TUTTI i record del dominio: quello che non viene
# rimandato indietro sparisce. Quindi il round-trip deve conservare anche
# i campi che non ci interessano direttamente (Flags/Tag dei CAA), non
# solo nome/tipo/indirizzo.
hosts = []
for h in root.findall(".//nc:host", ns):
hosts.append(
{
"HostName": h.get("Name"),
"RecordType": h.get("Type"),
"Address": h.get("Address"),
"TTL": h.get("TTL"),
"MXPref": h.get("MXPref", "10"),
"Flags": h.get("Flags"),
"Tag": h.get("Tag"),
"IsDDNSEnabled": (h.get("IsDDNSEnabled") or "").lower() == "true",
}
)
# L'API setHosts non ha un parametro per il flag Dynamic DNS: se un record
# ce l'ha attivo, il round-trip lo perde. Meglio fermarsi e lasciare che
# sia una scelta esplicita, invece di degradare un record in silenzio.
ddns = [h["HostName"] for h in hosts if h["IsDDNSEnabled"] and h["HostName"] != subdomain]
if ddns:
raise RuntimeError(
f"Records {ddns} have Dynamic DNS enabled and setHosts has no way to preserve "
f"it: rewriting them would downgrade them to static records. Add the record "
f"by hand from the Namecheap panel, or remove the DDNS flag if you no "
f"longer need it."
)
# EmailType NON e' opzionale in pratica: se non viene rimandato indietro,
# Namecheap puo' resettare l'instradamento email del dominio (MX inclusi).
email_type = None
hosts_result = root.find(".//nc:DomainDNSGetHostsResult", ns)
if hosts_result is not None:
email_type = hosts_result.get("EmailType")
# Rimuove un eventuale record preesistente con lo stesso nome, poi aggiunge quello nuovo
hosts = [h for h in hosts if h["HostName"] != subdomain]
hosts.append(
{
"HostName": subdomain,
"RecordType": "A",
"Address": SERVER_IP,
"TTL": "60", # "Automatic" nell'interfaccia corrisponde al TTL minimo
"MXPref": "10",
"Flags": None,
"Tag": None,
"IsDDNSEnabled": False,
}
)
extra = costruisci_set_hosts(hosts, sld, tld, email_type)
log("Namecheap", f"Writing {len(hosts)} records (including the new A record for '{subdomain}')")
set_resp = requests.get(
NAMECHEAP_API_BASE,
params=namecheap_params("namecheap.domains.dns.setHosts", extra),
timeout=30,
)
set_resp.raise_for_status()
root = ET.fromstring(set_resp.text)
if root.get("Status") != "OK":
errs = [e.text for e in root.findall(".//nc:Errors/nc:Error", ns)]
raise RuntimeError(f"setHosts failed: {errs}")
ok = root.find(".//nc:DomainDNSSetHostsResult", ns)
if ok is None or ok.get("IsSuccess") != "true":
raise RuntimeError("setHosts did not confirm success.")
log("Namecheap", f"A record {subdomain}.{ROOT_DOMAIN} -> {SERVER_IP} created.")
return True
# --------------------------------------------------------------------------- #
# 3. cPanel: AutoSSL (UAPI SSL::start_autossl_check)
#
# Basta il token utente cPanel: la versione UAPI (porta 2083) avvia il
# controllo AutoSSL per l'account. La versione WHM (porta 2087) chiedeva un
# token root/reseller che non c'e', per questo lo step prima veniva saltato.
# --------------------------------------------------------------------------- #
AUTOSSL_POLL_SECONDS = 10
VHOST_ATTESA_MAX = 180
VHOST_POLL_SECONDS = 5
def problemi_autossl(righe, fqdn):
"""Filtra i problemi di get_autossl_problems che riguardano fqdn.
Solo il nome esatto: www.<sottodominio> non e' affare di questo script.
Il wildcard "*" lo fa risolvere solo se <sottodominio> non ha un record
DNS suo (un wildcard non scende sotto un nome che esiste), quindi per i
sottodomini con un record dedicato e' tra i problemi da sempre.
"""
fqdn = fqdn.lower()
return [
r for r in (righe or [])
if str(r.get("domain", "")).lower() == fqdn
]
def impronta_http(status, location, corpo, host):
"""Riassume una risposta HTTP in modo confrontabile tra host diversi.
Il nome dell'host viene sostituito da un segnaposto, cosi' due risposte
dello stesso vhost a nomi diversi (es. il catch-all "*") risultano uguali.
"""
def neutro(testo):
return (testo or "").replace(host, "{host}")
digest = hashlib.sha256(neutro(corpo).encode("utf-8", "replace")).hexdigest()
return (status, neutro(location), digest)
def _impronta_di(host):
try:
resp = requests.get(f"http://{host}/", allow_redirects=False, timeout=10)
except requests.RequestException as e:
return ("errore", type(e).__name__, "")
return impronta_http(resp.status_code, resp.headers.get("Location"), resp.text, host)
def attendi_vhost(fqdn, attesa_max=VHOST_ATTESA_MAX):
"""Aspetta che Apache serva il sottodominio con il SUO vhost.
Verificato sul server: il vhost di un sottodominio appena creato risponde
dopo 20-30 secondi. Prima, con il DNS wildcard, la richiesta finisce al
vhost di ripiego (il catch-all "*" o quello di default del server), la
validazione HTTP di AutoSSL trova un 404 e il certificato slitta al giro
programmato del server. Il riferimento e' la risposta a un nome inventato:
finche' il sottodominio risponde allo stesso modo, non e' ancora suo.
"""
finto = f"hammerspace-probe-{secrets.token_hex(4)}.{ROOT_DOMAIN}"
ripiego = _impronta_di(finto)
scadenza = time.monotonic() + attesa_max
while True:
if _impronta_di(fqdn) != ripiego:
return True
if time.monotonic() >= scadenza:
return False
time.sleep(VHOST_POLL_SECONDS)
def _autossl_uapi(funzione, timeout=30):
url = f"https://{CPANEL_HOST}:2083/execute/SSL/{funzione}"
resp = requests.get(url, headers=cpanel_headers(), timeout=timeout)
data = resp.json()
if not data.get("status"):
raise RuntimeError(f"SSL::{funzione} failed: {data.get('errors')}")
return data.get("data")
def run_autossl(subdomain, dry_run=False, wait_seconds=0):
require(
{
"CPANEL_HOST": CPANEL_HOST,
"CPANEL_USER": CPANEL_USER,
"CPANEL_API_TOKEN": CPANEL_API_TOKEN,
"ROOT_DOMAIN": ROOT_DOMAIN,
},
["CPANEL_HOST", "CPANEL_USER", "CPANEL_API_TOKEN", "ROOT_DOMAIN"],
)
fqdn = f"{subdomain}.{ROOT_DOMAIN}"
url = f"https://{CPANEL_HOST}:2083/execute/SSL/start_autossl_check"
log("AutoSSL", f"Starting AutoSSL for user {CPANEL_USER}")
if dry_run:
log("AutoSSL", f"[dry-run] would first wait up to {VHOST_ATTESA_MAX}s for "
f"{fqdn} to be served by its own virtual host")
log("AutoSSL", f"[dry-run] GET {url}")
if wait_seconds:
log("AutoSSL", f"[dry-run] would wait up to {wait_seconds}s, then read "
f"SSL::get_autossl_problems for {fqdn}")
return True
log("AutoSSL", f"Waiting for the server to serve {fqdn} from its own virtual host...")
if not attendi_vhost(fqdn):
log("AutoSSL", f"WARNING: {fqdn} still answers like an undefined name after "
f"{VHOST_ATTESA_MAX}s: starting AutoSSL anyway, but its HTTP "
"validation may fail until the server's next scheduled run.")
_autossl_uapi("start_autossl_check")
if not wait_seconds:
log("AutoSSL", "AutoSSL check started. Certificate issuance is asynchronous: "
"check cPanel > SSL/TLS Status in a few minutes.")
return True
# Il controllo gira sul server per tutto l'account: si aspetta che
# finisca, poi si leggono i problemi del solo sottodominio nuovo.
log("AutoSSL", f"AutoSSL check started. Waiting up to {wait_seconds}s for it to finish...")
scadenza = time.monotonic() + wait_seconds
while True:
time.sleep(min(AUTOSSL_POLL_SECONDS, max(0, scadenza - time.monotonic())))
if not _autossl_uapi("is_autossl_check_in_progress"):
break
if time.monotonic() >= scadenza:
log("AutoSSL", f"Still running after {wait_seconds}s: check cPanel > "
"SSL/TLS Status later.")
return True
problemi = problemi_autossl(_autossl_uapi("get_autossl_problems"), fqdn)
if problemi:
for p in problemi:
log("AutoSSL", f"WARNING: {p.get('domain')}: {p.get('problem')}")
return False
log("AutoSSL", f"AutoSSL check finished with no problems reported for {fqdn}.")
return True
# --------------------------------------------------------------------------- #
# 4. Force HTTPS Redirect (via .htaccess, UAPI Fileman)
#
# NOTA: non esiste una singola chiamata UAPI pubblica e documentata che
# corrisponda 1:1 al toggle "Force HTTPS Redirect" mostrato nell'interfaccia
# Domains di cPanel (il comportamento interno dipende dalla versione/tema).
# L'equivalente affidabile e documentato è scrivere le regole di redirect
# in un .htaccess nella document root del sottodominio - è il meccanismo che
# quel toggle applica sotto il cofano nelle versioni cPanel più comuni.
# Verifica comunque il risultato: Domains -> il sottodominio dovrebbe
# mostrare il redirect attivo.
# --------------------------------------------------------------------------- #
HTACCESS_SNIPPET = """
# --- BEGIN force-https (added automatically) ---
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# --- END force-https ---
"""
def file_inesistente(data):
"""True se l'errore di get_file_content dice che il file non c'e'.
Verificato sul server: per un file mancante l'API risponde status 0 con
un errore tipo "The file ... does not exist for the account.". Va
distinto da un errore di lettura vero, perche' nel primo caso si puo'
creare il file da zero, nel secondo no.
"""
errori = data.get("errors") or []
return any("does not exist" in str(e) for e in errori)
def force_https_redirect(subdomain, dry_run=False):
require(
{"CPANEL_HOST": CPANEL_HOST, "CPANEL_USER": CPANEL_USER, "CPANEL_API_TOKEN": CPANEL_API_TOKEN},
["CPANEL_HOST", "CPANEL_USER", "CPANEL_API_TOKEN"],
)
# Qui il path non e' un'assunzione: questa funzione viene eseguita solo
# subito dopo create_subdomain, che ha appena impostato la document root
# a <subdomain>. Su un sottodominio preesistente andrebbe invece letta
# con get_docroot (come fa il percorso di cancellazione).
doc_root = subdomain
log("HTTPS redirect", f"Writing redirect rules to {doc_root}/.htaccess")
if dry_run:
log("HTTPS redirect", f"[dry-run] Would append to {doc_root}/.htaccess:\n{HTACCESS_SNIPPET}")
return True
read_url = f"https://{CPANEL_HOST}:2083/execute/Fileman/get_file_content"
resp = requests.get(
read_url,
headers=cpanel_headers(),
params={"dir": doc_root, "file": ".htaccess"},
timeout=30,
)
# save_file_content SOSTITUISCE l'intero file: se la lettura fallisce e si
# procede lo stesso, un .htaccess gia' popolato (regole WordPress, redirect
# custom...) verrebbe rimpiazzato dal solo blocco force-https. Quindi si
# distingue "il file non esiste" (normale su un sottodominio nuovo, si
# parte da vuoto) da "non sono riuscito a leggerlo" (ci si ferma).
existing = ""
data = resp.json() if resp.ok else {}
if data.get("status"):
existing = (data.get("data") or {}).get("content", "")
elif not file_inesistente(data):
raise RuntimeError(
f"Could not read {doc_root}/.htaccess: {data.get('errors') or resp.status_code}. "
"Not proceeding: that would overwrite an existing file without knowing what is in it."
)
if "force-https" in existing:
log("HTTPS redirect", ".htaccess already contains the force-https block, nothing to do.")
return True
new_content = existing + HTACCESS_SNIPPET
save_url = f"https://{CPANEL_HOST}:2083/execute/Fileman/save_file_content"
resp = requests.post(
save_url,
headers=cpanel_headers(),
data={"dir": doc_root, "file": ".htaccess", "content": new_content},
timeout=30,
)
data = resp.json()
if not data.get("status"):
raise RuntimeError(f"Writing .htaccess failed: {data.get('errors')}")
log("HTTPS redirect", "Forced HTTPS redirect enabled via .htaccess.")
return True
# --------------------------------------------------------------------------- #
# 5. Pagina iniziale con PostHog (UAPI Fileman)
#
# Un sottodominio appena creato ha la document root vuota: non esiste un
# template da cui "ereditare" lo snippet di analytics. Qui se ne scrive uno
# minimo, gia' strumentato, cosi' il sottodominio viene tracciato dal primo
# minuto. Il primo deploy vero (setup_autodeploy.py) lo sostituira' con
# l'index.html del repository: per questo setup_autodeploy.py controlla
# anche che quel repository contenga lo snippet.
#
# Lo snippet sta in assets/posthog-snippet.html ed e' l'unica copia
# canonica: la chiave phc_ e' una chiave di sola ingestione, pubblica per
# design (e' gia' nell'HTML di ogni pagina), quindi puo' stare nel repo.
# --------------------------------------------------------------------------- #
POSTHOG_SNIPPET_PATH = Path(__file__).resolve().parent / "assets" / "posthog-snippet.html"
# I file che cPanel/Apache servirebbero come pagina iniziale. Se ce n'e'
# gia' uno non si tocca niente: la pagina iniziale non deve mai sovrascrivere
# contenuto vero.
INDEX_CANDIDATI = ("index.html", "index.php")
# Tutti i siti mandano allo stesso progetto PostHog: il tag `project` e'
# l'unico modo per separarli nei report. Nello snippet c'e' un segnaposto
# che viene sostituito con il nome del sottodominio.
POSTHOG_PROJECT_PLACEHOLDER = "__PROJECT__"
def carica_snippet_posthog(path=POSTHOG_SNIPPET_PATH, project=None):
"""Legge lo snippet canonico. Fallisce subito se manca o e' incompleto.
Con `project` il segnaposto del tag viene sostituito con quel nome.
"""
snippet = Path(path).read_text(encoding="utf-8")
for obbligatorio in ("posthog.init(", "cookieless_mode: 'always'", "person_profiles: 'never'"):
if obbligatorio not in snippet:
raise RuntimeError(f"{path} does not contain {obbligatorio!r}: refusing to ship an unsafe snippet.")
if project is not None:
if not re.fullmatch(r"[a-z0-9-]+", project):
raise ValueError(f"{project!r} is not a valid PostHog project tag.")
snippet = snippet.replace(POSTHOG_PROJECT_PLACEHOLDER, project)
return snippet
def costruisci_starter_page(fqdn, snippet=None):
"""HTML della pagina iniziale. Funzione pura: testata senza rete.
Senza snippet (--skip-posthog) la pagina resta la stessa, solo non tracciata.
"""
nome = html.escape(fqdn)
righe_snippet = f"{snippet.rstrip()}\n" if snippet else ""
return (
"<!DOCTYPE html>\n"
'<html lang="en">\n'
"<head>\n"
' <meta charset="utf-8">\n'
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
' <meta name="robots" content="noindex">\n'
f" <title>{nome}</title>\n"
f"{righe_snippet}"
"</head>\n"
"<body>\n"
f" <p>{nome} is being set up.</p>\n"
# Every leandroestrella.com property links the shared privacy notice.
' <p><a href="https://leandroestrella.com/privacy.html" aria-label="privacy policy" title="privacy">🛡️</a></p>\n'
"</body>\n"
"</html>\n"
)
def write_starter_page(subdomain, posthog=True, dry_run=False):
require(
{"CPANEL_HOST": CPANEL_HOST, "CPANEL_USER": CPANEL_USER, "CPANEL_API_TOKEN": CPANEL_API_TOKEN},
["CPANEL_HOST", "CPANEL_USER", "CPANEL_API_TOKEN"],
)
fqdn = f"{subdomain}.{ROOT_DOMAIN}"
contenuto = costruisci_starter_page(fqdn, carica_snippet_posthog(project=subdomain) if posthog else None)
# Come per force_https_redirect: il path e' quello appena impostato da
# create_subdomain, non un'assunzione su un sottodominio preesistente.
doc_root = subdomain
if posthog:
log("Starter page", f"Writing {doc_root}/index.html with the PostHog snippet")
else:
log("Starter page", f"Writing {doc_root}/index.html without PostHog (--skip-posthog)")
if dry_run:
log("Starter page", f"[dry-run] Would write {doc_root}/index.html ({len(contenuto)} bytes) "
"unless an index file already exists.")
return True
read_url = f"https://{CPANEL_HOST}:2083/execute/Fileman/get_file_content"
for nome in INDEX_CANDIDATI:
resp = requests.get(read_url, headers=cpanel_headers(), params={"dir": doc_root, "file": nome}, timeout=30)
data = resp.json() if resp.ok else {}
if data.get("status"):
log("Starter page", f"{doc_root}/{nome} already exists: left untouched.")
return True
if not file_inesistente(data):
# Non sapere se il file c'e' e' diverso da sapere che non c'e':
# nel dubbio non si scrive, per non sovrascrivere niente.
raise RuntimeError(
f"Could not check {doc_root}/{nome}: {data.get('errors') or resp.status_code}. "
"Not writing the starter page."
)
save_url = f"https://{CPANEL_HOST}:2083/execute/Fileman/save_file_content"
resp = requests.post(
save_url,
headers=cpanel_headers(),
data={"dir": doc_root, "file": "index.html", "content": contenuto},
timeout=30,
)
data = resp.json()
if not data.get("status"):
raise RuntimeError(f"Writing index.html failed: {data.get('errors')}")
if posthog:
log("Starter page", f"Starter page written: https://{fqdn}/ is tracked in PostHog from now on.")
else:
log("Starter page", f"Starter page written: https://{fqdn}/ (not tracked).")
return True
# --------------------------------------------------------------------------- #
# 6. PostHog: Authorized URLs del progetto (REST API environments)
#
# Da quando il progetto ha degli Authorized URLs (app_urls), Web analytics
# filtra sui soli domini in quella lista: il traffico di un sottodominio nuovo
# arriva comunque, ma non si vede finche' il suo URL non ci viene aggiunto.
#
# L'API non ha un "append": PATCH sostituisce l'intera lista. Quindi, come per
# setHosts di Namecheap, si legge quella esistente e si rimanda tutta. Tra GET
# e PATCH non c'e' nessun lock: una modifica fatta a mano nello stesso istante
# andrebbe persa, ma per un'operazione lanciata a mano ogni tanto va bene.
#
# Lo step e' facoltativo e non blocca mai il resto: senza la chiave si salta,
# e un errore dell'API diventa un WARNING con la strada manuale, perche' a quel
# punto il sottodominio e' gia' stato creato (o rimosso) davvero.
# --------------------------------------------------------------------------- #
POSTHOG_SETTINGS_PATH = "project settings -> Authorized URLs"
def url_sottodominio(subdomain):
return f"https://{subdomain}.{ROOT_DOMAIN}"
def _chiave_url(url):
"""Forma normalizzata per il confronto: niente slash finale, minuscolo."""
return str(url).strip().rstrip("/").lower()
def aggiungi_app_url(app_urls, url):
"""Nuova lista app_urls con `url` in fondo. Funzione pura: testata senza rete.
Le voci esistenti restano com'erano e nell'ordine in cui erano; i
duplicati (anche solo per slash finale o maiuscole) spariscono, tenendo
la prima occorrenza.
"""
risultato, visti = [], set()
for voce in list(app_urls or []) + [url]:
if not voce or _chiave_url(voce) in visti:
continue
visti.add(_chiave_url(voce))
risultato.append(voce)
return risultato
def rimuovi_app_url(app_urls, url):
"""Nuova lista app_urls senza `url` (in nessuna delle sue varianti)."""
return [v for v in (app_urls or []) if v and _chiave_url(v) != _chiave_url(url)]
def posthog_headers():
return {"Authorization": f"Bearer {POSTHOG_PERSONAL_API_KEY}"}
def _posthog_json(resp, operazione):
"""Esito di una chiamata PostHog, con un errore parlante sui casi tipici."""
if resp.status_code in (401, 403):
raise RuntimeError(
f"{operazione} refused ({resp.status_code}): POSTHOG_PERSONAL_API_KEY must be a "
f"personal API key with the 'project:write' scope on project {POSTHOG_PROJECT_ID}."
)
if resp.status_code == 404:
raise RuntimeError(
f"{operazione}: project {POSTHOG_PROJECT_ID} not found on {POSTHOG_HOST} (404). "
"Check POSTHOG_PROJECT_ID and POSTHOG_HOST."
)
if not resp.ok:
raise RuntimeError(f"{operazione} failed ({resp.status_code}): {resp.text[:300]}")
return resp.json()
def aggiorna_app_urls_posthog(subdomain, rimuovi=False, dry_run=False):
"""Aggiunge (o con rimuovi=True toglie) il sottodominio dagli app_urls."""
url = url_sottodominio(subdomain)
if not POSTHOG_PERSONAL_API_KEY:
log(
"PostHog",
"POSTHOG_PERSONAL_API_KEY is not set: skipping the Authorized URLs step. "
f"{'Remove' if rimuovi else 'Add'} {url} by hand in PostHog -> {POSTHOG_SETTINGS_PATH}"
+ ("." if rimuovi else ", or Web analytics won't show its traffic."),
)
return False
api_url = f"{POSTHOG_HOST.rstrip('/')}/api/environments/{POSTHOG_PROJECT_ID}/"
azione = "Removing" if rimuovi else "Adding"
log("PostHog", f"{azione} {url} {'from' if rimuovi else 'to'} the Authorized URLs of project {POSTHOG_PROJECT_ID}")
if dry_run:
log("PostHog", f"[dry-run] Would GET {api_url} and PATCH its app_urls (no call made).")
return True
try:
attuali = _posthog_json(
requests.get(api_url, headers=posthog_headers(), timeout=30), "Reading the project"
).get("app_urls") or []
nuovi = rimuovi_app_url(attuali, url) if rimuovi else aggiungi_app_url(attuali, url)
if nuovi == attuali:
log("PostHog", f"{url} is already {'absent' if rimuovi else 'listed'}, nothing to do.")
return True
salvati = _posthog_json(
requests.patch(api_url, headers=posthog_headers(), json={"app_urls": nuovi}, timeout=30),
"Updating the Authorized URLs",
).get("app_urls") or []
presente = any(_chiave_url(v) == _chiave_url(url) for v in salvati)
if presente == rimuovi:
raise RuntimeError(f"PostHog accepted the update but its app_urls are now {salvati}.")
except (requests.exceptions.RequestException, RuntimeError, ValueError) as e:
log(
"PostHog",
f"WARNING: could not update the Authorized URLs ({e}). "
f"{'Remove' if rimuovi else 'Add'} {url} by hand in PostHog -> {POSTHOG_SETTINGS_PATH}.",
)
return False
if rimuovi:
log("PostHog", f"{url} removed from the Authorized URLs ({len(salvati)} left).")
else:
log("PostHog", f"{url} added to the Authorized URLs: its traffic shows up in Web analytics.")
return True
# --------------------------------------------------------------------------- #
# Orchestrazione
# --------------------------------------------------------------------------- #
def main():
parser = argparse.ArgumentParser(description="Automate creating a subdomain on cPanel")
parser.add_argument("subdomain", help="Subdomain name, e.g. 'lab'")
parser.add_argument("--dry-run", action="store_true", help="Print every call it would make, and make none")
parser.add_argument(
"--delete",
action="store_true",
help="Remove the subdomain from cPanel instead of creating it. Files in "
"the document root are left alone; see --with-files.",
)
parser.add_argument(
"--with-files",
action="store_true",
help="With --delete only: also delete the document root folder "
"(~/<subdomain>), moving it to the ~/.trash bin.",
)
parser.add_argument(
"--purge",
action="store_true",
help="With --with-files only: delete the folder PERMANENTLY instead of "
"moving it to the trash.",
)
parser.add_argument(
"--with-dns-api",
action="store_true",
help="Call the Namecheap API to create a dedicated A record anyway "
"(unnecessary if you have a wildcard '*' -> SERVER_IP, see below).",
)
parser.add_argument(
"--skip-autossl", action="store_true",
help="Skip the AutoSSL trigger.",
)
parser.add_argument(
"--autossl-wait", type=int, default=0, metavar="SECONDS",
help="Wait up to SECONDS for the AutoSSL check to finish, then report "
"any problem it found for the new subdomain (default: 0, don't wait).",
)
parser.add_argument(
"--skip-https-redirect", action="store_true",
help="Skip writing the force-https block into .htaccess.",
)
parser.add_argument(
"--skip-starter-page", action="store_true",
help="Skip writing a placeholder index.html (with the PostHog snippet) "
"into the new document root.",
)
parser.add_argument(
"--skip-posthog", action="store_true",
help="Write the starter page without the PostHog snippet: the subdomain "
"is not tracked, and is not added to PostHog's Authorized URLs.",
)
args = parser.parse_args()