@rip-lang/server 1.3.110 → 1.3.112
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/acme/client.rip +138 -0
- package/acme/crypto.rip +130 -0
- package/acme/manager.rip +107 -0
- package/acme/store.rip +67 -0
- package/control/cli.rip +123 -0
- package/control/control.rip +75 -0
- package/control/lifecycle.rip +71 -0
- package/control/mdns.rip +53 -0
- package/control/watchers.rip +68 -0
- package/control/worker.rip +136 -0
- package/control/workers.rip +53 -0
- package/edge/config.rip +75 -0
- package/edge/forwarding.rip +149 -0
- package/edge/metrics.rip +85 -0
- package/edge/queue.rip +53 -0
- package/edge/ratelimit.rip +63 -0
- package/edge/realtime.rip +200 -0
- package/edge/registry.rip +44 -0
- package/edge/security.rip +38 -0
- package/edge/tls.rip +49 -0
- package/package.json +6 -3
package/acme/client.rip
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# acme/client.rip — RFC 8555 ACME protocol client (zero dependencies)
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { signJws, thumbprint } from './crypto.rip'
|
|
6
|
+
|
|
7
|
+
LETS_ENCRYPT = 'https://acme-v02.api.letsencrypt.org/directory'
|
|
8
|
+
LETS_ENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
|
9
|
+
|
|
10
|
+
export { LETS_ENCRYPT, LETS_ENCRYPT_STAGING }
|
|
11
|
+
|
|
12
|
+
# --- Nonce management ---
|
|
13
|
+
|
|
14
|
+
getNonce = (client) ->
|
|
15
|
+
if client.nonces.length > 0
|
|
16
|
+
return client.nonces.pop()
|
|
17
|
+
res = fetch!(client.directory.newNonce, { method: 'HEAD' })
|
|
18
|
+
res.headers.get('replay-nonce')
|
|
19
|
+
|
|
20
|
+
saveNonce = (client, res) ->
|
|
21
|
+
nonce = res.headers.get('replay-nonce')
|
|
22
|
+
client.nonces.push(nonce) if nonce
|
|
23
|
+
|
|
24
|
+
# --- Signed request ---
|
|
25
|
+
|
|
26
|
+
acmeRequest = (client, url, payload, retryCount = 0) ->
|
|
27
|
+
nonce = getNonce!(client)
|
|
28
|
+
body = signJws(payload, url, nonce, client.accountKey, client.publicJwk, client.kid)
|
|
29
|
+
res = fetch! url,
|
|
30
|
+
method: 'POST'
|
|
31
|
+
headers: { 'content-type': 'application/jose+json' }
|
|
32
|
+
body: body
|
|
33
|
+
saveNonce(client, res)
|
|
34
|
+
|
|
35
|
+
if res.status >= 400
|
|
36
|
+
errBody = try res.json!
|
|
37
|
+
# Retry once on badNonce (RFC 8555 Section 6.5)
|
|
38
|
+
if errBody?.type is 'urn:ietf:params:acme:error:badNonce' and retryCount < 1
|
|
39
|
+
return acmeRequest!(client, url, payload, retryCount + 1)
|
|
40
|
+
throw new Error("ACME error #{res.status}: #{errBody?.detail or res.statusText}")
|
|
41
|
+
|
|
42
|
+
location = res.headers.get('location')
|
|
43
|
+
contentType = res.headers.get('content-type') or ''
|
|
44
|
+
|
|
45
|
+
data = if contentType.includes('json')
|
|
46
|
+
res.json!
|
|
47
|
+
else
|
|
48
|
+
res.text!
|
|
49
|
+
|
|
50
|
+
{ data, location, status: res.status, headers: res.headers }
|
|
51
|
+
|
|
52
|
+
# --- Client lifecycle ---
|
|
53
|
+
|
|
54
|
+
export createClient = (directoryUrl) ->
|
|
55
|
+
directoryUrl = directoryUrl or LETS_ENCRYPT
|
|
56
|
+
res = fetch!(directoryUrl)
|
|
57
|
+
directory = res.json!
|
|
58
|
+
{
|
|
59
|
+
directoryUrl
|
|
60
|
+
directory
|
|
61
|
+
accountKey: null
|
|
62
|
+
publicJwk: null
|
|
63
|
+
kid: null
|
|
64
|
+
nonces: []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export setAccountKey = (client, privateKey, publicJwk) ->
|
|
68
|
+
client.accountKey = privateKey
|
|
69
|
+
client.publicJwk = publicJwk
|
|
70
|
+
client
|
|
71
|
+
|
|
72
|
+
# --- Account ---
|
|
73
|
+
|
|
74
|
+
export newAccount = (client) ->
|
|
75
|
+
{ data, location } = acmeRequest! client, client.directory.newAccount,
|
|
76
|
+
termsOfServiceAgreed: true
|
|
77
|
+
client.kid = location
|
|
78
|
+
{ accountUrl: location, account: data }
|
|
79
|
+
|
|
80
|
+
# --- Order ---
|
|
81
|
+
|
|
82
|
+
export newOrder = (client, domains) ->
|
|
83
|
+
identifiers = domains.map((d) -> { type: 'dns', value: d })
|
|
84
|
+
{ data, location } = acmeRequest! client, client.directory.newOrder, { identifiers }
|
|
85
|
+
{ orderUrl: location, order: data }
|
|
86
|
+
|
|
87
|
+
export getAuthorization = (client, authUrl) ->
|
|
88
|
+
{ data } = acmeRequest! client, authUrl, ''
|
|
89
|
+
data
|
|
90
|
+
|
|
91
|
+
export getHttp01Challenge = (auth) ->
|
|
92
|
+
auth.challenges.find((c) -> c.type is 'http-01')
|
|
93
|
+
|
|
94
|
+
export keyAuthorization = (token, publicJwk) ->
|
|
95
|
+
"#{token}.#{thumbprint(publicJwk)}"
|
|
96
|
+
|
|
97
|
+
export respondToChallenge = (client, challengeUrl) ->
|
|
98
|
+
{ data } = acmeRequest! client, challengeUrl, {}
|
|
99
|
+
data
|
|
100
|
+
|
|
101
|
+
# --- Polling ---
|
|
102
|
+
|
|
103
|
+
export pollOrderStatus = (client, orderUrl, maxAttempts = 30, intervalMs = 2000) ->
|
|
104
|
+
for i in [0...maxAttempts]
|
|
105
|
+
{ data } = acmeRequest! client, orderUrl, ''
|
|
106
|
+
return data if data.status is 'valid'
|
|
107
|
+
return data if data.status is 'invalid'
|
|
108
|
+
throw new Error("ACME order failed: #{data.status}") if data.status in ['expired', 'revoked', 'deactivated']
|
|
109
|
+
await new Promise (r) -> setTimeout(r, intervalMs)
|
|
110
|
+
throw new Error('ACME order timed out waiting for validation')
|
|
111
|
+
|
|
112
|
+
export pollAuthorizationStatus = (client, authUrl, maxAttempts = 30, intervalMs = 2000) ->
|
|
113
|
+
for i in [0...maxAttempts]
|
|
114
|
+
{ data } = acmeRequest! client, authUrl, ''
|
|
115
|
+
return data if data.status is 'valid'
|
|
116
|
+
throw new Error("ACME authorization failed: #{data.status}") if data.status is 'invalid'
|
|
117
|
+
await new Promise (r) -> setTimeout(r, intervalMs)
|
|
118
|
+
throw new Error('ACME authorization timed out')
|
|
119
|
+
|
|
120
|
+
# --- Finalize and download ---
|
|
121
|
+
|
|
122
|
+
export finalizeOrder = (client, finalizeUrl, csrB64url) ->
|
|
123
|
+
{ data } = acmeRequest! client, finalizeUrl, { csr: csrB64url }
|
|
124
|
+
data
|
|
125
|
+
|
|
126
|
+
export downloadCert = (client, certUrl) ->
|
|
127
|
+
nonce = getNonce!(client)
|
|
128
|
+
body = signJws('', certUrl, nonce, client.accountKey, client.publicJwk, client.kid)
|
|
129
|
+
res = fetch! certUrl,
|
|
130
|
+
method: 'POST'
|
|
131
|
+
headers:
|
|
132
|
+
'content-type': 'application/jose+json'
|
|
133
|
+
'accept': 'application/pem-certificate-chain'
|
|
134
|
+
body: body
|
|
135
|
+
saveNonce(client, res)
|
|
136
|
+
if res.status >= 400
|
|
137
|
+
throw new Error("ACME cert download failed: #{res.status}")
|
|
138
|
+
res.text!
|
package/acme/crypto.rip
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# acme/crypto.rip — ACME protocol crypto helpers (zero dependencies)
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { generateKeyPairSync, createSign, createHash, createPrivateKey } from 'node:crypto'
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join, dirname } from 'node:path'
|
|
9
|
+
|
|
10
|
+
# --- Base64url ---
|
|
11
|
+
|
|
12
|
+
export b64url = (buf) ->
|
|
13
|
+
s = if typeof buf is 'string' then Buffer.from(buf).toString('base64') else Buffer.from(buf).toString('base64')
|
|
14
|
+
s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
15
|
+
|
|
16
|
+
b64urlBuf = (buf) -> b64url(buf)
|
|
17
|
+
|
|
18
|
+
# --- Key generation ---
|
|
19
|
+
|
|
20
|
+
export generateAccountKeyPair = ->
|
|
21
|
+
generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
22
|
+
|
|
23
|
+
export generateCertKeyPair = ->
|
|
24
|
+
generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
25
|
+
|
|
26
|
+
export exportPrivateKeyPem = (keyPair) ->
|
|
27
|
+
keyPair.privateKey.export({ format: 'pem', type: 'pkcs8' })
|
|
28
|
+
|
|
29
|
+
export exportPublicJwk = (keyPair) ->
|
|
30
|
+
jwk = keyPair.publicKey.export({ format: 'jwk' })
|
|
31
|
+
{ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }
|
|
32
|
+
|
|
33
|
+
# --- Account key persistence ---
|
|
34
|
+
|
|
35
|
+
export loadOrCreateAccountKey = (storePath) ->
|
|
36
|
+
accountFile = join(storePath, 'account.json')
|
|
37
|
+
if existsSync(accountFile)
|
|
38
|
+
data = JSON.parse(readFileSync(accountFile, 'utf8'))
|
|
39
|
+
privKey = createPrivateKey({ key: Buffer.from(data.privateKeyPem), format: 'pem' })
|
|
40
|
+
pubJwk = data.publicJwk
|
|
41
|
+
return { privateKey: privKey, publicJwk: pubJwk }
|
|
42
|
+
|
|
43
|
+
pair = generateAccountKeyPair()
|
|
44
|
+
pubJwk = exportPublicJwk(pair)
|
|
45
|
+
privPem = exportPrivateKeyPem(pair)
|
|
46
|
+
mkdirSync(dirname(accountFile), { recursive: true, mode: 0o700 }) unless existsSync(dirname(accountFile))
|
|
47
|
+
writeFileSync(accountFile, JSON.stringify({ publicJwk: pubJwk, privateKeyPem: privPem }), { encoding: 'utf8', mode: 0o600 })
|
|
48
|
+
{ privateKey: pair.privateKey, publicJwk: pubJwk }
|
|
49
|
+
|
|
50
|
+
# --- JWK Thumbprint (RFC 7638) ---
|
|
51
|
+
|
|
52
|
+
export thumbprint = (jwk) ->
|
|
53
|
+
canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })
|
|
54
|
+
hash = createHash('sha256').update(canonical).digest()
|
|
55
|
+
b64url(hash)
|
|
56
|
+
|
|
57
|
+
# --- JWS Signing (ES256 / ACME) ---
|
|
58
|
+
|
|
59
|
+
derToRaw = (derSig) ->
|
|
60
|
+
# Convert DER-encoded ECDSA signature to raw r||s (64 bytes for P-256)
|
|
61
|
+
offset = 2
|
|
62
|
+
rLen = derSig[offset + 1]
|
|
63
|
+
r = derSig.slice(offset + 2, offset + 2 + rLen)
|
|
64
|
+
offset = offset + 2 + rLen
|
|
65
|
+
sLen = derSig[offset + 1]
|
|
66
|
+
s = derSig.slice(offset + 2, offset + 2 + sLen)
|
|
67
|
+
# Pad or trim to 32 bytes each
|
|
68
|
+
pad = (buf, len) ->
|
|
69
|
+
return buf.slice(buf.length - len) if buf.length > len
|
|
70
|
+
return buf if buf.length is len
|
|
71
|
+
out = Buffer.alloc(len)
|
|
72
|
+
buf.copy(out, len - buf.length)
|
|
73
|
+
out
|
|
74
|
+
Buffer.concat([pad(r, 32), pad(s, 32)])
|
|
75
|
+
|
|
76
|
+
export signJws = (payload, url, nonce, privateKey, publicJwk, kid) ->
|
|
77
|
+
protectedHeader = if kid
|
|
78
|
+
{ alg: 'ES256', kid, nonce, url }
|
|
79
|
+
else
|
|
80
|
+
{ alg: 'ES256', jwk: publicJwk, nonce, url }
|
|
81
|
+
|
|
82
|
+
protectedB64 = b64url(JSON.stringify(protectedHeader))
|
|
83
|
+
payloadB64 = if payload is '' then '' else b64url(JSON.stringify(payload))
|
|
84
|
+
|
|
85
|
+
sigInput = "#{protectedB64}.#{payloadB64}"
|
|
86
|
+
signer = createSign('SHA256')
|
|
87
|
+
signer.update(sigInput)
|
|
88
|
+
derSig = signer.sign(privateKey)
|
|
89
|
+
rawSig = derToRaw(derSig)
|
|
90
|
+
|
|
91
|
+
JSON.stringify
|
|
92
|
+
protected: protectedB64
|
|
93
|
+
payload: payloadB64
|
|
94
|
+
signature: b64url(rawSig)
|
|
95
|
+
|
|
96
|
+
# --- CSR generation via openssl ---
|
|
97
|
+
|
|
98
|
+
export generateCsr = (domain, keyPem) ->
|
|
99
|
+
# Write key to temp file, generate CSR, read it back
|
|
100
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'rip-acme-'))
|
|
101
|
+
keyPath = join(tmpDir, 'key.pem')
|
|
102
|
+
csrPath = join(tmpDir, 'csr.der')
|
|
103
|
+
extPath = join(tmpDir, 'san.cnf')
|
|
104
|
+
writeFileSync(keyPath, keyPem, { encoding: 'utf8', mode: 0o600 })
|
|
105
|
+
|
|
106
|
+
# OpenSSL config with SAN extension (required by modern CAs)
|
|
107
|
+
sanConfig = "[req]\ndistinguished_name=dn\nreq_extensions=san\n[dn]\nCN=#{domain}\n[san]\nsubjectAltName=DNS:#{domain}\n"
|
|
108
|
+
writeFileSync(extPath, sanConfig, { encoding: 'utf8', mode: 0o600 })
|
|
109
|
+
|
|
110
|
+
result = Bun.spawnSync [
|
|
111
|
+
'openssl', 'req', '-new'
|
|
112
|
+
'-key', keyPath
|
|
113
|
+
'-subj', "/CN=#{domain}"
|
|
114
|
+
'-config', extPath
|
|
115
|
+
'-outform', 'DER'
|
|
116
|
+
'-out', csrPath
|
|
117
|
+
],
|
|
118
|
+
stdout: 'pipe'
|
|
119
|
+
stderr: 'pipe'
|
|
120
|
+
|
|
121
|
+
if result.exitCode isnt 0
|
|
122
|
+
stderr = Buffer.from(result.stderr or []).toString('utf8')
|
|
123
|
+
throw new Error("CSR generation failed: #{stderr}")
|
|
124
|
+
|
|
125
|
+
csrDer = readFileSync(csrPath)
|
|
126
|
+
|
|
127
|
+
# Cleanup
|
|
128
|
+
try rmSync(tmpDir, { recursive: true, force: true })
|
|
129
|
+
|
|
130
|
+
b64url(csrDer)
|
package/acme/manager.rip
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# acme/manager.rip — ACME certificate lifecycle orchestrator
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { loadOrCreateAccountKey, generateCertKeyPair, exportPrivateKeyPem, generateCsr } from './crypto.rip'
|
|
6
|
+
import { createClient, setAccountKey, newAccount, newOrder, getAuthorization, getHttp01Challenge, keyAuthorization, respondToChallenge, pollAuthorizationStatus, pollOrderStatus, finalizeOrder, downloadCert, LETS_ENCRYPT, LETS_ENCRYPT_STAGING } from './client.rip'
|
|
7
|
+
import { defaultCertDir, saveCert, loadCert, needsRenewal } from './store.rip'
|
|
8
|
+
|
|
9
|
+
export createAcmeManager = (options = {}) ->
|
|
10
|
+
certDir = options.certDir or defaultCertDir()
|
|
11
|
+
staging = options.staging or false
|
|
12
|
+
directoryUrl = if staging then LETS_ENCRYPT_STAGING else LETS_ENCRYPT
|
|
13
|
+
challengeStore = options.challengeStore or null
|
|
14
|
+
onCertRenewed = options.onCertRenewed or (->)
|
|
15
|
+
renewalTimer = null
|
|
16
|
+
|
|
17
|
+
manager =
|
|
18
|
+
certDir: certDir
|
|
19
|
+
staging: staging
|
|
20
|
+
directoryUrl: directoryUrl
|
|
21
|
+
challengeStore: challengeStore
|
|
22
|
+
onCertRenewed: onCertRenewed
|
|
23
|
+
|
|
24
|
+
obtainCert: (domain) ->
|
|
25
|
+
p "rip-acme: obtaining cert for #{domain}#{if staging then ' (staging)' else ''}"
|
|
26
|
+
|
|
27
|
+
# Load or create account key
|
|
28
|
+
account = loadOrCreateAccountKey(certDir)
|
|
29
|
+
|
|
30
|
+
# Create ACME client and register account
|
|
31
|
+
client = createClient!(directoryUrl)
|
|
32
|
+
setAccountKey(client, account.privateKey, account.publicJwk)
|
|
33
|
+
newAccount!(client)
|
|
34
|
+
|
|
35
|
+
# Create order
|
|
36
|
+
{ orderUrl, order } = newOrder!(client, [domain])
|
|
37
|
+
|
|
38
|
+
# Process authorizations
|
|
39
|
+
for authUrl in order.authorizations
|
|
40
|
+
auth = getAuthorization!(client, authUrl)
|
|
41
|
+
continue if auth.status is 'valid'
|
|
42
|
+
|
|
43
|
+
challenge = getHttp01Challenge(auth)
|
|
44
|
+
unless challenge
|
|
45
|
+
throw new Error("No HTTP-01 challenge available for #{domain}")
|
|
46
|
+
|
|
47
|
+
# Set challenge token for the HTTP server to serve
|
|
48
|
+
keyAuth = keyAuthorization(challenge.token, client.publicJwk)
|
|
49
|
+
if challengeStore
|
|
50
|
+
challengeStore.set(challenge.token, keyAuth)
|
|
51
|
+
|
|
52
|
+
try
|
|
53
|
+
# Tell CA we're ready
|
|
54
|
+
respondToChallenge!(client, challenge.url)
|
|
55
|
+
|
|
56
|
+
# Wait for authorization to be validated
|
|
57
|
+
pollAuthorizationStatus!(client, authUrl)
|
|
58
|
+
finally
|
|
59
|
+
# Always clean up challenge token, even on failure
|
|
60
|
+
challengeStore.remove(challenge.token) if challengeStore
|
|
61
|
+
|
|
62
|
+
# Generate cert key and CSR
|
|
63
|
+
certKey = generateCertKeyPair()
|
|
64
|
+
certKeyPem = exportPrivateKeyPem(certKey)
|
|
65
|
+
csrB64 = generateCsr(domain, certKeyPem)
|
|
66
|
+
|
|
67
|
+
# Finalize order with CSR
|
|
68
|
+
finalizeOrder!(client, order.finalize, csrB64)
|
|
69
|
+
|
|
70
|
+
# Poll until cert is ready
|
|
71
|
+
completedOrder = pollOrderStatus!(client, orderUrl)
|
|
72
|
+
unless completedOrder.certificate
|
|
73
|
+
throw new Error('ACME order completed but no certificate URL returned')
|
|
74
|
+
|
|
75
|
+
# Download cert chain
|
|
76
|
+
certPem = downloadCert!(client, completedOrder.certificate)
|
|
77
|
+
|
|
78
|
+
# Store cert and key
|
|
79
|
+
saveCert(certDir, domain, certPem, certKeyPem)
|
|
80
|
+
p "rip-acme: cert obtained and stored for #{domain}"
|
|
81
|
+
|
|
82
|
+
{ cert: certPem, key: certKeyPem }
|
|
83
|
+
|
|
84
|
+
renewIfNeeded: (domain) ->
|
|
85
|
+
if needsRenewal(certDir, domain)
|
|
86
|
+
p "rip-acme: cert for #{domain} needs renewal"
|
|
87
|
+
result = manager.obtainCert!(domain)
|
|
88
|
+
manager.onCertRenewed(domain, result)
|
|
89
|
+
return true
|
|
90
|
+
false
|
|
91
|
+
|
|
92
|
+
startRenewalLoop: (domains, intervalMs = 12 * 60 * 60 * 1000) ->
|
|
93
|
+
manager.stopRenewalLoop()
|
|
94
|
+
check = ->
|
|
95
|
+
for domain in domains
|
|
96
|
+
try manager.renewIfNeeded!(domain)
|
|
97
|
+
catch e
|
|
98
|
+
console.error "rip-acme: renewal failed for #{domain}:", e.message
|
|
99
|
+
renewalTimer = setInterval(check, intervalMs)
|
|
100
|
+
# Run first check after a short delay
|
|
101
|
+
setTimeout(check, 5000)
|
|
102
|
+
|
|
103
|
+
stopRenewalLoop: ->
|
|
104
|
+
clearInterval(renewalTimer) if renewalTimer
|
|
105
|
+
renewalTimer = null
|
|
106
|
+
|
|
107
|
+
manager
|
package/acme/store.rip
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# acme/store.rip — certificate storage, lookup, and HTTP-01 challenge store
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { X509Certificate } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
export defaultCertDir = -> join(homedir(), '.rip', 'certs')
|
|
11
|
+
|
|
12
|
+
export ensureDir = (dir) ->
|
|
13
|
+
mkdirSync(dir, { recursive: true }) unless existsSync(dir)
|
|
14
|
+
dir
|
|
15
|
+
|
|
16
|
+
export domainDir = (baseDir, domain) ->
|
|
17
|
+
ensureDir(join(ensureDir(baseDir), domain))
|
|
18
|
+
|
|
19
|
+
export saveCert = (baseDir, domain, certPem, keyPem) ->
|
|
20
|
+
dir = domainDir(baseDir, domain)
|
|
21
|
+
writeFileSync(join(dir, 'cert.pem'), certPem, { encoding: 'utf8', mode: 0o644 })
|
|
22
|
+
writeFileSync(join(dir, 'key.pem'), keyPem, { encoding: 'utf8', mode: 0o600 })
|
|
23
|
+
{ certPath: join(dir, 'cert.pem'), keyPath: join(dir, 'key.pem') }
|
|
24
|
+
|
|
25
|
+
export loadCert = (baseDir, domain) ->
|
|
26
|
+
dir = join(baseDir, domain)
|
|
27
|
+
certPath = join(dir, 'cert.pem')
|
|
28
|
+
keyPath = join(dir, 'key.pem')
|
|
29
|
+
return null unless existsSync(certPath) and existsSync(keyPath)
|
|
30
|
+
cert = readFileSync(certPath, 'utf8')
|
|
31
|
+
key = readFileSync(keyPath, 'utf8')
|
|
32
|
+
notAfter = null
|
|
33
|
+
try
|
|
34
|
+
x = new X509Certificate(cert)
|
|
35
|
+
notAfter = new Date(x.validTo).getTime()
|
|
36
|
+
{ cert, key, certPath, keyPath, notAfter }
|
|
37
|
+
|
|
38
|
+
export needsRenewal = (baseDir, domain, thresholdDays = 30) ->
|
|
39
|
+
rec = loadCert(baseDir, domain)
|
|
40
|
+
return true unless rec?.notAfter
|
|
41
|
+
msLeft = rec.notAfter - Date.now()
|
|
42
|
+
daysLeft = msLeft / (1000 * 60 * 60 * 24)
|
|
43
|
+
daysLeft < thresholdDays
|
|
44
|
+
|
|
45
|
+
export listCerts = (baseDir) ->
|
|
46
|
+
return [] unless existsSync(baseDir)
|
|
47
|
+
entries = readdirSync(baseDir, { withFileTypes: true })
|
|
48
|
+
entries.filter((e) -> e.isDirectory()).map((e) -> e.name)
|
|
49
|
+
|
|
50
|
+
# --- HTTP-01 challenge token store ---
|
|
51
|
+
|
|
52
|
+
export createChallengeStore = ->
|
|
53
|
+
tokens = new Map()
|
|
54
|
+
|
|
55
|
+
set: (token, keyAuthorization) -> tokens.set(token, keyAuthorization)
|
|
56
|
+
get: (token) -> tokens.get(token) ?? null
|
|
57
|
+
has: (token) -> tokens.has(token)
|
|
58
|
+
remove: (token) -> tokens.delete(token)
|
|
59
|
+
clear: -> tokens.clear()
|
|
60
|
+
|
|
61
|
+
export handleChallengeRequest = (url, challengeStore) ->
|
|
62
|
+
prefix = '/.well-known/acme-challenge/'
|
|
63
|
+
return null unless url.pathname.startsWith(prefix)
|
|
64
|
+
token = url.pathname.slice(prefix.length)
|
|
65
|
+
return null unless token and challengeStore.has(token)
|
|
66
|
+
keyAuth = challengeStore.get(token)
|
|
67
|
+
new Response(keyAuth, { headers: { 'content-type': 'application/octet-stream' } })
|
package/control/cli.rip
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/cli.rip — CLI parsing, subcommands, and output
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
# --- Argv helpers ---
|
|
6
|
+
|
|
7
|
+
export getKV = (argv, prefix) ->
|
|
8
|
+
for tok in argv
|
|
9
|
+
return tok.slice(prefix.length) if tok.startsWith(prefix)
|
|
10
|
+
undefined
|
|
11
|
+
|
|
12
|
+
export findAppPathToken = (argv, existsSyncFn, isAbsoluteFn, resolveFn, cwdFn) ->
|
|
13
|
+
for i in [2...argv.length]
|
|
14
|
+
tok = argv[i]
|
|
15
|
+
pathPart = if tok.includes('@') then tok.split('@')[0] else tok
|
|
16
|
+
looksLikePath = pathPart.includes('/') or pathPart.startsWith('.') or pathPart.endsWith('.rip') or pathPart.endsWith('.ts')
|
|
17
|
+
try
|
|
18
|
+
return pathPart if looksLikePath and existsSyncFn(if isAbsoluteFn(pathPart) then pathPart else resolveFn(cwdFn(), pathPart))
|
|
19
|
+
undefined
|
|
20
|
+
|
|
21
|
+
export computeSocketPrefix = (argv, resolveAppEntryFn, existsSyncFn, isAbsoluteFn, resolveFn, cwdFn) ->
|
|
22
|
+
override = getKV(argv, '--socket-prefix=')
|
|
23
|
+
return override if override
|
|
24
|
+
appTok = findAppPathToken(argv, existsSyncFn, isAbsoluteFn, resolveFn, cwdFn)
|
|
25
|
+
if appTok
|
|
26
|
+
try
|
|
27
|
+
{ appName } = resolveAppEntryFn(appTok)
|
|
28
|
+
return "rip_#{appName}"
|
|
29
|
+
'rip_server'
|
|
30
|
+
|
|
31
|
+
# --- Output ---
|
|
32
|
+
|
|
33
|
+
export runVersionOutput = (argv, readFileSyncFn, packageJsonPath) ->
|
|
34
|
+
return false unless '--version' in argv or '-v' in argv
|
|
35
|
+
try
|
|
36
|
+
pkg = JSON.parse(readFileSyncFn(packageJsonPath, 'utf8'))
|
|
37
|
+
p "rip-server v#{pkg.version}"
|
|
38
|
+
catch
|
|
39
|
+
p 'rip-server (version unknown)'
|
|
40
|
+
true
|
|
41
|
+
|
|
42
|
+
export runHelpOutput = (argv) ->
|
|
43
|
+
return false unless '--help' in argv or '-h' in argv
|
|
44
|
+
p """
|
|
45
|
+
rip-server - Pure Rip application server
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
rip serve [options] [app-path] [app-name]
|
|
49
|
+
rip serve [options] [app-path]@<alias1>,<alias2>,...
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
-h, --help Show this help
|
|
53
|
+
-v, --version Show version
|
|
54
|
+
--watch=<glob> Watch glob pattern (default: *.rip)
|
|
55
|
+
--static Disable hot reload and file watching
|
|
56
|
+
--env=<mode> Set environment (dev, production)
|
|
57
|
+
--debug Enable debug logging
|
|
58
|
+
--quiet Suppress URL lines (app prints its own)
|
|
59
|
+
|
|
60
|
+
Network:
|
|
61
|
+
http HTTP only (no TLS)
|
|
62
|
+
https HTTPS with trusted *.ripdev.io cert (default)
|
|
63
|
+
<port> Listen on specific port
|
|
64
|
+
--cert=<path> TLS certificate file (overrides shipped cert)
|
|
65
|
+
--key=<path> TLS key file (overrides shipped cert)
|
|
66
|
+
--hsts Enable HSTS header
|
|
67
|
+
--no-redirect-http Don't redirect HTTP to HTTPS
|
|
68
|
+
|
|
69
|
+
ACME (auto TLS):
|
|
70
|
+
--acme Enable auto TLS via Let's Encrypt
|
|
71
|
+
--acme-staging Use Let's Encrypt staging CA (for testing)
|
|
72
|
+
--acme-domain=<domain> Domain for ACME cert (required with --acme)
|
|
73
|
+
|
|
74
|
+
Realtime (WebSocket):
|
|
75
|
+
--realtime-path=<path> WebSocket endpoint path (default: /realtime)
|
|
76
|
+
|
|
77
|
+
Workers:
|
|
78
|
+
w:<n> Number of workers (default: cores/2)
|
|
79
|
+
w:auto One worker per core
|
|
80
|
+
r:<n>,<s>s Restart policy: max requests, max seconds
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
rip serve Start with ./index.rip (watches *.rip)
|
|
84
|
+
rip serve myapp Start with app name "myapp"
|
|
85
|
+
rip serve http HTTP only (no TLS)
|
|
86
|
+
rip serve --static w:8 Production: no reload, 8 workers
|
|
87
|
+
|
|
88
|
+
If no index.rip or index.ts is found, a built-in static file server
|
|
89
|
+
activates with directory indexes, auto-detected MIME types, and
|
|
90
|
+
hot-reload. Create an index.rip to customize server behavior.
|
|
91
|
+
"""
|
|
92
|
+
true
|
|
93
|
+
|
|
94
|
+
# --- Subcommands ---
|
|
95
|
+
|
|
96
|
+
export runStopSubcommand = (argv, prefix, getPidFilePathFn, existsSyncFn, readFileSyncFn, killFn, spawnSyncFn, importMetaPath) ->
|
|
97
|
+
return false unless 'stop' in argv
|
|
98
|
+
pidFile = getPidFilePathFn(prefix)
|
|
99
|
+
try
|
|
100
|
+
if existsSyncFn(pidFile)
|
|
101
|
+
pid = parseInt(readFileSyncFn(pidFile, 'utf8').trim())
|
|
102
|
+
killFn(pid, 'SIGTERM')
|
|
103
|
+
p "rip-server: sent SIGTERM to process #{pid}"
|
|
104
|
+
else
|
|
105
|
+
p "rip-server: no PID file found at #{pidFile}, trying pkill..."
|
|
106
|
+
spawnSyncFn(['pkill', '-f', importMetaPath])
|
|
107
|
+
catch e
|
|
108
|
+
console.error "rip-server: stop failed: #{e.message}"
|
|
109
|
+
true
|
|
110
|
+
|
|
111
|
+
export runListSubcommand = (argv, prefix, getControlSocketPathFn, fetchFn, exitFn) ->
|
|
112
|
+
return false unless 'list' in argv
|
|
113
|
+
controlUnix = getControlSocketPathFn(prefix)
|
|
114
|
+
try
|
|
115
|
+
res = fetchFn!('http://localhost/registry', { unix: controlUnix, method: 'GET' })
|
|
116
|
+
throw new Error("list failed: #{res.status}") unless res.ok
|
|
117
|
+
j = res.json!
|
|
118
|
+
hosts = if Array.isArray(j?.hosts) then j.hosts else []
|
|
119
|
+
p if hosts.length then hosts.join('\n') else '(no hosts)'
|
|
120
|
+
catch e
|
|
121
|
+
console.error "list command failed: #{e?.message or e}"
|
|
122
|
+
exitFn(1)
|
|
123
|
+
true
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# control/control.rip — paths, worker registry, and control socket handlers
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
# --- Socket and PID paths ---
|
|
6
|
+
|
|
7
|
+
export getWorkerSocketPath = (prefix, id) -> "/tmp/#{prefix}.#{id}.sock"
|
|
8
|
+
export getControlSocketPath = (prefix) -> "/tmp/#{prefix}.ctl.sock"
|
|
9
|
+
export getPidFilePath = (prefix) -> "/tmp/#{prefix}.pid"
|
|
10
|
+
|
|
11
|
+
# --- Worker registry ---
|
|
12
|
+
|
|
13
|
+
registerWorker = (sockets, availableWorkers, payload) ->
|
|
14
|
+
version = if typeof payload.version is 'number' then payload.version else null
|
|
15
|
+
exists = sockets.find((x) -> x.socket is payload.socket)
|
|
16
|
+
unless exists
|
|
17
|
+
worker = { socket: payload.socket, inflight: 0, version, workerId: payload.workerId }
|
|
18
|
+
sockets.push(worker)
|
|
19
|
+
availableWorkers.push(worker)
|
|
20
|
+
{ version }
|
|
21
|
+
|
|
22
|
+
removeWorkerById = (sockets, availableWorkers, workerId) ->
|
|
23
|
+
# Mutate in-place to avoid race conditions with concurrent join/quit
|
|
24
|
+
i = sockets.length
|
|
25
|
+
while i-- > 0
|
|
26
|
+
sockets.splice(i, 1) if sockets[i].workerId is workerId
|
|
27
|
+
i = availableWorkers.length
|
|
28
|
+
while i-- > 0
|
|
29
|
+
availableWorkers.splice(i, 1) if availableWorkers[i].workerId is workerId
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
# --- Control socket handlers ---
|
|
33
|
+
|
|
34
|
+
jsonOk = -> new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } })
|
|
35
|
+
jsonBad = -> new Response(JSON.stringify({ ok: false }), { status: 400, headers: { 'content-type': 'application/json' } })
|
|
36
|
+
|
|
37
|
+
export handleWorkerControl = (req, state) ->
|
|
38
|
+
return { handled: false } unless req.method is 'POST'
|
|
39
|
+
try
|
|
40
|
+
j = req.json!
|
|
41
|
+
if j?.op is 'join' and typeof j.socket is 'string' and typeof j.workerId is 'number'
|
|
42
|
+
{ version } = registerWorker(state.sockets, state.availableWorkers, j)
|
|
43
|
+
newestVersion = state.newestVersion
|
|
44
|
+
newestVersion = if newestVersion is null then version else Math.max(newestVersion, version) if version?
|
|
45
|
+
return
|
|
46
|
+
handled: true
|
|
47
|
+
response: jsonOk()
|
|
48
|
+
sockets: state.sockets
|
|
49
|
+
availableWorkers: state.availableWorkers
|
|
50
|
+
newestVersion
|
|
51
|
+
|
|
52
|
+
if j?.op is 'quit' and typeof j.workerId is 'number'
|
|
53
|
+
removeWorkerById(state.sockets, state.availableWorkers, j.workerId)
|
|
54
|
+
return
|
|
55
|
+
handled: true
|
|
56
|
+
response: jsonOk()
|
|
57
|
+
{ handled: true, response: jsonBad(), sockets: state.sockets, availableWorkers: state.availableWorkers, newestVersion: state.newestVersion }
|
|
58
|
+
|
|
59
|
+
export handleWatchControl = (req, registerWatch, watchDirs) ->
|
|
60
|
+
return { handled: false } unless req.method is 'POST'
|
|
61
|
+
try
|
|
62
|
+
j = req.json!
|
|
63
|
+
if j?.op is 'watch' and typeof j.prefix is 'string' and Array.isArray(j.dirs)
|
|
64
|
+
registerWatch(j.prefix)
|
|
65
|
+
watchDirs(j.prefix, j.dirs) if j.dirs.length > 0
|
|
66
|
+
return { handled: true, response: jsonOk() }
|
|
67
|
+
{ handled: true, response: jsonBad() }
|
|
68
|
+
|
|
69
|
+
export handleRegistryControl = (req, hostIndex) ->
|
|
70
|
+
return { handled: false } unless req.method is 'GET'
|
|
71
|
+
hostMap = {}
|
|
72
|
+
for [host, appId] as hostIndex
|
|
73
|
+
(hostMap[appId] = hostMap[appId] or []).push(host)
|
|
74
|
+
response = new Response(JSON.stringify({ ok: true, hosts: Array.from(hostIndex.keys()), apps: hostMap }), { headers: { 'content-type': 'application/json' } })
|
|
75
|
+
{ handled: true, response }
|