@rip-lang/server 1.4.15 → 1.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/control/cli.rip +17 -0
- package/control/manager.rip +11 -0
- package/control/worker.rip +4 -0
- package/package.json +2 -2
- package/server.rip +52 -1
- package/serving/config.rip +219 -1
- package/serving/tls.rip +68 -2
package/control/cli.rip
CHANGED
|
@@ -254,6 +254,7 @@ export parseFlags = (argv, defaultEntryPath = null) ->
|
|
|
254
254
|
reloadConfig: has('--reload') or has('-r')
|
|
255
255
|
listApps: has('--list') or has('-l')
|
|
256
256
|
stopServer: has('--stop') or has('-s')
|
|
257
|
+
restartServer: has('--restart')
|
|
257
258
|
envOverride: envOverride
|
|
258
259
|
debug: has('--debug')
|
|
259
260
|
}
|
|
@@ -322,6 +323,7 @@ export runHelpOutput = (argv) ->
|
|
|
322
323
|
-i, --info Show running server status
|
|
323
324
|
-l, --list List registered hosts
|
|
324
325
|
-r, --reload Reload config on running server
|
|
326
|
+
--restart Hot restart (rebuild everything, keep listeners)
|
|
325
327
|
-s, --stop Stop running server
|
|
326
328
|
--watch=<glob> Watch glob pattern (default: *.rip)
|
|
327
329
|
--static Disable hot reload and file watching
|
|
@@ -400,6 +402,21 @@ export reloadConfig = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
|
400
402
|
warn "rip-server: reload failed: #{e?.message or e}"
|
|
401
403
|
exitFn(1)
|
|
402
404
|
|
|
405
|
+
export restartServer = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
406
|
+
controlUnix = getControlSocketPathFn(prefix)
|
|
407
|
+
try
|
|
408
|
+
res = fetchFn!('http://localhost/restart', { unix: controlUnix, method: 'POST' })
|
|
409
|
+
j = res.json!
|
|
410
|
+
if j?.ok
|
|
411
|
+
p "rip-server: hot restart complete"
|
|
412
|
+
else
|
|
413
|
+
reason = j?.error or 'unknown'
|
|
414
|
+
warn "rip-server: restart failed: #{reason}"
|
|
415
|
+
exitFn(1)
|
|
416
|
+
catch e
|
|
417
|
+
warn "rip-server: restart failed: #{e?.message or e}"
|
|
418
|
+
exitFn(1)
|
|
419
|
+
|
|
403
420
|
export listApps = (prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
404
421
|
controlUnix = getControlSocketPathFn(prefix)
|
|
405
422
|
try
|
package/control/manager.rip
CHANGED
|
@@ -244,6 +244,17 @@ export class Manager
|
|
|
244
244
|
workers[idx] = @spawnWorker(app.appId, @currentVersion) if idx >= 0
|
|
245
245
|
@deferredDeaths.clear()
|
|
246
246
|
|
|
247
|
+
teardown: ->
|
|
248
|
+
@shuttingDown = true
|
|
249
|
+
@stop!
|
|
250
|
+
@shuttingDown = false
|
|
251
|
+
@currentMtimes.clear()
|
|
252
|
+
@rollingApps.clear()
|
|
253
|
+
@lastRollAt.clear()
|
|
254
|
+
@retiringIds.clear()
|
|
255
|
+
@restartBudgets.clear()
|
|
256
|
+
@deferredDeaths.clear()
|
|
257
|
+
|
|
247
258
|
getEntryMtime: (appId = null) ->
|
|
248
259
|
app = @getAppState(appId or @defaultAppId)
|
|
249
260
|
entry = app?.config?.entry or @flags.appEntry
|
package/control/worker.rip
CHANGED
|
@@ -63,6 +63,10 @@ export runWorkerMode = ->
|
|
|
63
63
|
workerState.handler or (-> new Response('not ready', { status: 503 }))
|
|
64
64
|
catch e
|
|
65
65
|
warn "[worker #{workerId}] import failed:", e
|
|
66
|
+
if typeof e.formatHTML is 'function'
|
|
67
|
+
html = "<!DOCTYPE html><html><head><meta charset=utf-8><title>Rip Compile Error</title><style>body{margin:40px auto;max-width:900px;background:#1e1e2e;color:#cdd6f4;font-family:system-ui,sans-serif}</style></head><body>#{e.formatHTML()}</body></html>"
|
|
68
|
+
workerState.handler = -> new Response(html, { status: 500, headers: { 'content-type': 'text/html; charset=UTF-8' } })
|
|
69
|
+
return workerState.handler
|
|
66
70
|
workerState.handler or (-> new Response('not ready', { status: 503 }))
|
|
67
71
|
|
|
68
72
|
selfJoin = ->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/server",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.17",
|
|
4
4
|
"description": "Bun-native content server: static sites, apps, HTTP proxy, and TCP/TLS passthrough",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "api.rip",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"rip-lang": ">=3.13.
|
|
50
|
+
"rip-lang": ">=3.13.136"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"api.rip",
|
package/server.rip
CHANGED
|
@@ -40,7 +40,7 @@ import { getControlSocketPath, getPidFilePath, handleWorkerControl, handleWatchC
|
|
|
40
40
|
import { getLanIP as controlGetLanIP, startMdnsAdvertisement as controlStartMdnsAdvertisement, stopMdnsAdvertisements as controlStopMdnsAdvertisements } from './control/mdns.rip'
|
|
41
41
|
import { registerWatchGroup, handleWatchGroup, broadcastWatchChange } from './control/watchers.rip'
|
|
42
42
|
import { computeHideUrls, logStartupSummary, createCleanup, installShutdownHandlers, setEventJsonMode, logEvent } from './control/lifecycle.rip'
|
|
43
|
-
import { runVersionOutput, runHelpOutput, stopServer, reloadConfig, listApps, showInfo, parseFlags } from './control/cli.rip'
|
|
43
|
+
import { runVersionOutput, runHelpOutput, stopServer, restartServer, reloadConfig, listApps, showInfo, parseFlags } from './control/cli.rip'
|
|
44
44
|
import { runSetupMode, runWorkerMode } from './control/worker.rip'
|
|
45
45
|
import { Manager } from './control/manager.rip'
|
|
46
46
|
|
|
@@ -581,6 +581,47 @@ class Server
|
|
|
581
581
|
|
|
582
582
|
controlStopMdnsAdvertisements(@mdnsProcesses)
|
|
583
583
|
|
|
584
|
+
restart: ->
|
|
585
|
+
logEvent('server_restart', { uptime: Math.floor((nowMs() - @startedAt) / 1000) })
|
|
586
|
+
|
|
587
|
+
# --- Teardown (listeners stay open) ---
|
|
588
|
+
@manager?.teardown!
|
|
589
|
+
stopHealthChecks(@servingRuntime.upstreamPool)
|
|
590
|
+
stopHealthChecks(runtime.upstreamPool) for runtime in @retiredServingRuntimes
|
|
591
|
+
stopStreamListeners(@streamRuntime, true)
|
|
592
|
+
stopStreamListeners(runtime, true) for runtime in @retiredStreamRuntimes
|
|
593
|
+
clearInterval(@queueSweepTimer) if @queueSweepTimer
|
|
594
|
+
try @control?.stop()
|
|
595
|
+
controlStopMdnsAdvertisements(@mdnsProcesses)
|
|
596
|
+
|
|
597
|
+
# Drain queues
|
|
598
|
+
for [appId, app] as @appRegistry.apps
|
|
599
|
+
while app.queue.length > 0
|
|
600
|
+
job = app.queue.shift()
|
|
601
|
+
try job.resolve(new Response('Server restarting', { status: 503, headers: { 'Retry-After': '1' } }))
|
|
602
|
+
|
|
603
|
+
# --- Reload ---
|
|
604
|
+
@tlsMaterial = @loadTlsMaterial()
|
|
605
|
+
@reloadRuntimeConfig!(@flags, 'restart', false)
|
|
606
|
+
|
|
607
|
+
# --- Rebuild ---
|
|
608
|
+
@queueSweepTimer = setInterval =>
|
|
609
|
+
for [appId, app] as @appRegistry.apps
|
|
610
|
+
now = Date.now()
|
|
611
|
+
while app.queue.length > 0 and now - app.queue[0].enqueuedAt > app.queueTimeoutMs
|
|
612
|
+
job = app.queue.shift()
|
|
613
|
+
try job.resolve(new Response('Queue timeout', { status: 504 }))
|
|
614
|
+
@metrics.queueTimeouts++
|
|
615
|
+
, 1000
|
|
616
|
+
|
|
617
|
+
@startControl!
|
|
618
|
+
@startActiveStreamListeners() if @streamListenersDeferred or @streamRuntime.listeners.size is 0
|
|
619
|
+
|
|
620
|
+
# --- Restart workers ---
|
|
621
|
+
@manager?.start!
|
|
622
|
+
|
|
623
|
+
logEvent('server_restart_complete', {})
|
|
624
|
+
|
|
584
625
|
proxyRouteToUpstream: (req, route, requestId, clientIp, runtime) ->
|
|
585
626
|
proxyRouteToUpstreamFn!(req, route, requestId, clientIp, runtime, @metrics, @flags, @maybeAddSecurityHeaders.bind(@), @logAccess.bind(@))
|
|
586
627
|
|
|
@@ -913,6 +954,13 @@ class Server
|
|
|
913
954
|
res = handleReloadControl(req, result)
|
|
914
955
|
return res.response if res.handled
|
|
915
956
|
|
|
957
|
+
if req.method is 'POST' and url.pathname is '/restart'
|
|
958
|
+
try
|
|
959
|
+
@restart!
|
|
960
|
+
return new Response(JSON.stringify({ ok: true, restarted: true }), { headers: { 'content-type': 'application/json' } })
|
|
961
|
+
catch e
|
|
962
|
+
return new Response(JSON.stringify({ ok: false, error: e?.message or 'restart failed' }), { status: 500, headers: { 'content-type': 'application/json' } })
|
|
963
|
+
|
|
916
964
|
if url.pathname is '/registry' and req.method is 'GET'
|
|
917
965
|
res = handleRegistryControl(req, @appRegistry.hostIndex)
|
|
918
966
|
return res.response if res.handled
|
|
@@ -991,6 +1039,9 @@ main = ->
|
|
|
991
1039
|
if flags.listApps
|
|
992
1040
|
listApps!(flags.socketPrefix, getControlSocketPath, fetch, exit)
|
|
993
1041
|
return
|
|
1042
|
+
if flags.restartServer
|
|
1043
|
+
restartServer!(flags.socketPrefix, getControlSocketPath, fetch, exit)
|
|
1044
|
+
return
|
|
994
1045
|
if flags.stopServer
|
|
995
1046
|
stopServer(flags.socketPrefix, getPidFilePath, existsSync, readFileSync, process.kill.bind(process), Bun.spawnSync, import.meta.path)
|
|
996
1047
|
return
|
package/serving/config.rip
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync, statSync } from 'node:fs'
|
|
6
6
|
import { join, dirname, resolve, basename } from 'node:path'
|
|
7
7
|
import { normalizeStreams, parseHostPort } from '../streams/config.rip'
|
|
8
|
+
import { scanSslDirectory, matchCertForHost } from './tls.rip'
|
|
8
9
|
|
|
9
10
|
CONFIG_FILE = 'serve.rip'
|
|
10
11
|
VALID_REDIRECT_STATUSES = new Set([301, 302, 307, 308])
|
|
@@ -687,8 +688,225 @@ export normalizeAppConfig = (appId, appConfig, baseDir) ->
|
|
|
687
688
|
throw validationError("app #{appId}", errors) if errors.length > 0
|
|
688
689
|
normalized
|
|
689
690
|
|
|
691
|
+
# ==============================================================================
|
|
692
|
+
# serve.rip normalizer: ssl + sites + apps
|
|
693
|
+
# ==============================================================================
|
|
694
|
+
|
|
695
|
+
SERVE_KEYS = new Set(%w[ssl sites apps version server])
|
|
696
|
+
|
|
697
|
+
parseAppSpec = (appName, spec, knownSites, errors, path) ->
|
|
698
|
+
tokens = String(spec or '').trim().split(/\s+/).filter(Boolean)
|
|
699
|
+
unless tokens.length > 0
|
|
700
|
+
pushError(errors, 'E_APP_EMPTY', path, "app #{appName}: empty spec")
|
|
701
|
+
return null
|
|
702
|
+
|
|
703
|
+
targetTokens = tokens.filter (t) -> /^(https?:\/\/|tcp:\/\/|\.\/|\.\.\/|\/)/.test(t)
|
|
704
|
+
|
|
705
|
+
if targetTokens.length > 1
|
|
706
|
+
pushError(errors, 'E_APP_MULTI_TARGET', path, "app #{appName}: multiple targets: #{targetTokens.join(', ')}", 'Only one path or URL prefix is allowed.')
|
|
707
|
+
return null
|
|
708
|
+
|
|
709
|
+
if targetTokens.length is 1
|
|
710
|
+
target = targetTokens[0]
|
|
711
|
+
siteNames = tokens.filter (t) -> t isnt target
|
|
712
|
+
else
|
|
713
|
+
target = '.'
|
|
714
|
+
siteNames = tokens
|
|
715
|
+
|
|
716
|
+
unknown = siteNames.filter (s) -> not knownSites.has(s)
|
|
717
|
+
if unknown.length > 0
|
|
718
|
+
pushError(errors, 'E_APP_UNKNOWN_SITE', path, "app #{appName}: unknown site(s): #{unknown.join(', ')}", "Define these in the sites section.")
|
|
719
|
+
return null
|
|
720
|
+
|
|
721
|
+
kind = if /^https?:\/\//.test(target) then 'http-proxy'
|
|
722
|
+
else if /^tcp:\/\//.test(target) then 'tcp-proxy'
|
|
723
|
+
else 'local'
|
|
724
|
+
|
|
725
|
+
{ kind, target, siteNames }
|
|
726
|
+
|
|
727
|
+
normalizeServeRip = (config, baseDir) ->
|
|
728
|
+
errors = []
|
|
729
|
+
|
|
730
|
+
for key of config
|
|
731
|
+
unless SERVE_KEYS.has(key)
|
|
732
|
+
pushError(errors, 'E_UNKNOWN_KEY', key, "unknown top-level key: #{key}", 'Allowed keys: ssl, sites, apps, version, server.')
|
|
733
|
+
|
|
734
|
+
# SSL directory
|
|
735
|
+
sslPath = if typeof config.ssl is 'string' then config.ssl else null
|
|
736
|
+
certMap = new Map()
|
|
737
|
+
if sslPath
|
|
738
|
+
certMap = scanSslDirectory(sslPath)
|
|
739
|
+
|
|
740
|
+
# Sites: siteName -> hostname
|
|
741
|
+
siteToHost = new Map()
|
|
742
|
+
hostToSite = new Map()
|
|
743
|
+
if config.sites?
|
|
744
|
+
unless isPlainObject(config.sites)
|
|
745
|
+
pushError(errors, 'E_SITES_TYPE', 'sites', 'sites must be an object')
|
|
746
|
+
else
|
|
747
|
+
for siteName, hostname of config.sites
|
|
748
|
+
host = String(hostname).toLowerCase().trim()
|
|
749
|
+
unless host
|
|
750
|
+
pushError(errors, 'E_SITE_EMPTY', "sites.#{siteName}", "site #{siteName}: hostname is empty")
|
|
751
|
+
continue
|
|
752
|
+
if hostToSite.has(host)
|
|
753
|
+
pushError(errors, 'E_SITE_DUPLICATE_HOST', "sites.#{siteName}", "hostname #{host} is already assigned to site #{hostToSite.get(host)}")
|
|
754
|
+
continue
|
|
755
|
+
siteToHost.set(String(siteName), host)
|
|
756
|
+
hostToSite.set(host, String(siteName))
|
|
757
|
+
|
|
758
|
+
knownSites = new Set(siteToHost.keys())
|
|
759
|
+
|
|
760
|
+
# Server settings (optional, defaults)
|
|
761
|
+
serverSettings = normalizeServerSettings(config.server or {}, errors)
|
|
762
|
+
|
|
763
|
+
# Apps
|
|
764
|
+
apps = {}
|
|
765
|
+
proxies = {}
|
|
766
|
+
httpProxies = {}
|
|
767
|
+
tcpProxies = {}
|
|
768
|
+
sites = {}
|
|
769
|
+
resolvedCerts = {}
|
|
770
|
+
streamUpstreams = {}
|
|
771
|
+
streams = []
|
|
772
|
+
|
|
773
|
+
if config.apps?
|
|
774
|
+
unless isPlainObject(config.apps)
|
|
775
|
+
pushError(errors, 'E_APPS_TYPE', 'apps', 'apps must be an object')
|
|
776
|
+
else
|
|
777
|
+
for appName, appSpec of config.apps
|
|
778
|
+
unless typeof appSpec is 'string'
|
|
779
|
+
if isPlainObject(appSpec)
|
|
780
|
+
pushError(errors, 'E_APP_OBJECT', "apps.#{appName}", "object app specs are not supported; use a string like 'siteName1 siteName2'")
|
|
781
|
+
else
|
|
782
|
+
pushError(errors, 'E_APP_INVALID', "apps.#{appName}", "app value must be a string")
|
|
783
|
+
continue
|
|
784
|
+
|
|
785
|
+
parsed = parseAppSpec(appName, appSpec, knownSites, errors, "apps.#{appName}")
|
|
786
|
+
continue unless parsed
|
|
787
|
+
|
|
788
|
+
hostnames = parsed.siteNames.map (s) -> siteToHost.get(s)
|
|
789
|
+
|
|
790
|
+
if parsed.kind is 'local'
|
|
791
|
+
entryPath = if parsed.target is '.' then baseDir else resolve(baseDir, parsed.target)
|
|
792
|
+
indexFile = join(entryPath, 'index.rip')
|
|
793
|
+
entry = if existsSync(indexFile) then indexFile else null
|
|
794
|
+
|
|
795
|
+
apps[appName] =
|
|
796
|
+
id: appName
|
|
797
|
+
entry: entry
|
|
798
|
+
hosts: hostnames
|
|
799
|
+
workers: null
|
|
800
|
+
maxQueue: 512
|
|
801
|
+
queueTimeoutMs: 30000
|
|
802
|
+
readTimeoutMs: 30000
|
|
803
|
+
env: {}
|
|
804
|
+
|
|
805
|
+
for hostname in hostnames
|
|
806
|
+
sites[hostname] =
|
|
807
|
+
host: hostname
|
|
808
|
+
timeouts: {}
|
|
809
|
+
routes: [
|
|
810
|
+
id: "#{appName}-default"
|
|
811
|
+
host: hostname
|
|
812
|
+
path: '/'
|
|
813
|
+
methods: '*'
|
|
814
|
+
priority: 0
|
|
815
|
+
proxy: null
|
|
816
|
+
app: appName
|
|
817
|
+
static: null
|
|
818
|
+
root: null
|
|
819
|
+
spa: false
|
|
820
|
+
browse: false
|
|
821
|
+
redirect: null
|
|
822
|
+
headers: null
|
|
823
|
+
websocket: false
|
|
824
|
+
timeouts: {}
|
|
825
|
+
]
|
|
826
|
+
|
|
827
|
+
cert = matchCertForHost(certMap, hostname) if certMap.size > 0
|
|
828
|
+
resolvedCerts[hostname] = cert if cert
|
|
829
|
+
|
|
830
|
+
else if parsed.kind is 'http-proxy'
|
|
831
|
+
proxyId = "proxy-#{appName}"
|
|
832
|
+
url = new URL(parsed.target)
|
|
833
|
+
proxies[proxyId] =
|
|
834
|
+
id: proxyId
|
|
835
|
+
kind: 'http'
|
|
836
|
+
hosts: ["#{url.protocol}//#{url.host}"]
|
|
837
|
+
targets: [{ url: parsed.target, weight: 1 }]
|
|
838
|
+
healthCheck: null
|
|
839
|
+
retry: { count: 1, delayMs: 0 }
|
|
840
|
+
timeouts: { connectMs: 10000, readMs: 30000 }
|
|
841
|
+
httpProxies[proxyId] = proxies[proxyId]
|
|
842
|
+
|
|
843
|
+
for hostname in hostnames
|
|
844
|
+
sites[hostname] =
|
|
845
|
+
host: hostname
|
|
846
|
+
timeouts: {}
|
|
847
|
+
routes: [
|
|
848
|
+
id: "#{appName}-proxy"
|
|
849
|
+
host: hostname
|
|
850
|
+
path: '/'
|
|
851
|
+
methods: '*'
|
|
852
|
+
priority: 0
|
|
853
|
+
proxy: proxyId
|
|
854
|
+
app: null
|
|
855
|
+
static: null
|
|
856
|
+
root: null
|
|
857
|
+
spa: false
|
|
858
|
+
browse: false
|
|
859
|
+
redirect: null
|
|
860
|
+
headers: null
|
|
861
|
+
websocket: true
|
|
862
|
+
timeouts: {}
|
|
863
|
+
]
|
|
864
|
+
|
|
865
|
+
cert = matchCertForHost(certMap, hostname) if certMap.size > 0
|
|
866
|
+
resolvedCerts[hostname] = cert if cert
|
|
867
|
+
|
|
868
|
+
else if parsed.kind is 'tcp-proxy'
|
|
869
|
+
proxyId = "tcp-#{appName}"
|
|
870
|
+
url = new URL(parsed.target.replace('tcp://', 'http://'))
|
|
871
|
+
tcpProxies[proxyId] =
|
|
872
|
+
id: proxyId
|
|
873
|
+
targets: [{ host: url.hostname, port: parseInt(url.port) }]
|
|
874
|
+
connectTimeoutMs: 10000
|
|
875
|
+
streamUpstreams[proxyId] = tcpProxies[proxyId]
|
|
876
|
+
|
|
877
|
+
for hostname in hostnames
|
|
878
|
+
streams.push
|
|
879
|
+
id: proxyId
|
|
880
|
+
order: streams.length
|
|
881
|
+
listen: 443
|
|
882
|
+
sni: [hostname]
|
|
883
|
+
proxy: proxyId
|
|
884
|
+
timeouts: {}
|
|
885
|
+
|
|
886
|
+
cert = matchCertForHost(certMap, hostname) if certMap.size > 0
|
|
887
|
+
resolvedCerts[hostname] = cert if cert
|
|
888
|
+
|
|
889
|
+
throw validationError(CONFIG_FILE, errors) if errors.length > 0
|
|
890
|
+
|
|
891
|
+
{
|
|
892
|
+
kind: 'serve'
|
|
893
|
+
version: config.version or 2
|
|
894
|
+
server: serverSettings
|
|
895
|
+
proxies
|
|
896
|
+
upstreams: httpProxies
|
|
897
|
+
apps
|
|
898
|
+
certs: {}
|
|
899
|
+
rules: {}
|
|
900
|
+
groups: {}
|
|
901
|
+
routes: []
|
|
902
|
+
sites
|
|
903
|
+
resolvedCerts
|
|
904
|
+
streamUpstreams
|
|
905
|
+
streams
|
|
906
|
+
}
|
|
907
|
+
|
|
690
908
|
export normalizeConfig = (config, baseDir) ->
|
|
691
|
-
|
|
909
|
+
normalizeServeRip(config, baseDir)
|
|
692
910
|
|
|
693
911
|
export normalizeEdgeConfig = (config, baseDir) -> normalizeConfig(config, baseDir)
|
|
694
912
|
|
package/serving/tls.rip
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# serving/tls.rip — TLS material loading helpers
|
|
3
3
|
# ==============================================================================
|
|
4
4
|
|
|
5
|
-
import { readFileSync } from 'node:fs'
|
|
6
|
-
import { join } from 'node:path'
|
|
5
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
6
|
+
import { join, basename, extname } from 'node:path'
|
|
7
7
|
import { X509Certificate } from 'node:crypto'
|
|
8
8
|
|
|
9
9
|
hostSpecificity = (serverName) ->
|
|
@@ -66,3 +66,69 @@ export loadTlsMaterial = (flags, serverDir, acmeLoadCertFn) ->
|
|
|
66
66
|
warn "rip-server: failed to load TLS certs from #{certsDir}: #{e.message}"
|
|
67
67
|
warn 'Use --cert/--key to provide your own, or use http to disable TLS.'
|
|
68
68
|
exit 2
|
|
69
|
+
|
|
70
|
+
# ==============================================================================
|
|
71
|
+
# SSL directory scanning — scan for cert/key pairs, extract SANs
|
|
72
|
+
# ==============================================================================
|
|
73
|
+
|
|
74
|
+
normalizeHostname = (host) ->
|
|
75
|
+
return null unless typeof host is 'string'
|
|
76
|
+
host = host.trim().toLowerCase().replace(/\.$/, '')
|
|
77
|
+
return null unless host.length > 0
|
|
78
|
+
host
|
|
79
|
+
|
|
80
|
+
parseCertSans = (certPath) ->
|
|
81
|
+
pem = readFileSync(certPath, 'utf8')
|
|
82
|
+
x = new X509Certificate(pem)
|
|
83
|
+
san = x.subjectAltName or ''
|
|
84
|
+
san.split(',')
|
|
85
|
+
.map (entry) -> entry.trim()
|
|
86
|
+
.filter (entry) -> entry.startsWith('DNS:')
|
|
87
|
+
.map (entry) -> normalizeHostname(entry.slice(4))
|
|
88
|
+
.filter Boolean
|
|
89
|
+
|
|
90
|
+
export scanSslDirectory = (sslPath) ->
|
|
91
|
+
certMap = new Map()
|
|
92
|
+
return certMap unless sslPath
|
|
93
|
+
|
|
94
|
+
files = try
|
|
95
|
+
readdirSync(sslPath).sort()
|
|
96
|
+
catch err
|
|
97
|
+
throw new Error "cannot read ssl directory #{sslPath}: #{err.message}"
|
|
98
|
+
|
|
99
|
+
fileSet = new Set(files)
|
|
100
|
+
|
|
101
|
+
for crtFile in files
|
|
102
|
+
continue unless extname(crtFile) is '.crt'
|
|
103
|
+
stem = basename(crtFile, '.crt')
|
|
104
|
+
keyFile = "#{stem}.key"
|
|
105
|
+
continue unless fileSet.has(keyFile)
|
|
106
|
+
|
|
107
|
+
certPath = join(sslPath, crtFile)
|
|
108
|
+
keyPath = join(sslPath, keyFile)
|
|
109
|
+
|
|
110
|
+
sans = null
|
|
111
|
+
try
|
|
112
|
+
sans = parseCertSans(certPath)
|
|
113
|
+
catch err
|
|
114
|
+
warn "rip-server: failed to parse certificate #{certPath}: #{err.message}"
|
|
115
|
+
continue unless sans
|
|
116
|
+
|
|
117
|
+
for san in sans
|
|
118
|
+
certMap.set(san, { certPath, keyPath }) unless certMap.has(san)
|
|
119
|
+
|
|
120
|
+
certMap
|
|
121
|
+
|
|
122
|
+
export matchCertForHost = (certMap, hostname) ->
|
|
123
|
+
host = normalizeHostname(hostname)
|
|
124
|
+
return null unless host
|
|
125
|
+
|
|
126
|
+
return certMap.get(host) if certMap.has(host)
|
|
127
|
+
|
|
128
|
+
hostLabels = host.split('.')
|
|
129
|
+
return null unless hostLabels.length >= 2
|
|
130
|
+
|
|
131
|
+
wildcard = "*.#{hostLabels.slice(1).join('.')}"
|
|
132
|
+
return certMap.get(wildcard) if certMap.has(wildcard)
|
|
133
|
+
|
|
134
|
+
null
|