Compare commits
No commits in common. "main" and "pre-rewrite" have entirely different histories.
main
...
pre-rewrit
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -132,5 +132,3 @@ dist
|
|||
*.db
|
||||
sessions/*
|
||||
.DS_Store
|
||||
|
||||
test/*
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
AstroCom integration for freePBX (Originally made for TandmX by @lachesis_._ @lachesis_._)
|
||||
1. (caveats: assumes full exchange, assumes 4 digit extensions, assumes you're okay with AstroCom users dialing anything 4-digit on your switch)
|
||||
2. Put the following in your `globals_custom.conf`:
|
||||
```
|
||||
astrocomkey=(long-key-you-got-from-admins)
|
||||
```
|
||||
3. Put the following in your `iax_custom.conf`:
|
||||
```
|
||||
[from-astrocom]
|
||||
type=user
|
||||
username=from-astrocom
|
||||
secret=(short-secret-you-sent-to-astrocom)
|
||||
auth=md5
|
||||
encryption=yes
|
||||
context=from-astrocom
|
||||
```
|
||||
4. Put the following in `extensions_custom.conf`, *change 777 to your AstroCom exchange prefix*, and uncomment one or both blocks directly below
|
||||
```
|
||||
[from-internal-additional-custom]
|
||||
; Change 777 to your exchange prefix
|
||||
; Uncomment for 7 digit dialing to AstroCom
|
||||
;exten => _XXXXXXX,1,NoOp
|
||||
; same => n,Set(CALLERID(num)=777${CALLERID(num)})
|
||||
; same => n,Goto(astrocom-dial,${EXTEN},1)
|
||||
; same => n,Hangup
|
||||
|
||||
; Uncomment for 1-300-XXX-XXXX to AstroCom
|
||||
;exten => _1300XXXXXXX,1,NoOp
|
||||
; same => n,Set(CALLERID(num)=777${CALLERID(num)})
|
||||
; same => n,Goto(astrocom-dial,${EXTEN:4},1)
|
||||
; same => n,Hangup
|
||||
|
||||
[astrocom-dial]
|
||||
exten => _X!,1,Set(number=${EXTEN})
|
||||
same => n,Set(lookup=${CURL(https://api.astrocom.tel/api/v1/route/${astrocomkey}/${FILTER(0-9,${CALLERID(num)})}/${FILTER(0-9,${number})})})
|
||||
same => n,GotoIf($["${lookup}"=="local"]?local:long)
|
||||
same => n(local),Goto(astrocom-exchange,${number},1)
|
||||
same => n(long),Dial(${lookup})
|
||||
same => n,Hangup
|
||||
|
||||
[from-astrocom]
|
||||
exten => _X!,1,NoOp()
|
||||
same => n,Goto(astrocom-exchange,${EXTEN},1)
|
||||
same => n,Hangup
|
||||
|
||||
; Change 777 to your exchange prefix
|
||||
[astrocom-exchange]
|
||||
exten => _777XXXX,1,Goto(from-internal,${EXTEN:3},1)
|
||||
same => n,Hangup
|
||||
```
|
||||
|
||||
Literally picked straight from tandmx (will make our own docs eventually, but this'll get us going)
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
; =====================================================; GLOBALS REQUIRED (globals.conf or globals_custom.conf)
|
||||
; -------------------------------------------------------------
|
||||
; [globals]
|
||||
; BRIDGE1_APIKEY=<routes.apiKey for bridge 1>
|
||||
; BRIDGE1_EXCHANGE=777 ; NXX prefix — used to build the outbound ANI only,
|
||||
; ; NOT applied to inbound DIDs (full number passes through)
|
||||
; BRIDGE2_APIKEY=<routes.apiKey for bridge 2>
|
||||
; BRIDGE2_EXCHANGE=888
|
||||
; ; ...add one pair per bridge; AstroCom SIP Bridge -- Dialplan (extensions.conf)
|
||||
; /etc/asterisk/extensions.conf
|
||||
;
|
||||
; CALL FLOWS
|
||||
; -------------------------------------------------------------
|
||||
;
|
||||
; INBOUND (AstroCom -> downstream PBX via PJSIP)
|
||||
; -----------------------------------------------
|
||||
; AstroCom delivers the full 7-digit number, e.g. 7771234.
|
||||
; The full number is forwarded as-is to the downstream PBX as
|
||||
; the SIP Request-URI / To: user (DID). The PBX is responsible
|
||||
; for routing it internally however it sees fit.
|
||||
;
|
||||
; AstroCom --IAX2--> [bridgeN-from-astrocom]
|
||||
; |
|
||||
; Pass full DID (e.g. 7771234) to PBX
|
||||
; |
|
||||
; Dial PJSIP/7771234@bridgeN-pbx
|
||||
; |
|
||||
; v
|
||||
; Downstream PBX
|
||||
;
|
||||
; OUTBOUND (downstream PBX -> AstroCom via IAX2)
|
||||
; ------------------------------------------------
|
||||
; The PBX dials the full 7-digit AstroCom number.
|
||||
; The bridge prepends the local exchange so the ANI is also
|
||||
; 7 digits, then hits the AstroCom routing API:
|
||||
;
|
||||
; GET https://api.astrocom.tel/api/v1/route/<apiKey>/<ANI>/<number>
|
||||
;
|
||||
; The API returns one of:
|
||||
; "local"
|
||||
; --> same block; route to this bridge's own SIP PBX
|
||||
; "IAX2/<auth>:<secret>@<host>:<port>/<number>"
|
||||
; --> dial that URI verbatim
|
||||
; "<MSG_ROUTE_ADDRESS>/4xx"
|
||||
; --> rejected; play busy/congestion toward PBX
|
||||
;
|
||||
; Downstream PBX --SIP--> [bridgeN-from-pbx]
|
||||
; |
|
||||
; CURL api.astrocom.tel/api/v1/route/...
|
||||
; |
|
||||
; +----------+----------+
|
||||
; local IAX2 URI
|
||||
; | |
|
||||
; Dial PJSIP/XXXX@bridgeN-pbx Dial ${lookup}
|
||||
;
|
||||
; ISOLATION
|
||||
; -------------------------------------------------------------
|
||||
; * Every bridge has exactly two contexts; they share nothing.
|
||||
; * [default] silently drops anything with no context match.
|
||||
; * Inter-bridge calls route through AstroCom automatically --
|
||||
; no extra config needed here.
|
||||
;
|
||||
; GLOBALS REQUIRED (globals.conf or globals_custom.conf)
|
||||
; -------------------------------------------------------------
|
||||
; [globals]
|
||||
; BRIDGE1_APIKEY=<routes.apiKey for bridge 1>
|
||||
; BRIDGE1_EXCHANGE=777 ; NXX prefix (3 digits) for bridge 1
|
||||
; BRIDGE2_APIKEY=<routes.apiKey for bridge 2>
|
||||
; BRIDGE2_EXCHANGE=888
|
||||
; ; ...add one pair per bridge
|
||||
; ============================================================
|
||||
|
||||
[default]
|
||||
exten => _X!,1,Verbose(2,DROP: ${EXTEN} landed in [default] -- no matching context)
|
||||
same => n,Hangup(21)
|
||||
|
||||
; ============================================================
|
||||
; --- BRIDGE 1 -----------------------------------------------
|
||||
; ============================================================
|
||||
|
||||
; Inbound from AstroCom -> forward full DID to PBX
|
||||
; Context name == routes.auth == IAX2 section name in iax.conf
|
||||
[bridge1-from-astrocom]
|
||||
exten => _XXXXXXX,1,Verbose(2,BRIDGE1 inbound from AstroCom: ${CALLERID(all)} -> ${EXTEN})
|
||||
same => n,Dial(PJSIP/${EXTEN}@bridge1-pbx,30,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "CONGESTION"]?congestion)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "NOANSWER"]?noanswer)
|
||||
same => n,Hangup()
|
||||
same => n(busy),Busy(5)
|
||||
same => n(congestion),Congestion(5)
|
||||
same => n(noanswer),Hangup(19)
|
||||
|
||||
; Outbound from Bridge-1 PBX -> CURL AstroCom API -> IAX2
|
||||
[bridge1-from-pbx]
|
||||
exten => _XXXXXXX,1,Verbose(2,BRIDGE1 outbound from PBX: ${CALLERID(all)} -> ${EXTEN})
|
||||
; Build the 7-digit ANI: exchange prefix + bare extension from CallerID
|
||||
same => n,Set(ANI=${BRIDGE1_EXCHANGE}${FILTER(0-9,${CALLERID(num)})})
|
||||
; Ask AstroCom where to send this call
|
||||
same => n,Set(lookup=${CURL(https://api.astrocom.tel/api/v1/route/${BRIDGE1_APIKEY}/${ANI}/${EXTEN})})
|
||||
same => n,Verbose(2,BRIDGE1 AstroCom lookup: ${lookup})
|
||||
; "local" -- callee shares our block, route to our own SIP PBX
|
||||
same => n,GotoIf($["${lookup}" = "local"]?local)
|
||||
; Empty or error URL -- reject
|
||||
same => n,GotoIf($["${lookup}" = ""]?reject)
|
||||
; Valid IAX2 URI -- dial it verbatim
|
||||
same => n,Dial(${lookup},30,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "CONGESTION"]?congestion)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "NOANSWER"]?noanswer)
|
||||
same => n,Hangup()
|
||||
; Local path -- strip exchange prefix, dial extension on own PBX
|
||||
same => n(local),Verbose(2,BRIDGE1 local call: routing ${EXTEN} to own PBX)
|
||||
same => n,Dial(PJSIP/${EXTEN}@bridge1-pbx,30,t)
|
||||
same => n,Hangup()
|
||||
; Error paths
|
||||
same => n(reject),Verbose(2,BRIDGE1 AstroCom rejected call: ${lookup})
|
||||
same => n,Congestion(5)
|
||||
same => n(busy),Busy(5)
|
||||
same => n(congestion),Congestion(5)
|
||||
same => n(noanswer),Hangup(19)
|
||||
|
||||
; ============================================================
|
||||
; --- BRIDGE 3 (copy & uncomment for each additional bridge) -
|
||||
; ============================================================
|
||||
; Also add BRIDGE3_APIKEY and BRIDGE3_EXCHANGE to globals.conf.
|
||||
;
|
||||
;[bridge3-from-astrocom]
|
||||
;exten => _X!,1,Verbose(2,BRIDGE3 inbound: ${CALLERID(all)} -> ${EXTEN})
|
||||
; same => n,Dial(PJSIP/${EXTEN}@bridge3-pbx,30,t)
|
||||
; same => n,Hangup()
|
||||
;
|
||||
;[bridge3-from-pbx]
|
||||
;exten => _X!,1,Verbose(2,BRIDGE3 outbound: ${CALLERID(all)} -> ${EXTEN})
|
||||
; same => n,Set(ANI=${BRIDGE3_EXCHANGE}${FILTER(0-9,${CALLERID(num)})})
|
||||
; same => n,Set(lookup=${CURL(https://api.astrocom.tel/api/v1/route/${BRIDGE3_APIKEY}/${ANI}/${EXTEN})})
|
||||
; same => n,GotoIf($["${lookup}" = "local"]?local)
|
||||
; same => n,GotoIf($["${lookup}" = ""]?reject)
|
||||
; same => n,Dial(${lookup},30,t)
|
||||
; same => n,Hangup()
|
||||
; same => n(local),Dial(PJSIP/${EXTEN}@bridge3-pbx,30,t)
|
||||
; same => n,Hangup()
|
||||
; same => n(reject),Congestion(5)
|
||||
|
||||
; ============================================================
|
||||
; --- OPTIONAL: Direct SIP inter-bridge shortcut -------------
|
||||
;
|
||||
; Bridges can already call each other through AstroCom with
|
||||
; zero extra config. If you want direct SIP routing between
|
||||
; bridges (no AstroCom hop), add a higher-priority pattern for
|
||||
; the other bridge's block BEFORE the _X! catch-all.
|
||||
;
|
||||
; Example -- Bridge-1 calling Bridge-2's 888XXXX block directly:
|
||||
;
|
||||
;[bridge1-from-pbx]
|
||||
;exten => _888XXXX,1,Verbose(2,BRIDGE1->BRIDGE2 direct: ${EXTEN})
|
||||
; same => n,Dial(PJSIP/${EXTEN}@bridge2-pbx,30,t)
|
||||
; same => n,Hangup()
|
||||
;
|
||||
; (The _X! rule below then handles all other numbers normally.)
|
||||
; ============================================================
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
; ============================================================
|
||||
; AstroCom SIP Bridge — Dialplan (extensions.conf)
|
||||
; /etc/asterisk/extensions.conf
|
||||
;
|
||||
; CALL FLOW
|
||||
; ─────────────────────────────────────────────────────────────
|
||||
;
|
||||
; INBOUND from AstroCom (IAX2 → SIP toward downstream PBX):
|
||||
;
|
||||
; AstroCom ──IAX2──▶ [bridgeN-from-astrocom]
|
||||
; │
|
||||
; Bridge to SIP peer
|
||||
; │
|
||||
; SIP/bridgeN-pbx/<EXTEN>
|
||||
; │
|
||||
; ▼
|
||||
; Downstream PBX / phone
|
||||
;
|
||||
; OUTBOUND from downstream PBX (SIP → IAX2 toward AstroCom):
|
||||
;
|
||||
; Downstream PBX ──SIP──▶ [bridgeN-from-pbx]
|
||||
; │
|
||||
; Bridge to IAX2 peer
|
||||
; │
|
||||
; IAX2/bridge1-astrocom/<EXTEN>
|
||||
; │
|
||||
; ▼
|
||||
; AstroCom
|
||||
;
|
||||
; ISOLATION
|
||||
; ─────────────────────────────────────────────────────────────
|
||||
; • Each bridge uses its own pair of contexts
|
||||
; (bridgeN-from-astrocom / bridgeN-from-pbx).
|
||||
; • No cross-bridge dialplan references exist UNLESS you
|
||||
; explicitly uncomment the inter-bridge section at the
|
||||
; bottom. Until then each bridge is a closed island.
|
||||
; • The [default] context is intentionally empty / drops
|
||||
; calls to prevent anything landing there from escaping.
|
||||
; ============================================================
|
||||
|
||||
; ============================================================
|
||||
; Safety net — calls that arrive in no recognised context are
|
||||
; silently dropped.
|
||||
; ============================================================
|
||||
[default]
|
||||
exten => _X.,1,Verbose(2,DROP: call to ${EXTEN} landed in [default] — no context matched)
|
||||
same => n,Hangup(21)
|
||||
|
||||
; ============================================================
|
||||
; ─── BRIDGE 1 ───────────────────────────────────────────────
|
||||
; ============================================================
|
||||
|
||||
; ------ Inbound from AstroCom → forward to Bridge-1 PBX ----
|
||||
; This context name MUST equal routes.auth for this bridge.
|
||||
[bridge1-from-astrocom]
|
||||
; Accept any extension that AstroCom sends for this block.
|
||||
exten => _X.,1,Verbose(2,BRIDGE1 inbound from AstroCom: ${CALLERID(all)} → ${EXTEN})
|
||||
same => n,Set(CALLERID(name)=${CALLERID(name)})
|
||||
same => n,Dial(SIP/bridge1-pbx/${EXTEN},30,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "CONGESTION"]?congestion)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "NOANSWER"]?noanswer)
|
||||
same => n,Hangup()
|
||||
|
||||
same => n(congestion),Congestion(5)
|
||||
same => n(busy),Busy(5)
|
||||
same => n(noanswer),Hangup(19)
|
||||
|
||||
; ------ Inbound from Bridge-1 PBX → forward to AstroCom ----
|
||||
[bridge1-from-pbx]
|
||||
exten => _X.,1,Verbose(2,BRIDGE1 outbound from PBX: ${CALLERID(all)} → ${EXTEN})
|
||||
; Strip any leading '9' access code the PBX may prepend, e.g. "9XXXXXXX"
|
||||
; Uncomment/adapt if needed:
|
||||
;same => n,Set(EXTEN=${EXTEN:1})
|
||||
same => n,Set(CALLERID(name)=${CALLERID(name)})
|
||||
same => n,Dial(IAX2/bridge1-astrocom/${EXTEN},30,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "CONGESTION"]?congestion)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "NOANSWER"]?noanswer)
|
||||
same => n,Hangup()
|
||||
|
||||
same => n(congestion),Congestion(5)
|
||||
same => n(busy),Busy(5)
|
||||
same => n(noanswer),Hangup(19)
|
||||
|
||||
; ============================================================
|
||||
; ─── BRIDGE 2 ───────────────────────────────────────────────
|
||||
; ============================================================
|
||||
|
||||
[bridge2-from-astrocom]
|
||||
exten => _X.,1,Verbose(2,BRIDGE2 inbound from AstroCom: ${CALLERID(all)} → ${EXTEN})
|
||||
same => n,Set(CALLERID(name)=${CALLERID(name)})
|
||||
same => n,Dial(SIP/bridge2-pbx/${EXTEN},30,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "CONGESTION"]?congestion)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "NOANSWER"]?noanswer)
|
||||
same => n,Hangup()
|
||||
|
||||
same => n(congestion),Congestion(5)
|
||||
same => n(busy),Busy(5)
|
||||
same => n(noanswer),Hangup(19)
|
||||
|
||||
[bridge2-from-pbx]
|
||||
exten => _X.,1,Verbose(2,BRIDGE2 outbound from PBX: ${CALLERID(all)} → ${EXTEN})
|
||||
same => n,Set(CALLERID(name)=${CALLERID(name)})
|
||||
same => n,Dial(IAX2/bridge2-astrocom/${EXTEN},30,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "CONGESTION"]?congestion)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "NOANSWER"]?noanswer)
|
||||
same => n,Hangup()
|
||||
|
||||
same => n(congestion),Congestion(5)
|
||||
same => n(busy),Busy(5)
|
||||
same => n(noanswer),Hangup(19)
|
||||
|
||||
; ============================================================
|
||||
; ─── BRIDGE 3 (template — duplicate & uncomment) ────────────
|
||||
; ============================================================
|
||||
;[bridge3-from-astrocom]
|
||||
;exten => _X.,1,Verbose(2,BRIDGE3 inbound from AstroCom: ${CALLERID(all)} → ${EXTEN})
|
||||
; same => n,Dial(SIP/bridge3-pbx/${EXTEN},30,t)
|
||||
; same => n,Hangup()
|
||||
;
|
||||
;[bridge3-from-pbx]
|
||||
;exten => _X.,1,Verbose(2,BRIDGE3 outbound from PBX: ${CALLERID(all)} → ${EXTEN})
|
||||
; same => n,Dial(IAX2/bridge3-astrocom/${EXTEN},30,t)
|
||||
; same => n,Hangup()
|
||||
|
||||
; ============================================================
|
||||
; ─── OPTIONAL: Inter-bridge dialling ────────────────────────
|
||||
;
|
||||
; If you want Bridge-1's PBX to be able to call Bridge-2's
|
||||
; numbers *directly* (without round-tripping through AstroCom)
|
||||
; you can include the other bridge's inbound SIP context here.
|
||||
;
|
||||
; Example: in [bridge1-from-pbx], add a pattern for Bridge-2's
|
||||
; number block *before* the catch-all _X. rule so it is
|
||||
; preferred. Bridge-2's block in AstroCom is consulted to
|
||||
; determine the number range.
|
||||
;
|
||||
;[bridge1-from-pbx]
|
||||
;exten => _7XXXX,1,Verbose(2,BRIDGE1→BRIDGE2 direct: ${EXTEN})
|
||||
; same => n,Dial(SIP/bridge2-pbx/${EXTEN},30,t)
|
||||
; same => n,Hangup()
|
||||
;
|
||||
; Without uncommenting the above, bridge1 and bridge2 can still
|
||||
; reach each other — calls just travel via AstroCom as normal.
|
||||
; ============================================================
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
; ============================================================
|
||||
; AstroCom SIP Bridge — IAX2 Configuration (chan_iax2)
|
||||
; /etc/asterisk/iax.conf
|
||||
;
|
||||
; HOW ASTROCOM CALLS THIS BRIDGE
|
||||
; ──────────────────────────────
|
||||
; When AstroCom routes a call here it dials:
|
||||
; IAX2/<routes.auth>:<routes.secret>@<routes.server>:<routes.port>/<number>
|
||||
;
|
||||
; That means:
|
||||
; • The SECTION NAME (in brackets) IS the IAX2 username AstroCom
|
||||
; authenticates with. It must equal routes.auth exactly.
|
||||
; • 'secret' must equal routes.secret exactly.
|
||||
; • 'context' is where the call lands in extensions.conf.
|
||||
; It must also equal routes.auth (the dialplan context is
|
||||
; named identically for clarity).
|
||||
; • type=user — AstroCom calls us; we never initiate an IAX2
|
||||
; connection back to AstroCom. Outbound calls use a
|
||||
; dynamically-generated IAX2 URI from the routing API
|
||||
; (handled in extensions.conf) and need no peer config here.
|
||||
;
|
||||
; NAT NOTE
|
||||
; ────────
|
||||
; Do NOT use 'register =>'. If this server has a dynamic IP,
|
||||
; use tools/astrocom_dynamic_ip.sh (or the /api/v1/user/update
|
||||
; endpoint) to keep routes.server current in AstroCom.
|
||||
; ============================================================
|
||||
|
||||
[general]
|
||||
bindport=4569
|
||||
bindaddr=0.0.0.0
|
||||
iaxcompat=yes
|
||||
authdebug=no
|
||||
bandwidth=high
|
||||
jitterbuffer=yes
|
||||
forcejitterbuffer=yes
|
||||
dropcount=2
|
||||
maxjitterbuffer=1000
|
||||
jittertargetextra=40
|
||||
|
||||
; ============================================================
|
||||
; Shared user template
|
||||
; ============================================================
|
||||
[bridge-iax-tmpl](!)
|
||||
type=user
|
||||
auth=md5
|
||||
encryption=yes
|
||||
disallow=all
|
||||
allow=ulaw
|
||||
allow=alaw
|
||||
allow=g729
|
||||
qualify=yes
|
||||
|
||||
; ============================================================
|
||||
; BRIDGE 1
|
||||
; routes.auth = bridge1-from-astrocom ← section name
|
||||
; routes.secret = BRIDGE1_IAX_SECRET
|
||||
; routes.server = <this server's public IP>
|
||||
; routes.port = 4569
|
||||
; ============================================================
|
||||
[bridge1-from-astrocom](bridge-iax-tmpl)
|
||||
secret=20fbb81ba96091af1fe45858a070c7 ; Must equal routes.secret in AstroCom
|
||||
context=bridge1-from-astrocom ; Must equal routes.auth in AstroCom
|
||||
|
||||
|
||||
; ============================================================
|
||||
; BRIDGE 3 (copy this block for every additional bridge)
|
||||
; ============================================================
|
||||
;[bridge3-from-astrocom](bridge-iax-tmpl)
|
||||
;secret=BRIDGE3_IAX_SECRET
|
||||
;context=bridge3-from-astrocom
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
; ============================================================
|
||||
; AstroCom SIP Bridge — PJSIP Configuration (chan_pjsip)
|
||||
; /etc/asterisk/pjsip.conf
|
||||
;
|
||||
; Downstream PBXes REGISTER to this server.
|
||||
; Each bridge requires three stanzas sharing the same base name:
|
||||
;
|
||||
; [bridgeN-pbx] type=endpoint — codecs, context, links to auth/aor
|
||||
; [bridgeN-pbx] type=aor — stores the registered contact URI
|
||||
; [bridgeN-pbx-auth] type=auth — username / password the PBX uses
|
||||
;
|
||||
; The PBX must be configured to register with:
|
||||
; Registrar : sip:<this server's IP>:5060
|
||||
; Username : bridgeN-pbx (must match auth.username below)
|
||||
; Password : the 'password' value in [bridgeN-pbx-auth]
|
||||
;
|
||||
; Outbound calls to this PBX from the dialplan:
|
||||
; Dial(PJSIP/${EXTEN}@bridgeN-pbx)
|
||||
; Asterisk substitutes the registered contact URI automatically.
|
||||
;
|
||||
; Disable/remove sip.conf (or set chan_sip bindport=0) so both
|
||||
; modules do not compete for port 5060.
|
||||
; ============================================================
|
||||
|
||||
; ============================================================
|
||||
; Transport — one UDP listener for all bridges
|
||||
; ============================================================
|
||||
[bridge-transport]
|
||||
type=transport
|
||||
protocol=udp
|
||||
bind=0.0.0.0:5060
|
||||
; For NAT — uncomment and fill in:
|
||||
;local_net=192.168.0.0/16
|
||||
;external_signaling_address=PUBLIC_IP
|
||||
;external_media_address=PUBLIC_IP
|
||||
|
||||
; ============================================================
|
||||
; Shared endpoint template
|
||||
; ============================================================
|
||||
[bridge-endpoint-tmpl](!)
|
||||
type=endpoint
|
||||
transport=bridge-transport
|
||||
disallow=all
|
||||
allow=ulaw
|
||||
allow=alaw
|
||||
allow=g729
|
||||
dtmf_mode=rfc4733
|
||||
; Force media through this server — required if either side is behind NAT
|
||||
direct_media=no
|
||||
|
||||
; ============================================================
|
||||
; BRIDGE 1
|
||||
; Auth username : bridge1-pbx
|
||||
; Auth password : (set below — must match PBX registration password)
|
||||
; Inbound calls from this PBX land in [bridge1-from-pbx] (extensions.conf)
|
||||
; ============================================================
|
||||
[bridge1-pbx](bridge-endpoint-tmpl)
|
||||
type=endpoint
|
||||
auth=bridge1-pbx-auth
|
||||
aors=bridge1-pbx
|
||||
context=bridge1-from-pbx
|
||||
|
||||
[bridge1-pbx-auth]
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
username=bridge1-pbx
|
||||
password=1242ebce8b420ee4723887ffe0a01211330851776862e6021f680f1406c94eff
|
||||
|
||||
[bridge1-pbx]
|
||||
type=aor
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
|
||||
; ============================================================
|
||||
; BRIDGE 2
|
||||
; ============================================================
|
||||
[bridge2-pbx](bridge-endpoint-tmpl)
|
||||
type=endpoint
|
||||
auth=bridge2-pbx-auth
|
||||
aors=bridge2-pbx
|
||||
context=bridge2-from-pbx
|
||||
|
||||
[bridge2-pbx-auth]
|
||||
type=auth
|
||||
auth_type=userpass
|
||||
username=bridge2-pbx
|
||||
password=BRIDGE2_SIP_SECRET
|
||||
|
||||
[bridge2-pbx]
|
||||
type=aor
|
||||
max_contacts=1
|
||||
remove_existing=yes
|
||||
|
||||
; ============================================================
|
||||
; Additional bridges — copy this triple and increment N
|
||||
; ============================================================
|
||||
;[bridge3-pbx](bridge-endpoint-tmpl)
|
||||
;type=endpoint
|
||||
;auth=bridge3-pbx-auth
|
||||
;aors=bridge3-pbx
|
||||
;context=bridge3-from-pbx
|
||||
;
|
||||
;[bridge3-pbx-auth]
|
||||
;type=auth
|
||||
;auth_type=userpass
|
||||
;username=bridge3-pbx
|
||||
;password=BRIDGE3_SIP_SECRET
|
||||
;
|
||||
;[bridge3-pbx]
|
||||
;type=aor
|
||||
;max_contacts=1
|
||||
;remove_existing=yes
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
; ============================================================
|
||||
; AstroCom SIP Bridge — SIP Configuration (chan_sip)
|
||||
; /etc/asterisk/sip.conf
|
||||
;
|
||||
; Downstream PBXes REGISTER to this server.
|
||||
; Each bridge has one [bridgeN-pbx] peer with host=dynamic.
|
||||
;
|
||||
; The PBX must be configured to register with:
|
||||
; Registrar : <this server's IP>:5060
|
||||
; Username : bridgeN-pbx (the section name below)
|
||||
; Password : the 'secret' value below
|
||||
;
|
||||
; 'context' is where calls FROM the PBX land (outbound to AstroCom).
|
||||
; It must match [bridgeN-from-pbx] in extensions.conf.
|
||||
; ============================================================
|
||||
|
||||
[general]
|
||||
bindport=5060
|
||||
bindaddr=0.0.0.0
|
||||
realm=ac-sip-bridge
|
||||
srvlookup=no
|
||||
; NAT handling — set to 'force_rport,comedia' if PBX is behind NAT
|
||||
nat=no
|
||||
qualify=no
|
||||
disallow=all
|
||||
allow=ulaw
|
||||
allow=alaw
|
||||
allow=g729
|
||||
|
||||
; ============================================================
|
||||
; Shared peer template
|
||||
; ============================================================
|
||||
[bridge-sip-tmpl](!)
|
||||
type=friend
|
||||
host=dynamic ; IP is learned from the REGISTER request
|
||||
dtmfmode=rfc2833
|
||||
canreinvite=no
|
||||
disallow=all
|
||||
allow=ulaw
|
||||
allow=alaw
|
||||
allow=g729
|
||||
qualify=yes
|
||||
; No 'insecure=' — registration provides proper authentication.
|
||||
; Removing it means unauthenticated INVITEs from unknown IPs are rejected.
|
||||
|
||||
; ============================================================
|
||||
; BRIDGE 1
|
||||
; PBX registers as username 'bridge1-pbx' with the secret below.
|
||||
; Outbound to this PBX: Dial(SIP/bridge1-pbx/<exten>)
|
||||
; → delivered to the registered contact URI.
|
||||
; ============================================================
|
||||
[bridge1-pbx](bridge-sip-tmpl)
|
||||
secret=1242ebce8b420ee4723887ffe0a01211330851776862e6021f680f1406c94eff
|
||||
; If the PBX must register with a different username, set it here:
|
||||
;username=bridge1
|
||||
fromuser=bridge1-pbx ; identity used in outbound SIP From: headers
|
||||
context=bridge1-from-pbx
|
||||
|
||||
; ============================================================
|
||||
; BRIDGE 2
|
||||
; PBX registers as username 'bridge2-pbx' with the secret below.
|
||||
; ============================================================
|
||||
[bridge2-pbx](bridge-sip-tmpl)
|
||||
secret=BRIDGE2_SIP_SECRET
|
||||
fromuser=bridge2-pbx
|
||||
context=bridge2-from-pbx
|
||||
|
||||
; ============================================================
|
||||
; Additional bridges — copy this block and increment N
|
||||
; ============================================================
|
||||
;[bridge3-pbx](bridge-sip-tmpl)
|
||||
;secret=BRIDGE3_SIP_SECRET
|
||||
;fromuser=bridge3-pbx
|
||||
;context=bridge3-from-pbx
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
# AstroCom SIP Bridge — Design Notes
|
||||
|
||||
This directory contains a set of Asterisk configuration files that act as a
|
||||
**protocol bridge** between IAX2 (used by AstroCom) and SIP (used by
|
||||
downstream, non-Asterisk phone systems such as FreeSWITCH, 3CX, Cisco
|
||||
UCM, or any generic SIP PBX).
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `iax.conf` | IAX2 peers — one per bridge, pointing at AstroCom |
|
||||
| `pjsip.conf` | PJSIP endpoints — one triple (endpoint/auth/aor) per bridge |
|
||||
| `extensions.conf` | Dialplan — contexts that wire IAX↔PJSIP for each bridge |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ AstroCom Platform │
|
||||
│ (IAX2 server, routes DB) │
|
||||
└──────────┬──────────────────────-┘
|
||||
│ IAX2 (port 4569)
|
||||
┌───────────────┼────────────────┐
|
||||
│ │ │
|
||||
IAX2 peer IAX2 peer IAX2 peer
|
||||
bridge1-astrocom bridge2-astrocom bridgeN-astrocom
|
||||
│ │ │
|
||||
┌───────┴───────────────┴────────────────┴──────┐
|
||||
│ Bridge Server │
|
||||
│ (this Asterisk instance) │
|
||||
│ │
|
||||
│ [bridge1-from-astrocom] [bridge1-from-pbx] │
|
||||
│ [bridge2-from-astrocom] [bridge2-from-pbx] │
|
||||
│ [bridgeN-from-astrocom] [bridgeN-from-pbx] │
|
||||
└───────┬───────────────┬────────────────┬──────┘
|
||||
│ │ │
|
||||
SIP peer SIP peer SIP peer
|
||||
bridge1-pbx bridge2-pbx bridgeN-pbx
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
PBX / System 1 PBX / System 2 PBX / System N
|
||||
```
|
||||
|
||||
### Inbound call (AstroCom → PBX)
|
||||
|
||||
1. AstroCom looks up the dialled number in its `routes` table and finds
|
||||
the bridge server's IP (`routes.server`) and port (`routes.port`).
|
||||
2. AstroCom dials over IAX2 using the shared secret (`routes.secret`)
|
||||
and delivers the call to the context named in `routes.auth`
|
||||
(e.g. `bridge1-from-astrocom`).
|
||||
3. The bridge's dialplan receives the call in `[bridge1-from-astrocom]`
|
||||
and dials `SIP/bridge1-pbx/<EXTEN>`. Because the PBX has already
|
||||
registered (`host=dynamic`), Asterisk knows the current contact URI
|
||||
and sends the INVITE directly to it.
|
||||
|
||||
### Outbound call (PBX → AstroCom)
|
||||
|
||||
1. The downstream PBX has registered to this server as `bridge1-pbx`.
|
||||
It sends a SIP INVITE for the target number.
|
||||
2. `sip.conf` authenticates the REGISTER/INVITE against `secret` and
|
||||
lands the call in context `bridge1-from-pbx`.
|
||||
3. The dialplan CURLs the AstroCom routing API:
|
||||
`GET /api/v1/route/<apiKey>/<ANI>/<number>`
|
||||
4. The API returns either `"local"` (callee is on the same PBX — route
|
||||
back via `SIP/bridge1-pbx/<exten>`) or a full
|
||||
`IAX2/<auth>:<secret>@<host>:<port>/<number>` URI which is dialled
|
||||
verbatim.
|
||||
|
||||
---
|
||||
|
||||
## AstroCom Database Requirements
|
||||
|
||||
For each bridge you must create one row in the `routes` table:
|
||||
|
||||
| Column | Value |
|
||||
|--------|-------|
|
||||
| `server` | Bridge server's public IP |
|
||||
| `port` | `4569` (IAX2) |
|
||||
| `auth` | `bridgeN-from-astrocom` (must match context name) |
|
||||
| `secret` | The shared IAX2 secret (`BRIDGEN_IAX_SECRET`) |
|
||||
| `block_start` | First number in this bridge's number block |
|
||||
| `block_length` | Size of the block (default 9999) |
|
||||
| `apiKey` | Auto-generated by AstroCom on creation |
|
||||
|
||||
---
|
||||
|
||||
## Isolation Model
|
||||
|
||||
Each bridge is **completely isolated** by default:
|
||||
|
||||
- IAX2 peers use separate contexts (`bridge1-from-astrocom`, etc.)
|
||||
- PJSIP endpoints use separate contexts (`bridge1-from-pbx`, etc.)
|
||||
- No context includes or jumps to another bridge's context
|
||||
- The `[default]` context drops all unmatched calls
|
||||
|
||||
### Calling between bridges
|
||||
|
||||
Calls between Bridge-1 and Bridge-2 work through AstroCom's normal
|
||||
routing — Bridge-1's PBX dials a number, it travels to AstroCom via
|
||||
IAX2, AstroCom routes it to Bridge-2's context, and it arrives at
|
||||
Bridge-2's PBX via PJSIP. **No special config is needed for this.**
|
||||
|
||||
If you want **direct PJSIP** bridging (bypassing AstroCom for inter-bridge
|
||||
calls), see the commented-out section at the bottom of `extensions.conf`.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Bridge
|
||||
|
||||
1. **`iax.conf`** — Copy the `[bridge1-from-astrocom]` block, rename it to
|
||||
`[bridgeN-from-astrocom]`, and set a new `secret` and `context`.
|
||||
|
||||
2. **`pjsip.conf`** — Copy the three stanzas for `bridge1-pbx`, rename them
|
||||
to `bridgeN-pbx` / `bridgeN-pbx-auth`, set a new `password`, and update
|
||||
`context`. The downstream PBX must be configured to register with:
|
||||
- **Registrar**: `sip:<bridge server IP>:5060`
|
||||
- **Username**: `bridgeN-pbx`
|
||||
- **Password**: the `password` value in `[bridgeN-pbx-auth]`
|
||||
|
||||
3. **`extensions.conf`** — Copy both the `[bridge1-from-astrocom]` and
|
||||
`[bridge1-from-pbx]` context blocks, rename them to
|
||||
`[bridgeN-from-astrocom]` / `[bridgeN-from-pbx]`, and update the
|
||||
`Dial()` targets and global variable names.
|
||||
Add `BRIDGEN_APIKEY` and `BRIDGEN_EXCHANGE` to `globals.conf`.
|
||||
|
||||
4. **AstroCom** — Create the route via the admin panel or API with
|
||||
`auth = bridgeN-from-astrocom` and the matching `secret`.
|
||||
|
||||
5. Reload Asterisk:
|
||||
```
|
||||
asterisk -rx "pjsip reload"
|
||||
asterisk -rx "iax2 reload"
|
||||
asterisk -rx "dialplan reload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Codecs**: `ulaw`, `alaw`, and `g729` are negotiated; adjust per your
|
||||
network and licensing constraints.
|
||||
- **NAT**: If a downstream PBX is behind NAT, set `direct_media=no` (already
|
||||
in the template) and add `local_net` / `external_signaling_address` /
|
||||
`external_media_address` to `[bridge-transport]` in `pjsip.conf`.
|
||||
- **No `register =>`** for IAX2: IP updates use `tools/astrocom_dynamic_ip.sh`
|
||||
or the `/api/v1/user/update` API endpoint — not IAX2 registration.
|
||||
- **Security**: Each bridge's IAX2 secret should be a long random string
|
||||
(AstroCom generates one via `crypto.randomBytes(15).toString('hex')`
|
||||
if none is specified during route creation).
|
||||
- **`sip.conf`**: The old `chan_sip` config is superseded by `pjsip.conf`.
|
||||
Ensure `chan_sip` is not loaded (`noload => chan_sip.so` in `modules.conf`)
|
||||
to avoid both modules fighting over port 5060.
|
||||
536
index.js
536
index.js
|
|
@ -20,24 +20,8 @@ const ejs = require("ejs")
|
|||
const mariadb = require('mariadb');
|
||||
const bcrypt = require("bcrypt")
|
||||
const crypto = require("crypto")
|
||||
const dns = require("dns");
|
||||
const app = express();
|
||||
const port = process.env.SERVER_PORT || 3000;
|
||||
const iaxping = require("iaxping");
|
||||
|
||||
const invalidBlocks = [
|
||||
// Emergency number prefixes (112, 911, 999, 110, 117, 119, 113, 191, 111)
|
||||
1120000, // UK, EU, etc
|
||||
9110000, // US, Canada
|
||||
9880000, // Suicide prevention (US)
|
||||
9990000,
|
||||
1100000,
|
||||
1170000,
|
||||
1190000,
|
||||
1130000,
|
||||
1910000,
|
||||
1110000
|
||||
]
|
||||
|
||||
const pool = mariadb.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
|
|
@ -56,7 +40,7 @@ pool.getConnection().then((conn) => {
|
|||
// delete all users (The big scary one lol)
|
||||
conn.query("DELETE FROM users").then(() => {
|
||||
// Generate 32 char random string
|
||||
const passwd = process.env.SET_ADMIN_PASS || crypto.randomBytes(32).toString('hex');
|
||||
const passwd = crypto.randomBytes(32).toString('hex');
|
||||
bcrypt.hash(passwd, 10).then((hash) => {
|
||||
conn.query("INSERT INTO users (id, username, passwordHash) VALUES (1, 'admin', ?)",
|
||||
[hash]).then(() => {
|
||||
|
|
@ -235,78 +219,6 @@ app.post('/admin/login', (req, res) => {
|
|||
});
|
||||
})
|
||||
|
||||
app.get("/admin/register/:inviteCode", async (req, res) => {
|
||||
const inviteCode = req.params.inviteCode;
|
||||
if (!inviteCode) {
|
||||
res.status(400).send('Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
const inviteData = await pool.query("SELECT * FROM admin_invites WHERE code = ?", [inviteCode]);
|
||||
if (!inviteData || inviteData.length === 0) {
|
||||
res.status(400).send('Bad or Expired Invite Code');
|
||||
return;
|
||||
}
|
||||
const invite = inviteData[0];
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
||||
res.status(400).send('Invite Code Expired');
|
||||
return;
|
||||
}
|
||||
if (invite.uses >= invite.maxUses) {
|
||||
res.status(400).send('Invite Code Max Uses Reached');
|
||||
return;
|
||||
}
|
||||
res.render('admin/register', { inviteCode });
|
||||
});
|
||||
|
||||
app.post("/admin/register/:inviteCode", async (req, res) => {
|
||||
const inviteCode = req.params.inviteCode;
|
||||
if (!inviteCode) {
|
||||
res.render('admin/register', { error: 'Bad Request' });
|
||||
return;
|
||||
}
|
||||
const inviteData = await pool.query("SELECT * FROM admin_invites WHERE code = ?", [inviteCode]);
|
||||
if (!inviteData || inviteData.length === 0) {
|
||||
res.render('admin/register', { error: 'Bad or Expired Invite Code' });
|
||||
return;
|
||||
}
|
||||
const invite = inviteData[0];
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
|
||||
res.render('admin/register', { error: 'Invite Code Expired' });
|
||||
return;
|
||||
}
|
||||
if (invite.uses >= invite.maxUses) {
|
||||
res.render('admin/register', { error: 'Invite Code Max Uses Reached' });
|
||||
return;
|
||||
}
|
||||
const username = req.body.username;
|
||||
const password = req.body.password;
|
||||
if (!username || !password) {
|
||||
res.render('admin/register', { error: 'Username and Password are required', inviteCode });
|
||||
return;
|
||||
}
|
||||
const existingUser = await pool.query("SELECT * FROM users WHERE username = ?", [String(username)]);
|
||||
if (existingUser && existingUser.length > 0) {
|
||||
res.render('admin/register', { error: 'Username already exists', inviteCode });
|
||||
return;
|
||||
}
|
||||
bcrypt.hash(password, saltRounds).then((hash) => {
|
||||
const newUser = pool.query("INSERT INTO users (username, passwordHash) VALUES (?, ?)",
|
||||
[String(username), hash]);
|
||||
const updateInvite = pool.query("UPDATE admin_invites SET uses = uses + 1 WHERE code = ?", [inviteCode]);
|
||||
Promise.all([newUser, updateInvite]).then(() => {
|
||||
res.redirect('/admin/login');
|
||||
}).catch(err => {
|
||||
console.error('Error creating user:', err);
|
||||
res.render('admin/register', { error: 'Internal server error', inviteCode });
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Error hashing password:', err);
|
||||
res.render('admin/register', { error: 'Internal server error', inviteCode });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
app.get('/api/v1/admin/routes', (req, res) => { // Get all routes
|
||||
if (!req.session.adminAuthenticated) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
|
|
@ -469,7 +381,7 @@ app.delete('/api/v1/admin/directory/:number', (req, res) => { // Delete a direct
|
|||
});
|
||||
|
||||
app.get("/api/v1/admin/callLogs", (req, res) => {
|
||||
if (!req.session.adminAuthenticated && (!req.headers['authorization'] || req.headers['authorization'] !== `${process.env.ADMIN_API_KEY}`) && (!req.query.apiKey || req.query.apiKey !== `${process.env.ADMIN_API_KEY}`)) {
|
||||
if (!req.session.adminAuthenticated) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -479,29 +391,9 @@ app.get("/api/v1/admin/callLogs", (req, res) => {
|
|||
// Get full count of call logs to calculate total pages
|
||||
pool.getConnection().then(conn => {
|
||||
conn.query("SELECT COUNT(*) as count FROM callLogs").then((rows) => {
|
||||
const totalPages = Math.ceil(Number(rows[0].count) / 100);
|
||||
const totalPages = Math.ceil(rows[0].count / 100);
|
||||
conn.query("SELECT * FROM callLogs ORDER BY timestamp DESC LIMIT 100 OFFSET ?", [offset]).then((rows) => {
|
||||
// Turn all values in rows to strings, prevents type issues with bigints.
|
||||
rows = rows.map(row => {
|
||||
const newRow = {};
|
||||
for (const key in row) {
|
||||
// Convert BigInt explicitly to avoid "Cannot mix BigInt and other types" errors,
|
||||
// handle Dates and null/undefined safely, otherwise coerce to string.
|
||||
switch (key) {
|
||||
case 'timestamp':
|
||||
newRow[key] = new Date(Number(row[key])).toISOString();
|
||||
break;
|
||||
case 'success':
|
||||
newRow[key] = Boolean(row[key]);
|
||||
break;
|
||||
default:
|
||||
newRow[key] = new String(row[key]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return newRow;
|
||||
});
|
||||
res.json({ totalPages, page, current_time: new Date().toISOString(), data: rows });
|
||||
res.json({ totalPages, page, data: rows });
|
||||
}).catch(err => {
|
||||
console.error('Error getting call logs:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
|
@ -611,32 +503,6 @@ app.put('/api/v1/user/route', (req, res) => { // Update route
|
|||
});
|
||||
});
|
||||
|
||||
app.patch('/api/v1/user/update', async (req, res) => { // Update users server, port, auth, or secret via API key instead of session. Used for automated scripts
|
||||
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'API Key is required!' });
|
||||
return;
|
||||
}
|
||||
const oldData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
|
||||
if (!oldData || oldData.length === 0) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const row = oldData[0];
|
||||
const server = req.body.server || row.server;
|
||||
const port = req.body.port || row.port;
|
||||
const auth = req.body.auth || row.auth;
|
||||
const secret = req.body.secret || row.secret;
|
||||
|
||||
const updateData = await pool.query('UPDATE routes SET server = ?, port = ?, auth = ?, secret = ? WHERE apiKey = ?',
|
||||
[server, port, auth, secret, apiKey]);
|
||||
if (updateData.affectedRows === 1) {
|
||||
res.json({ message: 'Updated' });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/v1/user/directory', (req, res) => { // Get directory entries created by user
|
||||
if (!req.session.userAuthenticated) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
|
|
@ -729,153 +595,6 @@ app.delete('/api/v1/user/directory/:number', (req, res) => { // Delete a directo
|
|||
});
|
||||
});
|
||||
|
||||
// User directory management via API key, for automated scripts
|
||||
app.post('/api/v1/user/dir/newEntry', async (req, res) => {
|
||||
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'API Key is required!' });
|
||||
return;
|
||||
}
|
||||
|
||||
const routeData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
|
||||
if (!routeData || routeData.length === 0) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeData[0];
|
||||
var number = Number(req.body.number);
|
||||
var name = String(req.body.name);
|
||||
if (!number || !name) {
|
||||
res.status(400).json({ error: 'Bad Request' });
|
||||
return;
|
||||
}
|
||||
if (number < route.block_start || number > route.block_start + route.block_length) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove html
|
||||
name = require("escape-html")(name);
|
||||
// If number already exists, update, otherwise insert
|
||||
pool.query('SELECT * FROM directory WHERE number = ? AND route = ?', [number, route.id]).then((rows) => {
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
pool.query('UPDATE directory SET name = ? WHERE number = ? AND route = ?',
|
||||
[name, number, route.id]).then(() => {
|
||||
res.json({ message: 'Updated' });
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Error updating directory entry:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
} else {
|
||||
pool.query('INSERT INTO directory (number, name, route) VALUES (?, ?, ?)',
|
||||
[number, name, route.id]).then(() => {
|
||||
res.status(201).json({ message: 'Created' });
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Error creating directory entry:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Error checking for existing directory entry:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/v1/user/dir/deleteEntry/:number', async (req, res) => {
|
||||
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'API Key is required!' });
|
||||
return;
|
||||
}
|
||||
|
||||
const routeData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
|
||||
if (!routeData || routeData.length === 0) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const route = routeData[0];
|
||||
const number = Number(req.params.number);
|
||||
if (!number) {
|
||||
res.status(400).json({ error: 'Bad Request' });
|
||||
return;
|
||||
}
|
||||
// Check that the number is within the block range for the current user
|
||||
if (number < route.block_start || number > route.block_start + route.block_length) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
pool.query('DELETE FROM directory WHERE number = ? AND route = ?', [number, route.id]).then(() => {
|
||||
res.status(200).json({ message: 'Deleted' });
|
||||
}).catch(err => {
|
||||
console.error('Error deleting directory entry:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
|
||||
// User directory endpoint to mass update entries, with boolean 'replace' field to indicate if existing entries should be replaced with the new list.
|
||||
app.post('/api/v1/user/dir/massUpdate', async (req, res) => {
|
||||
const apiKey = req.headers['authorization'] ? req.headers['authorization'].replace('Bearer ', '') : null;
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'API Key is required!' });
|
||||
return;
|
||||
}
|
||||
const routeData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
|
||||
if (!routeData || routeData.length === 0) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all entries, make sure we check that each number is within the block range for the current user
|
||||
|
||||
|
||||
const route = routeData[0];
|
||||
const entries = req.body.entries;
|
||||
const replace = req.body.replace || false;
|
||||
if (!Array.isArray(entries)) {
|
||||
res.status(400).json({ error: 'Bad Request. Not array' });
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const number = Number(entry.number);
|
||||
const name = String(entry.name);
|
||||
if (!number || !name) {
|
||||
res.status(400).json({ error: 'Bad Request. Number Or Name' });
|
||||
return;
|
||||
}
|
||||
if (number < route.block_start || number > route.block_start + route.block_length) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (replace) {
|
||||
// Delete all existing entries for this route
|
||||
await pool.query('DELETE FROM directory WHERE route = ?', [route.id]);
|
||||
}
|
||||
// Insert or update entries
|
||||
for (const entry of entries) {
|
||||
const number = Number(entry.number);
|
||||
const name = String(entry.name);
|
||||
// Remove html
|
||||
const safeName = require("escape-html")(name);
|
||||
// If number already exists, update, otherwise insert
|
||||
const existingEntries = await pool.query('SELECT * FROM directory WHERE number = ? AND route = ?', [number, route.id]);
|
||||
const row = existingEntries[0];
|
||||
if (row) {
|
||||
await pool.query('UPDATE directory SET name = ? WHERE number = ? AND route = ?',
|
||||
[safeName, number, route.id]);
|
||||
} else {
|
||||
await pool.query('INSERT INTO directory (number, name, route) VALUES (?, ?, ?)',
|
||||
[number, safeName, route.id]);
|
||||
}
|
||||
}
|
||||
res.json({ message: 'Mass update completed' });
|
||||
});
|
||||
|
||||
// == END USER ROUTES ==
|
||||
|
||||
// == Directory routes == (unauthenticated)
|
||||
|
|
@ -893,72 +612,6 @@ app.get("/api/v1/directory", (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Function to find open number blocks
|
||||
app.get("/api/v1/directory/openBlocks", (req, res) => {
|
||||
pool.query("SELECT block_start, block_length FROM routes").then((rows) => {
|
||||
console.log(JSON.stringify(rows)); // for testing
|
||||
|
||||
const takenBlocks = rows.map(row => {
|
||||
return { start: row.block_start, end: row.block_start + row.block_length };
|
||||
});
|
||||
const openBlocks = [];
|
||||
for (let i = 2000000; i <= 9999999; i += 10000) {
|
||||
const blockStart = i;
|
||||
const blockEnd = i + 9999;
|
||||
// Check if block is invalid
|
||||
if (invalidBlocks.includes(blockStart)) {
|
||||
continue;
|
||||
}
|
||||
// Check if block overlaps with any taken blocks
|
||||
const overlap = takenBlocks.some(taken => {
|
||||
return (blockStart <= taken.end && blockEnd >= taken.start);
|
||||
});
|
||||
if (!overlap) {
|
||||
openBlocks.push(blockStart);
|
||||
}
|
||||
}
|
||||
res.json(openBlocks);
|
||||
}).catch(err => {
|
||||
console.error('Error getting open blocks:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/v1/provision/:apiKey", async (req, res) => {
|
||||
const apiKey = req.params.apiKey;
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const serverData = await pool.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]);
|
||||
if (!serverData || serverData.length === 0) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const server = serverData[0];
|
||||
// Do a quick DNS lookup on server.server to see if it matches the IP making the request. If it doesn't, add 'warning': "IP Mismatch. Are you running this on the right server?" to the response but continue to send all data
|
||||
dns.lookup(server.server, (err, address, family) => {
|
||||
if (err) {
|
||||
console.error('Error looking up DNS:', err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
const responseData = {
|
||||
server: server.server, // Informational
|
||||
port: server.port, // Informational
|
||||
inbound_context: server.auth, // IAX2 username and context
|
||||
iax_secret: server.secret, // IAX2 password
|
||||
block: server.block_start, // Used for generating context
|
||||
api_key: server.apiKey // Used for authentication
|
||||
};
|
||||
|
||||
if (address !== req.ip && address !== req.headers['x-forwarded-for']) {
|
||||
responseData.warning = "IP Mismatch. Are you running this on the right server?";
|
||||
}
|
||||
res.json(responseData);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Other public endpoints that need special handling
|
||||
discordInviteCache = { time: 0, url: "" };
|
||||
|
||||
|
|
@ -1013,11 +666,9 @@ app.get("/footer", (req, res) => {
|
|||
|
||||
app.get("/api/v1/checkAvailability/:number", (req, res) => {
|
||||
// Check if the number is 7 digits
|
||||
let number = Number(req.params.number);
|
||||
// Round to nearest 10000 so it's always NXX0000
|
||||
number = Math.floor(number / 10000) * 10000;
|
||||
if (!number || number < 2000000 || number > 9999999 || invalidBlocks.includes(number)) {
|
||||
res.status(400).json({ error: `Number is outside valid range or is an invalid block` });
|
||||
const number = Number(req.params.number);
|
||||
if (number < 2000000 || number > 9999999) {
|
||||
res.status(400).json({ error: `Number is outside valid range` });
|
||||
return;
|
||||
}
|
||||
pool.getConnection().then(conn => {
|
||||
|
|
@ -1051,74 +702,11 @@ app.get("/api/healthcheck", (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Periodic healthcheck on routes (iax ping every server every 5 minutes)
|
||||
let persistentHealthCheckData;
|
||||
function healthCheck() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let healthCheckData = {};
|
||||
pool.getConnection().then(conn => {
|
||||
conn.query('SELECT * FROM routes').then((rows) => {
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
const host = row.server;
|
||||
const port = row.port;
|
||||
const block = row.block_start;
|
||||
iaxping({ host, port }).then((pingResult) => {
|
||||
// Good ping
|
||||
healthCheckData[block] = { online: true, ping: pingResult.rttMs, timestamp: Date.now() };
|
||||
}).catch((pingErr) => {
|
||||
switch (pingErr.code) {
|
||||
case "ERR_POKE_TIMEOUT":
|
||||
healthCheckData[block] = { online: false, ping: null, timestamp: Date.now(), error: "Timeout" };
|
||||
break;
|
||||
case "ENOTFOUND":
|
||||
healthCheckData[block] = { online: false, ping: null, timestamp: Date.now(), error: "Host not found (DNS)" };
|
||||
break;
|
||||
default:
|
||||
healthCheckData[block] = { online: false, ping: null, timestamp: Date.now(), error: pingErr.message };
|
||||
break;
|
||||
}
|
||||
}).finally(() => {
|
||||
count++;
|
||||
if (count === rows.length) {
|
||||
conn.release();
|
||||
persistentHealthCheckData = healthCheckData;
|
||||
resolve(healthCheckData);
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Error getting routes for health check:', err);
|
||||
conn.release();
|
||||
reject(err);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Error getting connection for health check:', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
setInterval(healthCheck, 300000); // Run every 5 minutes
|
||||
|
||||
app.get("/api/servers", (req, res) => {
|
||||
if (!persistentHealthCheckData) {
|
||||
healthCheck().then((data) => {
|
||||
res.json(data);
|
||||
}).catch(err => {
|
||||
console.error('Error running health check:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
} else {
|
||||
res.json(persistentHealthCheckData);
|
||||
}
|
||||
});
|
||||
|
||||
// logCall function (caller, callee)
|
||||
const logCall = (caller, callee, srcIp = "none_given", success, reason = "none_given") => {
|
||||
const logCall = (caller, callee) => {
|
||||
pool.getConnection().then(conn => {
|
||||
conn.query('INSERT INTO callLogs (caller, callee, timestamp, srcIp, success, reason) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[caller, callee, Math.floor(Date.now()), srcIp, success, reason]).catch(err => {
|
||||
conn.query('INSERT INTO callLogs (caller, callee, timestamp) VALUES (?, ?, ?)',
|
||||
[caller, callee, Math.floor(Date.now())]).catch(err => {
|
||||
console.error('Error logging call:', err);
|
||||
}).finally(() => {
|
||||
conn.release();
|
||||
|
|
@ -1126,63 +714,27 @@ const logCall = (caller, callee, srcIp = "none_given", success, reason = "none_g
|
|||
});
|
||||
}
|
||||
|
||||
const genCall = (req, res, apiKey, ani, number) => {
|
||||
const srcIp = process.env.PROXY_HEADER ? req.headers[process.env.PROXY_HEADER] : req.ip;
|
||||
// Query to get a route
|
||||
app.get('/api/v1/route/:apiKey/:ani/:number', (req, res) => {
|
||||
const apiKey = req.params.apiKey;
|
||||
const number = Number(req.params.number);
|
||||
const ani = Number(req.params.ani);
|
||||
pool.getConnection().then(conn => {
|
||||
//conn.query("SELECT * FROM routes WHERE apiKey = ? AND block_start <= ? AND block_start + block_length >= ?", [apiKey, ani, ani]).then((rows) => {
|
||||
conn.query("SELECT * FROM routes WHERE apiKey = ?", [apiKey]).then((rows) => { // We'll try this Nick, if it doesn't work we'll go back to the original
|
||||
if (process.env.DEBUG_MODE === "true") {
|
||||
console.log(`API Key: ${apiKey}, ANI: ${ani}, Number: ${number}`);
|
||||
console.log(`Found ${rows.length} routes for API Key`);
|
||||
console.log(JSON.stringify(rows));
|
||||
}
|
||||
const row = rows[0];
|
||||
// If no row or error, return 401
|
||||
if (!row) {
|
||||
res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`)
|
||||
logCall(ani, number, srcIp, false, "invalid_api_key");
|
||||
return;
|
||||
}
|
||||
// Validate the ani and number are 7 digit numbers
|
||||
if (!ani || ani < 1000000 || ani > 9999999 || !number || number < 1000000 || number > 9999999) {
|
||||
res.status(400).send(`${process.env.MSG_ROUTE_ADDRESS}/400`);
|
||||
logCall(ani, number, srcIp, false, "invalid_number");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the ani is owned by the apiKey
|
||||
if (ani < row.block_start || ani > row.block_start + row.block_length) {
|
||||
res.status(403).send(`${process.env.MSG_ROUTE_ADDRESS}/403`);
|
||||
logCall(ani, number, srcIp, false, "ani_not_in_block");
|
||||
return;
|
||||
}
|
||||
|
||||
conn.query('SELECT * FROM routes WHERE block_start <= ? AND block_start + block_length >= ?', [number, number]).then((rows) => {
|
||||
const row = rows[0];
|
||||
|
||||
// Check blocklist. Type 1 is exact match, Type 2 is prefix match NNNXXXX where NNN is the prefix value.
|
||||
// Check if the ANI is blocked from calling this route
|
||||
const routeId = row ? row.id : null;
|
||||
if (!routeId) {
|
||||
res.status(404).send(`${process.env.MSG_ROUTE_ADDRESS}/404`);
|
||||
logCall(ani, number, srcIp, false, "no_route");
|
||||
return;
|
||||
}
|
||||
|
||||
conn.query('SELECT * FROM blocklist WHERE (blockType = 1 AND blockValue = ?) OR (blockType = 2 AND ? BETWEEN blockValue AND blockValue + ?);', [ani, ani, row.block_length]).then((blockRows) => {
|
||||
if (blockRows.length > 0) {
|
||||
// ANI is blocked from calling this route
|
||||
console.log(`Blocked Call Attempt: ${ani} -> ${number}`);
|
||||
logCall(ani, number, srcIp, false, "blocklist");
|
||||
res.status(403).send(`${process.env.MSG_ROUTE_ADDRESS}/403`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (row) {
|
||||
// Check if the ANI is within the block range
|
||||
// If it is, return `local`
|
||||
console.log(`New Call: ${ani} -> ${number}`);
|
||||
logCall(ani, number, srcIp, true);
|
||||
logCall(ani, number);
|
||||
// incriment estCallsMade analytics
|
||||
addAnalytic("estCallsMade");
|
||||
dailyAnalytic("dailyCallsMade");
|
||||
|
|
@ -1193,44 +745,60 @@ const genCall = (req, res, apiKey, ani, number) => {
|
|||
}
|
||||
} else {
|
||||
res.status(404).send(`${process.env.MSG_ROUTE_ADDRESS}/404`);
|
||||
logCall(ani, number, srcIp, false, "no_route");
|
||||
return;
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Error checking blocklist:', err);
|
||||
res.status(500).send(`${process.env.MSG_ROUTE_ADDRESS}/500`);
|
||||
logCall(ani, number, srcIp, false, "blocklist_error");
|
||||
return;
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Error getting route:', err);
|
||||
res.status(500).send(`${process.env.MSG_ROUTE_ADDRESS}/500`)
|
||||
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`)
|
||||
logCall(ani, number, srcIp, false, "invalid_api_key");
|
||||
}).finally(() => {
|
||||
conn.release();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Query to get a route
|
||||
app.get('/api/v1/route/:apiKey/:ani/:number', (req, res) => {
|
||||
const apiKey = req.params.apiKey;
|
||||
const number = Number(req.params.number);
|
||||
const ani = Number(req.params.ani);
|
||||
genCall(req, res, apiKey, ani, number);
|
||||
});
|
||||
|
||||
app.get('/api/v1', (req, res) => { // Backwards compatibility with TandmX cause why not, it's easy
|
||||
const apiKey = req.query.auth;
|
||||
const number = Number(req.query.number);
|
||||
const ani = Number(req.query.ani);
|
||||
genCall(req, res, apiKey, ani, number);
|
||||
pool.getConnection().then(conn => {
|
||||
conn.query("SELECT * FROM routes WHERE apiKey = ? AND block_start <= ? AND block_start + block_length >= ?", [apiKey, ani, ani]).then((rows) => {
|
||||
const row = rows[0];
|
||||
// If no row or error, return 401
|
||||
if (!row) {
|
||||
res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`)
|
||||
return;
|
||||
}
|
||||
conn.query('SELECT * FROM routes WHERE block_start <= ? AND block_start + block_length >= ?', [number, number]).then((rows) => {
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
// Check if the ANI is within the block range
|
||||
// If it is, return `local`
|
||||
console.log(`New Call: ${ani} -> ${number}`);
|
||||
logCall(ani, number);
|
||||
addAnalytic("estCallsMade");
|
||||
dailyAnalytic("dailyCallsMade");
|
||||
if (ani >= row.block_start && ani <= row.block_start + row.block_length) {
|
||||
res.status(200).send('local');
|
||||
} else {
|
||||
res.status(200).send(`IAX2/${row.auth}:${row.secret}@${row.server}:${row.port}/${number}`);
|
||||
}
|
||||
} else {
|
||||
res.status(404).send(`${process.env.MSG_ROUTE_ADDRESS}/404`);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Error getting route:', err);
|
||||
res.status(500).send(`${process.env.MSG_ROUTE_ADDRESS}/500`)
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
res.status(401).send(`${process.env.MSG_ROUTE_ADDRESS}/401`)
|
||||
}).finally(() => {
|
||||
conn.release();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Management Routes (Like restarting the server)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ function runMigrations(pool) {
|
|||
resolve();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error running migrations:', err);
|
||||
console.errorr('Error running migrations:', err);
|
||||
reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS admin_invites (
|
||||
code VARCHAR(36) PRIMARY KEY NOT NULL DEFAULT (UUID()),
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
maxUses INTEGER NOT NULL DEFAULT 1,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
expiresAt TIMESTAMP,
|
||||
createdBy INTEGER,
|
||||
FOREIGN KEY (createdBy) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS blocklist (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ownerId INT NOT NULL,
|
||||
blockType INT NOT NULL,
|
||||
blockValue VARCHAR(255) NOT NULL,
|
||||
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ownerId) REFERENCES routes(id) ON DELETE CASCADE
|
||||
);
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
ALTER TABLE callLogs
|
||||
ADD COLUMN srcIp VARCHAR(255) NOT NULL DEFAULT 'unknown',
|
||||
ADD COLUMN success INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN reason VARCHAR(255) NOT NULL DEFAULT 'none';
|
||||
1468
package-lock.json
generated
1468
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,13 +11,14 @@
|
|||
"description": "",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"connect-sqlite": "^0.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"ejs": "^3.1.10",
|
||||
"escape-html": "^1.0.3",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"iaxping": "github:ChrisChrome/IAXPing.js",
|
||||
"mariadb": "^3.4.0",
|
||||
"session-file-store": "^1.5.0"
|
||||
"session-file-store": "^1.5.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
height: 50px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
right: -10px
|
||||
}
|
||||
|
||||
.text-wrapper {
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
/* AstroCom Theme — consistent dark palette applied over Bootstrap */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
|
||||
|
||||
body {
|
||||
background-color: #111 !important;
|
||||
color: #f0f0f0 !important;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Navbar ─────────────────────────────────── */
|
||||
.navbar.bg-dark {
|
||||
background-color: #0d0d0d !important;
|
||||
border-bottom: 1px solid #2e2e2e;
|
||||
}
|
||||
|
||||
/* ── Cards ──────────────────────────────────── */
|
||||
.card.bg-dark,
|
||||
.card.bg-secondary {
|
||||
background-color: #1e1e1e !important;
|
||||
border: 1px solid #2e2e2e !important;
|
||||
color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.card.bg-dark .card-title,
|
||||
.card.bg-secondary .card-title {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* ── Tables ─────────────────────────────────── */
|
||||
.table-dark {
|
||||
--bs-table-bg: #1e1e1e;
|
||||
--bs-table-striped-bg: #252525;
|
||||
--bs-table-border-color: #2e2e2e;
|
||||
--bs-table-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* ── Form controls ──────────────────────────── */
|
||||
.form-control,
|
||||
.form-control.bg-dark {
|
||||
background-color: #1a1a1a !important;
|
||||
border-color: #3a3a3a !important;
|
||||
color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: #222 !important;
|
||||
border-color: #555 !important;
|
||||
color: #f0f0f0 !important;
|
||||
box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ── Alerts ─────────────────────────────────── */
|
||||
.alert-secondary {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #3a3a3a;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
|
@ -5,24 +5,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom Directory</title>
|
||||
<script>
|
||||
(function (d, t) {
|
||||
var BASE_URL = "https://support.chrischro.me";
|
||||
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
|
||||
g.src = BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g, s);
|
||||
g.onload = function () {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
|
||||
baseUrl: BASE_URL
|
||||
})
|
||||
}
|
||||
})(document, "script");
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="bg-dark text-white">
|
||||
|
|
|
|||
|
|
@ -1,180 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom API Docs</title>
|
||||
<script>
|
||||
(function (d, t) {
|
||||
var BASE_URL = "https://support.chrischro.me";
|
||||
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
|
||||
g.src = BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g, s);
|
||||
g.onload = function () {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
|
||||
baseUrl: BASE_URL
|
||||
})
|
||||
}
|
||||
})(document, "script");
|
||||
</script>
|
||||
<style>
|
||||
.doc-section { max-width: 900px; margin: 1.5rem auto; }
|
||||
.endpoint { background: #1e1e1e; border: 1px solid #2e2e2e; padding: 1rem; border-radius: .5rem; margin-bottom: .75rem; }
|
||||
.code { background:#0d1117; color:#9ad8ff; padding:.5rem; border-radius:.25rem; font-family:monospace; white-space:pre-wrap; }
|
||||
.small-muted { color: rgba(255,255,255,0.6); font-size:.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-dark text-white">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">AstroCom</a>
|
||||
<span id="footer"></span>
|
||||
</div>
|
||||
<div class="ms-auto d-flex text-nowrap">
|
||||
<a href="/user" class="btn btn-outline-light me-2">User Login</a>
|
||||
<a href="/admin" class="btn btn-outline-light">Admin Login</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container doc-section">
|
||||
<h2 class="mb-1">API Documentation</h2>
|
||||
<p class="small-muted">This page lists only endpoints that are fully unauthenticated or accept an API key via Bearer token.</p>
|
||||
|
||||
<h4 class="mt-4">Unauthenticated (public) endpoints</h4>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>GET /api/v1/directory</h5>
|
||||
<p class="small-muted">Returns all directory entries.</p>
|
||||
<div class="mb-2"><strong>Request</strong></div>
|
||||
<div class="code">curl -s -X GET https://astrocom.tel/api/v1/directory</div>
|
||||
<div class="mb-2 mt-2"><strong>Response (200)</strong></div>
|
||||
<div class="code">[{"id":1,"number":4472000,"name":"Example","route":2}, ...]</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>GET /api/v1/directory/openBlocks</h5>
|
||||
<p class="small-muted">Returns a list of available 10k blocks (block start numbers).</p>
|
||||
<div class="code">curl -s https://astrocom.tel/api/v1/directory/openBlocks</div>
|
||||
<div class="code">[1000000,1010000, ...]</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>GET /api/v1/checkAvailability/:number</h5>
|
||||
<p class="small-muted">Checks availability for a 7-digit number (rounded to NXX0000). Returns available: true/false.</p>
|
||||
<div class="code">curl -s https://astrocom.tel/api/v1/checkAvailability/4472001</div>
|
||||
<div class="code">{"available":true}</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>GET /api/analytics</h5>
|
||||
<p class="small-muted">Public analytics (total and daily counts).</p>
|
||||
<div class="code">curl -s https://astrocom.tel/api/analytics</div>
|
||||
<div class="code">{"total":[{"tag":"apiCalls","count":123}], "daily":[{"tag":"apiCalls","tag_date":"2025-10-27","count":10}]}</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>GET /discord</h5>
|
||||
<p class="small-muted">Redirects to the configured Discord invite (server-side fetch from WIDGET_URL).</p>
|
||||
<div class="code">curl -i https://astrocom.tel/discord</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>GET /api/v1/provision/:apiKey</h5>
|
||||
<p class="small-muted">Provisioning info for a route identified by API key. Returns server/port/iax creds and block.</p>
|
||||
<div class="code">curl -s https://astrocom.tel/api/v1/provision/REPLACE_API_KEY</div>
|
||||
<div class="code">{
|
||||
"server":"iax.example.net",
|
||||
"port":4569,
|
||||
"inbound_context":"from-astrocom",
|
||||
"iax_secret":"...secret...",
|
||||
"block":4470000,
|
||||
"api_key":"REPLACE_API_KEY"
|
||||
}</div>
|
||||
<p class="small-muted">Response may include "warning" if DNS IP doesn't match requester.</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>GET /api/v1/route/:apiKey/:ani/:number</h5>
|
||||
<p class="small-muted">Primary routing endpoint. Returns "local" or an IAX2 dial string for the callee.</p>
|
||||
<div class="code">curl -s https://astrocom.tel/api/v1/route/REPLACE_API_KEY/4472001/4473005</div>
|
||||
<div class="code">local
|
||||
-- or --
|
||||
IAX2/from-astrocom:secret@iax.example.net:4569/4473005</div>
|
||||
<p class="small-muted">Also available as legacy query form:</p>
|
||||
<div class="code">GET /api/v1?auth=APIKEY&ani=4472001&number=4473005</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary">
|
||||
|
||||
<h4 class="mt-4">Bearer token endpoints (Authorization: Bearer <API_KEY>)</h4>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>PATCH /api/v1/user/update</h5>
|
||||
<p class="small-muted">Update server/port/auth/secret for the route identified by Bearer API key (used by automated scripts).</p>
|
||||
<div class="code">curl -s -X PATCH \
|
||||
-H "Authorization: Bearer REPLACE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"server":"iax.example.net","port":4569,"auth":"from-astrocom","secret":"new-secret"}' \
|
||||
https://astrocom.tel/api/v1/user/update</div>
|
||||
<div class="code">{"message":"Updated"}</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>POST /api/v1/user/dir/newEntry</h5>
|
||||
<p class="small-muted">Create or update a single directory entry for the route belonging to the API key.</p>
|
||||
<div class="code">curl -s -X POST \
|
||||
-H "Authorization: Bearer REPLACE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"number":4472005,"name":"Alice"}' \
|
||||
https://astrocom.tel/api/v1/user/dir/newEntry</div>
|
||||
<div class="code">{"message":"Created"} or {"message":"Updated"}</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>DELETE /api/v1/user/dir/deleteEntry/:number</h5>
|
||||
<p class="small-muted">Delete a directory entry owned by the API key's route.</p>
|
||||
<div class="code">curl -s -X DELETE -H "Authorization: Bearer REPLACE_API_KEY" https://astrocom.tel/api/v1/user/dir/deleteEntry/4472005</div>
|
||||
<div class="code">{"message":"Deleted"}</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h5>POST /api/v1/user/dir/massUpdate</h5>
|
||||
<p class="small-muted">Mass-insert/update directory entries. Body must be {"entries":[{number,name},...],"replace":true|false}.</p>
|
||||
<div class="code">curl -s -X POST \
|
||||
-H "Authorization: Bearer REPLACE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"replace":false,"entries":[{"number":4472001,"name":"Bob"},{"number":4472002,"name":"Carol"}]}' \
|
||||
https://astrocom.tel/api/v1/user/dir/massUpdate</div>
|
||||
<div class="code">{"message":"Mass update completed"}</div>
|
||||
<p class="small-muted">All numbers must be within the route's block range.</p>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary">
|
||||
|
||||
<p class="small-muted">Notes:</p>
|
||||
<ul class="small-muted">
|
||||
<li>Bearer endpoints accept header Authorization: Bearer <API_KEY>.</li>
|
||||
<li>Unauthenticated endpoints that accept an API key in the path/query do not require Authorization header.</li>
|
||||
<li>All numeric "number" and "ani" values must be 7-digit integers (1,000,000–9,999,999) where applicable.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/directory.js"></script>
|
||||
<script src="/assets/js/bootstrap.min.js"></script>
|
||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/jquery.min.js"></script>
|
||||
<script>
|
||||
$(function() {
|
||||
$("#footer").load("/footer");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -20,22 +20,6 @@
|
|||
<script type="application/ld+json">
|
||||
{"name":"AstroCom","description":"Simplifying communication.","@type":"WebSite","url":"https://astrocom.tel/","headline":"AstroCom","@context":"http://schema.org"}
|
||||
</script>
|
||||
<script>
|
||||
(function (d, t) {
|
||||
var BASE_URL = "https://support.chrischro.me";
|
||||
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
|
||||
g.src = BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g, s);
|
||||
g.onload = function () {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
|
||||
baseUrl: BASE_URL
|
||||
})
|
||||
}
|
||||
})(document, "script");
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -52,8 +36,6 @@
|
|||
<div class="links">
|
||||
<a href="/about">About (WIP)</a><span> </span>
|
||||
<a href="/directory">Directory</a><span> </span>
|
||||
<a href="/validator">Block Availability</a> <span> </span>
|
||||
<a href="/status">Status</a><span> </span>
|
||||
<a href="/discord">Discord Server</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,291 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" />
|
||||
<title>AstroCom — Server Status</title>
|
||||
<style>
|
||||
.status-card {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.status-card .block-label {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.status-card .ping-badge {
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-card .status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.indicator-online {
|
||||
background-color: #28a745;
|
||||
box-shadow: 0 0 6px #28a745;
|
||||
}
|
||||
|
||||
.indicator-offline {
|
||||
background-color: #dc3545;
|
||||
box-shadow: 0 0 6px #dc3545;
|
||||
}
|
||||
|
||||
.indicator-unknown {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.status-card.card-online {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.status-card.card-offline {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.status-card .ts-label {
|
||||
font-size: 0.72rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 10px;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.summary-bar .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
#refreshBtn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #dc3545;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.ping-good { background-color: #1a3d2b; color: #5cdb95; }
|
||||
.ping-warn { background-color: #3d2e1a; color: #f0a500; }
|
||||
.ping-bad { background-color: #3d1a1a; color: #ff6b6b; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold" href="/">AstroCom</a>
|
||||
<span id="footer"></span>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<a href="/user" class="btn btn-outline-light btn-sm">User Login</a>
|
||||
<a href="/admin" class="btn btn-outline-light btn-sm">Admin Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<!-- Header row -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 class="mb-0 fw-bold">Server Status</h2>
|
||||
<span class="last-updated" id="lastUpdated">Loading…</span>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="refreshBtn" title="Refresh">
|
||||
<i class="fa-solid fa-rotate-right me-1"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary bar -->
|
||||
<div class="summary-bar d-flex align-items-center gap-4 mb-4 flex-wrap" id="summaryBar">
|
||||
<span><span class="dot" style="background:#28a745;"></span><strong id="onlineCount">—</strong> Online</span>
|
||||
<span><span class="dot" style="background:#dc3545;"></span><strong id="offlineCount">—</strong> Offline</span>
|
||||
<span><strong id="totalCount">—</strong> Total Blocks</span>
|
||||
<span>Avg ping: <strong id="avgPing">—</strong></span>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div id="statusGrid" class="grid-container">
|
||||
<div class="loading-spinner">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Fetching status…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const AUTO_REFRESH_MS = 60000; // auto-refresh every 60 s
|
||||
|
||||
function formatRelative(ts) {
|
||||
const diff = Math.floor((Date.now() - ts) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
const m = Math.floor(diff / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
return `${Math.floor(m / 60)}h ago`;
|
||||
}
|
||||
|
||||
function pingClass(ms) {
|
||||
if (ms === null || ms === undefined) return '';
|
||||
if (ms < 100) return 'ping-good';
|
||||
if (ms < 500) return 'ping-warn';
|
||||
return 'ping-bad';
|
||||
}
|
||||
|
||||
function renderCard(block, data) {
|
||||
const online = data.online === true;
|
||||
const ping = data.ping;
|
||||
const ts = data.timestamp;
|
||||
|
||||
const indicator = `<span class="status-indicator ${online ? 'indicator-online' : 'indicator-offline'}"></span>`;
|
||||
const statusText = online ? 'Online' : 'Offline';
|
||||
|
||||
let pingHtml = '';
|
||||
if (online && ping !== null && ping !== undefined) {
|
||||
pingHtml = `<span class="ping-badge ${pingClass(ping)}">${Math.round(ping)} ms</span>`;
|
||||
} else if (!online && data.error) {
|
||||
pingHtml = `<span class="ping-badge" style="background:#2a1a1a;color:#ff6b6b;font-size:0.72rem;" title="${data.error}">
|
||||
<i class="fa-solid fa-triangle-exclamation me-1"></i>${data.error}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
const tsHtml = ts ? `<div class="ts-label mt-1">Checked ${formatRelative(ts)}</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="status-card ${online ? 'card-online' : 'card-offline'}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="block-label">${block}</div>
|
||||
${pingHtml}
|
||||
</div>
|
||||
<div class="mt-2" style="font-size:0.9rem;">
|
||||
${indicator}<span style="vertical-align:middle;">${statusText}</span>
|
||||
</div>
|
||||
${tsHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const grid = document.getElementById('statusGrid');
|
||||
grid.innerHTML = `<div class="loading-spinner">
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
<span>Fetching status…</span>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/servers');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const blocks = Object.keys(data).sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
if (blocks.length === 0) {
|
||||
grid.innerHTML = `<div class="error-msg"><i class="fa-solid fa-circle-info me-2"></i>No servers found.</div>`;
|
||||
updateSummary([], data);
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = blocks.map(b => renderCard(b, data[b])).join('');
|
||||
updateSummary(blocks, data);
|
||||
|
||||
document.getElementById('lastUpdated').textContent =
|
||||
`Last updated: ${new Date().toLocaleTimeString()}`;
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="error-msg">
|
||||
<i class="fa-solid fa-circle-exclamation me-2"></i>
|
||||
Failed to load status: ${err.message}
|
||||
</div>`;
|
||||
document.getElementById('lastUpdated').textContent = 'Failed to fetch';
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSummary(blocks, data) {
|
||||
let online = 0, offline = 0, totalPing = 0, pingCount = 0;
|
||||
blocks.forEach(b => {
|
||||
if (data[b].online) {
|
||||
online++;
|
||||
if (data[b].ping !== null && data[b].ping !== undefined) {
|
||||
totalPing += data[b].ping;
|
||||
pingCount++;
|
||||
}
|
||||
} else {
|
||||
offline++;
|
||||
}
|
||||
});
|
||||
document.getElementById('onlineCount').textContent = online;
|
||||
document.getElementById('offlineCount').textContent = offline;
|
||||
document.getElementById('totalCount').textContent = blocks.length;
|
||||
document.getElementById('avgPing').textContent = pingCount
|
||||
? `${Math.round(totalPing / pingCount)} ms` : '—';
|
||||
}
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => {
|
||||
loadStatus();
|
||||
});
|
||||
|
||||
// Initial load + auto-refresh
|
||||
loadStatus();
|
||||
setInterval(loadStatus, AUTO_REFRESH_MS);
|
||||
</script>
|
||||
|
||||
<script src="/assets/js/bootstrap.min.js"></script>
|
||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/jquery.min.js"></script>
|
||||
<script>
|
||||
$(function () { $("#footer").load("/footer"); });
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -5,24 +5,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom Availability Checker</title>
|
||||
<script>
|
||||
(function (d, t) {
|
||||
var BASE_URL = "https://support.chrischro.me";
|
||||
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
|
||||
g.src = BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g, s);
|
||||
g.onload = function () {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
|
||||
baseUrl: BASE_URL
|
||||
})
|
||||
}
|
||||
})(document, "script");
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="bg-dark text-white">
|
||||
|
|
@ -51,47 +34,6 @@
|
|||
<button type="submit" class="btn btn-primary mt-3">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4" style="max-width: 400px;">
|
||||
<h4>Available Blocks</h4>
|
||||
<table class="table table-dark table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="availHeader" scope="col">Available Blocks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="availableBlocksTable">
|
||||
<tr><td>Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
async function loadAvailableBlocks() {
|
||||
const tableBody = document.getElementById('availableBlocksTable');
|
||||
try {
|
||||
const res = await fetch('/api/v1/directory/openBlocks');
|
||||
const blocks = await res.json();
|
||||
tableBody.innerHTML = '';
|
||||
if (Array.isArray(blocks) && blocks.length > 0) {
|
||||
blocks.forEach(block => {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.textContent = block;
|
||||
row.appendChild(cell);
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
// Set header text to "Available Blocks (X total)" where X is the number of available blocks
|
||||
document.getElementById('availHeader').textContent = `${blocks.length} Available Blocks`;
|
||||
} else {
|
||||
tableBody.innerHTML = '<tr><td>No blocks available</td></tr>';
|
||||
}
|
||||
} catch {
|
||||
tableBody.innerHTML = '<tr><td>Error loading blocks</td></tr>';
|
||||
}
|
||||
}
|
||||
loadAvailableBlocks();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.querySelector('form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
[Unit]
|
||||
Description=Rotate AstroCom Secret
|
||||
[Service]
|
||||
ExecStart=/usr/bin/astrocom_rotate.sh
|
||||
Type=oneshot
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
[Unit]
|
||||
Description=Rotate AstroCom Secret
|
||||
[Timer]
|
||||
# This will run daily at 0200 local time
|
||||
OnCalendar=*-*-* 02:00:00
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# --- USER CONFIG ---
|
||||
IAXBLOCK="from-astrocom"
|
||||
CONF="/etc/asterisk/iax_custom.conf"
|
||||
|
||||
# Astrocom API endpoint info
|
||||
ASTROCOM_URL="https://astrocom.tel/api/v1/user/update" # Shouldn't need to change this!
|
||||
ASTROCOM_TOKEN=""
|
||||
# -------------------
|
||||
|
||||
# Parse flags
|
||||
DRYRUN=0
|
||||
if [[ "$1" == "--dry-run" || "$1" == "-n" ]]; then
|
||||
DRYRUN=1
|
||||
fi
|
||||
|
||||
# Generate a 32-char alphanumeric secret
|
||||
NEWSECRET=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
|
||||
# Escape block name for sed search
|
||||
ESC_BLOCK=$(printf '%s\n' "$IAXBLOCK" | sed 's/[]\/$*.^[]/\\&/g')
|
||||
|
||||
# Sed script to modify only the first secret= inside the block
|
||||
SED_SCRIPT="
|
||||
/^\[$ESC_BLOCK\]/,/^\[/{
|
||||
/^\[/!{
|
||||
s/^secret=.*/secret=$NEWSECRET/
|
||||
t done
|
||||
}
|
||||
}
|
||||
: done
|
||||
"
|
||||
|
||||
if [[ $DRYRUN -eq 1 ]]; then
|
||||
echo "=== DRY RUN: Showing updated output only ==="
|
||||
sed "$SED_SCRIPT" "$CONF"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
#
|
||||
# REAL UPDATE EXECUTION
|
||||
#
|
||||
|
||||
echo "Updating secret in file..."
|
||||
sed -i "$SED_SCRIPT" "$CONF" || { echo "Error updating $CONF"; exit 1; }
|
||||
|
||||
echo "New secret: $NEWSECRET"
|
||||
|
||||
#
|
||||
# ASTROCOM API REQUEST
|
||||
#
|
||||
echo "Sending updated secret to Astrocom..."
|
||||
|
||||
# ----- EDIT THIS TO MATCH YOUR REAL API FORMAT -----
|
||||
curl -X PATCH "$ASTROCOM_URL" \
|
||||
-H "Authorization: Bearer $ASTROCOM_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"secret\": \"$NEWSECRET\"}" \
|
||||
|| echo "Warning: Astrocom API call failed"
|
||||
# ---------------------------------------------------
|
||||
|
||||
#
|
||||
# RELOAD ASTERISK
|
||||
#
|
||||
echo "Reloading Asterisk..."
|
||||
asterisk -x "core reload"
|
||||
|
||||
echo "Update complete."
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
#!/bin/bash
|
||||
# AstroCom Dynamic IP Update Script
|
||||
# Gets current public IP from https://myip.wtf/text and posts it to the AstroCom API
|
||||
# Requires: curl
|
||||
|
||||
# Configuration
|
||||
API_KEY="Your ASTROCOM API Key" # Replace with your AstroCom API Key!
|
||||
|
||||
|
||||
|
||||
# Get current IP
|
||||
CURRENT_IP=$(curl -s https://myip.wtf/text)
|
||||
if [[ -z "$CURRENT_IP" ]]; then
|
||||
echo "Failed to retrieve current IP address."
|
||||
exit 1
|
||||
fi
|
||||
echo "Current IP: $CURRENT_IP"
|
||||
# Update IP via AstroCom API PATCH https://astrocom.tel/api/v1/user/update; JSON body: {"server": "current_ip"}
|
||||
curl -s -X PATCH https://astrocom.tel/api/v1/user/update -H "Content-Type: application/json" -H "Authorization: Bearer $API_KEY" -d "{\"server\": \"$CURRENT_IP\"}"
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom Admin - Create new server</title>
|
||||
</head>
|
||||
<body class="bg-dark text-white">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom Admin - Editing <%= data.id %></title>
|
||||
</head>
|
||||
<body class="bg-dark text-white">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom Admin</title>
|
||||
</head>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom Admin Login</title>
|
||||
</head>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom Admin Registration</title>
|
||||
</head>
|
||||
|
||||
<body class="bg-dark">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card bg-dark text-light shadow">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-center mb-4">Admin Registration</h2>
|
||||
<% if (typeof notice !== 'undefined') { %>
|
||||
<div class="alert alert-info text-center mb-3"><%= notice %></div>
|
||||
<% } %>
|
||||
<% if (typeof info !== 'undefined') { %>
|
||||
<div class="alert alert-primary text-center mb-3"><%= info %></div>
|
||||
<% } %>
|
||||
<% if (typeof warn !== 'undefined') { %>
|
||||
<div class="alert alert-warning text-center mb-3"><%= warn %></div>
|
||||
<% } %>
|
||||
<% if (typeof error !== 'undefined') { %>
|
||||
<div class="alert alert-danger text-center mb-3"><%= error %></div>
|
||||
<% } %>
|
||||
<form action="#" method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username:</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password:</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Register</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer" class="text-light mt-5"></div>
|
||||
</div>
|
||||
<script src="/assets/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/jquery.min.js"></script>
|
||||
<script>
|
||||
$(function() {
|
||||
$("#footer").load("/footer");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,22 +1,5 @@
|
|||
<!-- style="background-color:#0d0d0d; border-top: 1px solid #2e2e2e;" -->
|
||||
<footer class="footer mt-auto py-3 text-white" >
|
||||
<footer class="footer mt-auto py-3 bg-dark text-white">
|
||||
<div class="container text-center">
|
||||
<span>© <%= new Date().getFullYear() %> AstroCom <%= version %></span>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- <script>
|
||||
(function (d, t) {
|
||||
var BASE_URL = "https://support.chrischro.me";
|
||||
var g = d.createElement(t), s = d.getElementsByTagName(t)[0];
|
||||
g.src = BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g, s);
|
||||
g.onload = function () {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '1Epwwnhnmieqzu2dm3jYH3Qp',
|
||||
baseUrl: BASE_URL
|
||||
})
|
||||
}
|
||||
})(document, "script");
|
||||
</script> -->
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom User - Edit</title>
|
||||
</head>
|
||||
<body class="bg-dark text-white">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom User Panel</title>
|
||||
</head>
|
||||
|
||||
|
|
@ -26,7 +25,7 @@
|
|||
<h2 class="m-0 text-center w-100">User Dashboard</h2>
|
||||
</div>
|
||||
<div class="container mt-4">
|
||||
<div class="card bg-dark mb-4 text-white">
|
||||
<div class="card bg-secondary mb-4 text-white">
|
||||
<div class="card-body">
|
||||
<div class="position-absolute top-0 end-0 m-3">
|
||||
<a href="/user/edit" class="btn btn-primary" id="editInfoBtn">Edit Information</a>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
||||
<title>AstroCom User Login</title>
|
||||
</head>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue