Skip to content

Commit f81c3a3

Browse files
committed
generalize certificate edge function
1 parent 2ae7124 commit f81c3a3

File tree

10 files changed

+312
-1
lines changed

10 files changed

+312
-1
lines changed

.vscode/settings.json

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
{
22
"deno.enablePaths": ["supabase/functions"],
33
"deno.lint": true,
4-
"deno.unstable": true
4+
"deno.unstable": true,
5+
"[javascript]": {
6+
"editor.defaultFormatter": "esbenp.prettier-vscode"
7+
},
8+
"[json]": {
9+
"editor.defaultFormatter": "esbenp.prettier-vscode"
10+
},
11+
"[jsonc]": {
12+
"editor.defaultFormatter": "esbenp.prettier-vscode"
13+
},
14+
"[typescript]": {
15+
"editor.defaultFormatter": "esbenp.prettier-vscode"
16+
},
17+
"[typescriptreact]": {
18+
"editor.defaultFormatter": "esbenp.prettier-vscode"
19+
}
520
}

apps/browser-proxy/Dockerfile

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /app
4+
5+
COPY --link package.json ./
6+
COPY --link src/ ./src/
7+
8+
RUN npm install
9+
10+
EXPOSE 443
11+
EXPOSE 5432
12+
13+
CMD ["node", "--experimental-strip-types", "src/index.ts"]

apps/browser-proxy/fly.toml

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
app = "postgres-new-browser-proxy"
2+
3+
primary_region = 'iad'
4+
5+
[[services]]
6+
internal_port = 5432
7+
protocol = "tcp"
8+
[[services.ports]]
9+
port = 5432
10+
11+
[[services]]
12+
internal_port = 443
13+
protocol = "tcp"
14+
[[services.ports]]
15+
port = 443
16+
17+
[[restart]]
18+
policy = "always"
19+
retries = 10
20+
21+
[[vm]]
22+
memory = '512mb'
23+
cpu_kind = 'shared'
24+
cpus = 1

supabase/functions/.env.example

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
ACME_DIRECTORY_URL=https://acme-staging-v02.api.letsencrypt.org/directory
2+
ACME_DOMAIN=db.example.com
3+
ACME_EMAIL="<acme-email>"
4+
AWS_ACCESS_KEY_ID="<aws-access-key-id>"
5+
AWS_ENDPOINT_URL_S3=http://172.17.0.1:54321/storage/v1/s3
6+
AWS_S3_BUCKET=s3fs
7+
AWS_SECRET_ACCESS_KEY="<aws-secret-access-key>"
8+
AWS_REGION=local
9+
CLOUDFLARE_API_TOKEN="<cloudflare-api-token>"
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Certificate function
2+
3+
This function manages the SSL certificates for the various services. The certificate is delivered by Let's Encrypt and stored in Supabase Storage under the `tls/<domain>` directory.
4+
5+
When the certificate is about to expire (less than 30 days), the function will renew it.
6+
7+
## Setup
8+
9+
This function requires all these extensions to be enabled:
10+
11+
- `pg_cron`
12+
- `pg_net`
13+
- `vault`
14+
15+
The cron job relies on two secrets being present in Supabase Vault:
16+
17+
```sql
18+
select vault.create_secret('<supabase_url>', 'supabase_url', 'Supabase API URL');
19+
select vault.create_secret(encode(gen_random_bytes(24), 'base64'), 'supabase_functions_certificate_secret', 'Shared secret to trigger the "certificate" Supabase Edge Function');
20+
```
21+
22+
Now you can schedule a new weekly cron job with `pg_cron`:
23+
24+
```sql
25+
select cron.schedule (
26+
'certificates',
27+
-- every Sunday at 00:00
28+
'0 0 * * 0',
29+
$$
30+
-- certificate for the browser proxy
31+
select net.http_post(
32+
url:=(select supabase_url() || '/functions/v1/certificate'),
33+
headers:=('{"Content-Type": "application/json", "Authorization": "Bearer ' || (select supabase_functions_certificate_secret()) || '"}')::jsonb,
34+
body:='{"domainName": "db.browser.db.build"}'::jsonb
35+
) as request_id
36+
$$
37+
);
38+
```
39+
40+
If you immediately want a certificate, you can call the Edge Function manually:
41+
42+
```sql
43+
select net.http_post(
44+
url:=(select supabase_url() || '/functions/v1/certificate'),
45+
headers:=('{"Content-Type": "application/json", "Authorization": "Bearer ' || (select supabase_functions_certificate_secret()) || '"}')::jsonb,
46+
body:='{"domainName": "db.browser.db.build"}'::jsonb
47+
) as request_id;
48+
```

supabase/functions/certificate/env.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { z } from 'https://deno.land/x/[email protected]/mod.ts'
2+
3+
export const env = z
4+
.object({
5+
ACME_DIRECTORY_URL: z.string(),
6+
ACME_EMAIL: z.string(),
7+
AWS_ACCESS_KEY_ID: z.string(),
8+
AWS_ENDPOINT_URL_S3: z.string(),
9+
AWS_S3_BUCKET: z.string(),
10+
AWS_SECRET_ACCESS_KEY: z.string(),
11+
AWS_REGION: z.string(),
12+
CLOUDFLARE_API_TOKEN: z.string(),
13+
SUPABASE_SERVICE_ROLE_KEY: z.string(),
14+
SUPABASE_URL: z.string(),
15+
})
16+
.parse(Deno.env.toObject())
+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
2+
import { X509Certificate } from 'node:crypto'
3+
import { NoSuchKey, S3 } from 'npm:@aws-sdk/client-s3'
4+
import { createClient } from 'jsr:@supabase/supabase-js@2'
5+
import * as ACME from 'https://deno.land/x/[email protected]/acme.ts'
6+
import { env } from './env.ts'
7+
8+
const supabaseClient = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY)
9+
10+
const s3Client = new S3({
11+
forcePathStyle: true,
12+
})
13+
14+
Deno.serve(async (req) => {
15+
// Check if the request is authorized
16+
if (!(await isAuthorized(req))) {
17+
return Response.json(
18+
{
19+
status: 'error',
20+
message: 'Unauthorized',
21+
},
22+
{ status: 401 }
23+
)
24+
}
25+
26+
const { domainName } = await req.json()
27+
28+
if (!domainName) {
29+
return Response.json(
30+
{
31+
status: 'error',
32+
message: 'Domain name is required',
33+
},
34+
{ status: 400 }
35+
)
36+
}
37+
38+
// Check if we need to renew the certificate
39+
const certificate = await getObject('tls/cert.pem')
40+
if (certificate) {
41+
const { validTo } = new X509Certificate(certificate)
42+
// if the validity is more than 30 days, no need to renew
43+
const day = 24 * 60 * 60 * 1000
44+
if (new Date(validTo) > new Date(Date.now() + 30 * day)) {
45+
return new Response(null, { status: 304 })
46+
}
47+
}
48+
49+
// Load account keys if they exist
50+
const [publicKey, privateKey] = await Promise.all([
51+
getObject('tls/account/publicKey.pem'),
52+
getObject('tls/account/privateKey.pem'),
53+
])
54+
let accountKeys: { privateKeyPEM: string; publicKeyPEM: string } | undefined
55+
if (publicKey && privateKey) {
56+
accountKeys = {
57+
privateKeyPEM: privateKey,
58+
publicKeyPEM: publicKey,
59+
}
60+
}
61+
62+
const { domainCertificates, pemAccountKeys } = await ACME.getCertificatesWithCloudflare(
63+
env.CLOUDFLARE_API_TOKEN,
64+
[
65+
{
66+
domainName,
67+
subdomains: ['*'],
68+
},
69+
],
70+
{
71+
acmeDirectoryUrl: env.ACME_DIRECTORY_URL,
72+
yourEmail: env.ACME_EMAIL,
73+
pemAccountKeys: accountKeys,
74+
}
75+
)
76+
77+
const persistOperations = [
78+
s3Client.putObject({
79+
Bucket: env.AWS_S3_BUCKET,
80+
Key: `tls/${domainName}/key.pem`,
81+
Body: domainCertificates[0].pemPrivateKey,
82+
}),
83+
s3Client.putObject({
84+
Bucket: env.AWS_S3_BUCKET,
85+
Key: `tls/${domainName}/cert.pem`,
86+
Body: domainCertificates[0].pemCertificate,
87+
}),
88+
]
89+
90+
if (!accountKeys) {
91+
persistOperations.push(
92+
s3Client.putObject({
93+
Bucket: env.AWS_S3_BUCKET,
94+
Key: 'tls/account/publicKey.pem',
95+
Body: pemAccountKeys.publicKeyPEM,
96+
}),
97+
s3Client.putObject({
98+
Bucket: env.AWS_S3_BUCKET,
99+
Key: 'tls/account/privateKey.pem',
100+
Body: pemAccountKeys.privateKeyPEM,
101+
})
102+
)
103+
}
104+
105+
await Promise.all(persistOperations)
106+
107+
if (certificate) {
108+
return Response.json({
109+
status: 'renewed',
110+
message: `Certificate renewed successfully for domain ${domainName}`,
111+
})
112+
}
113+
114+
return Response.json(
115+
{
116+
status: 'created',
117+
message: `New certificate created successfully for domain ${domainName}`,
118+
},
119+
{ status: 201 }
120+
)
121+
})
122+
123+
async function isAuthorized(req: Request) {
124+
const authHeader = req.headers.get('Authorization')
125+
126+
if (!authHeader) {
127+
return false
128+
}
129+
130+
const bearerToken = authHeader.split(' ')[1]
131+
132+
const { data: sharedSecret } = await supabaseClient.rpc('supabase_functions_certificate_secret')
133+
134+
if (sharedSecret !== bearerToken) {
135+
return false
136+
}
137+
138+
return true
139+
}
140+
141+
async function getObject(key: string) {
142+
const response = await s3Client
143+
.getObject({
144+
Bucket: env.AWS_S3_BUCKET,
145+
Key: key,
146+
})
147+
.catch((e) => {
148+
if (e instanceof NoSuchKey) {
149+
return undefined
150+
}
151+
throw e
152+
})
153+
154+
return await response?.Body?.transformToString()
155+
}

supabase/functions/deno.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "@postgres-new/supabase-functions"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
create function supabase_url()
2+
returns text
3+
language plpgsql
4+
security definer
5+
as $$
6+
declare
7+
secret_value text;
8+
begin
9+
select decrypted_secret into secret_value from vault.decrypted_secrets where name = 'supabase_url';
10+
return secret_value;
11+
end;
12+
$$;
13+
14+
create function supabase_functions_certificate_secret()
15+
returns text
16+
language plpgsql
17+
security definer
18+
as $$
19+
declare
20+
secret_value text;
21+
begin
22+
select decrypted_secret into secret_value from vault.decrypted_secrets where name = 'supabase_functions_certificate_secret';
23+
return secret_value;
24+
end;
25+
$$;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
create extension pg_cron with schema extensions;
2+
grant usage on schema cron to postgres;
3+
grant all privileges on all tables in schema cron to postgres;

0 commit comments

Comments
 (0)