@axis-network/butter-col 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +81 -2
- package/bin/butter-col.js +4 -0
- package/dist/bootstrap.html +67 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +7 -0
- package/dist/controller.mjs +2 -0
- package/dist/controller.mjs.map +7 -0
- package/dist/formatter.mjs +2 -0
- package/dist/formatter.mjs.map +7 -0
- package/dist/sw.js +30 -0
- package/dist/sw.js.map +7 -0
- package/dist/transports/butter.js +2 -0
- package/dist/transports/butter.js.map +7 -0
- package/dist/types/controller/index.d.ts +65 -0
- package/dist/types/formatter/codecs.d.ts +17 -0
- package/dist/types/formatter/css.d.ts +10 -0
- package/dist/types/formatter/html.d.ts +61 -0
- package/dist/types/formatter/index.d.ts +77 -0
- package/dist/types/formatter/js.d.ts +27 -0
- package/dist/types/formatter/url.d.ts +40 -0
- package/lib/cli.js +257 -0
- package/lib/index.d.ts +68 -0
- package/lib/index.js +166 -0
- package/package.json +77 -4
- package/server/conf/butter-proxy.conf.template +29 -0
- package/server/conf/mime.types +19 -0
- package/server/conf/nginx.conf.template +152 -0
- package/server/config.lua +34 -0
- package/server/lua/butter/config.lua +160 -0
- package/server/lua/butter/cookiejar.lua +197 -0
- package/server/lua/butter/dns.lua +67 -0
- package/server/lua/butter/guard.lua +78 -0
- package/server/lua/butter/headers.lua +83 -0
- package/server/lua/butter/init.lua +18 -0
- package/server/lua/butter/ip.lua +165 -0
- package/server/lua/butter/limit.lua +18 -0
- package/server/lua/butter/session.lua +42 -0
- package/server/lua/butter/store.lua +77 -0
- package/server/lua/butter/transport.lua +248 -0
- package/server/lua/butter/url.lua +78 -0
- package/site/app.css +373 -0
- package/site/app.js +335 -0
- package/site/butter-col.png +0 -0
- package/site/index.html +139 -0
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axis-network/butter-col",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A service-worker web proxy with pluggable transports and an OpenResty (Lua) backend.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"butter-col",
|
|
7
|
+
"proxy",
|
|
8
|
+
"web-proxy",
|
|
9
|
+
"service-worker",
|
|
10
|
+
"openresty",
|
|
11
|
+
"transport"
|
|
12
|
+
],
|
|
13
|
+
"author": "Axis Network",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/TheAxis-Network/butter-col.git",
|
|
18
|
+
"directory": "packages/butter-col"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/TheAxis-Network/butter-col/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/TheAxis-Network/butter-col#readme",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./lib/index.js",
|
|
26
|
+
"types": "./lib/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./lib/index.d.ts",
|
|
30
|
+
"import": "./lib/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./formatter": {
|
|
33
|
+
"types": "./dist/types/formatter/index.d.ts",
|
|
34
|
+
"import": "./dist/formatter.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./controller": {
|
|
37
|
+
"types": "./dist/types/controller/index.d.ts",
|
|
38
|
+
"import": "./dist/controller.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"bin": {
|
|
43
|
+
"butter-col": "bin/butter-col.js"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"bin",
|
|
47
|
+
"lib",
|
|
48
|
+
"dist",
|
|
49
|
+
"site",
|
|
50
|
+
"LICENSE",
|
|
51
|
+
"server/conf/nginx.conf.template",
|
|
52
|
+
"server/conf/butter-proxy.conf.template",
|
|
53
|
+
"server/conf/mime.types",
|
|
54
|
+
"server/lua",
|
|
55
|
+
"server/config.lua",
|
|
56
|
+
"README.md"
|
|
57
|
+
],
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=20"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "node scripts/build.mjs",
|
|
66
|
+
"dev": "node scripts/build.mjs --watch",
|
|
67
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
68
|
+
"test": "node --test test/*.test.mjs",
|
|
69
|
+
"test:lua": "node test/lua/run.mjs"
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@axis-network/butter-transport": "^0.1.0"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"esbuild": "^0.28.2",
|
|
76
|
+
"fengari": "^0.1.5",
|
|
77
|
+
"typescript": "^7.0.2"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Rendered by `butter-col server` into <runtime>/conf/butter-proxy.conf ({{CACERT}} = CA bundle path).
|
|
2
|
+
# Upstream settings shared by /butter/fetch and /butter/ws.
|
|
3
|
+
# The Lua access handler fills in the $butter_* variables after validating
|
|
4
|
+
# the target and resolving it to an address that passed the SSRF guard.
|
|
5
|
+
|
|
6
|
+
proxy_pass $butter_scheme://$butter_addr$butter_uri;
|
|
7
|
+
proxy_http_version 1.1;
|
|
8
|
+
proxy_set_header Host $butter_host;
|
|
9
|
+
|
|
10
|
+
proxy_ssl_server_name on;
|
|
11
|
+
proxy_ssl_name $butter_sni;
|
|
12
|
+
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
|
13
|
+
proxy_ssl_verify on;
|
|
14
|
+
proxy_ssl_verify_depth 5;
|
|
15
|
+
proxy_ssl_trusted_certificate {{CACERT}};
|
|
16
|
+
proxy_ssl_session_reuse on;
|
|
17
|
+
|
|
18
|
+
# Never let an upstream steer nginx (X-Accel-Redirect would be an internal redirect).
|
|
19
|
+
proxy_ignore_headers X-Accel-Redirect X-Accel-Expires X-Accel-Limit-Rate X-Accel-Buffering X-Accel-Charset;
|
|
20
|
+
|
|
21
|
+
proxy_redirect off;
|
|
22
|
+
proxy_buffering off;
|
|
23
|
+
proxy_intercept_errors off;
|
|
24
|
+
proxy_connect_timeout 15s;
|
|
25
|
+
|
|
26
|
+
# Room for large upstream response headers (big CSPs, many cookies).
|
|
27
|
+
proxy_buffer_size 64k;
|
|
28
|
+
proxy_buffers 16 64k;
|
|
29
|
+
proxy_busy_buffers_size 128k;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
types {
|
|
2
|
+
text/html html htm;
|
|
3
|
+
text/css css;
|
|
4
|
+
text/javascript js mjs;
|
|
5
|
+
application/json json map;
|
|
6
|
+
application/manifest+json webmanifest;
|
|
7
|
+
application/wasm wasm;
|
|
8
|
+
text/plain txt;
|
|
9
|
+
text/markdown md;
|
|
10
|
+
image/svg+xml svg svgz;
|
|
11
|
+
image/png png;
|
|
12
|
+
image/jpeg jpg jpeg;
|
|
13
|
+
image/gif gif;
|
|
14
|
+
image/webp webp;
|
|
15
|
+
image/avif avif;
|
|
16
|
+
image/x-icon ico;
|
|
17
|
+
font/woff woff;
|
|
18
|
+
font/woff2 woff2;
|
|
19
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# butter-col: OpenResty configuration template.
|
|
2
|
+
#
|
|
3
|
+
# `butter-col server start` renders this into <runtime>/conf/nginx.conf,
|
|
4
|
+
# replacing each double-brace placeholder with an absolute path or value:
|
|
5
|
+
#
|
|
6
|
+
# {{LISTEN}} listen address, e.g. 8080
|
|
7
|
+
# {{LUA_DIR}} this package's server/lua
|
|
8
|
+
# {{CONFIG_FILE}} your butter.config.lua (or the package default)
|
|
9
|
+
# {{SITE_ROOT}} the site served at / (launcher, docs, your app)
|
|
10
|
+
# {{APP_DIR}} this package's built-in frontend (site/), served at /app/
|
|
11
|
+
# {{DIST_DIR}} this package's dist (service worker, runtime, ...)
|
|
12
|
+
# {{RUNTIME_DIR}} the runtime directory (logs, transports, cacert.pem)
|
|
13
|
+
#
|
|
14
|
+
# The /butter/ paths below must match `base` in the Lua configuration.
|
|
15
|
+
|
|
16
|
+
worker_processes auto;
|
|
17
|
+
error_log logs/error.log notice;
|
|
18
|
+
pid logs/nginx.pid;
|
|
19
|
+
|
|
20
|
+
events {
|
|
21
|
+
worker_connections 4096;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
http {
|
|
25
|
+
include mime.types;
|
|
26
|
+
default_type application/octet-stream;
|
|
27
|
+
charset utf-8;
|
|
28
|
+
charset_types text/css text/plain text/javascript application/javascript application/json image/svg+xml;
|
|
29
|
+
sendfile on;
|
|
30
|
+
server_tokens off;
|
|
31
|
+
|
|
32
|
+
# Target URLs travel in headers, so access logs never contain browsing history.
|
|
33
|
+
access_log logs/access.log;
|
|
34
|
+
|
|
35
|
+
lua_package_path "{{LUA_DIR}}/?.lua;{{LUA_DIR}}/?/init.lua;;";
|
|
36
|
+
lua_shared_dict butter_dns 10m;
|
|
37
|
+
lua_shared_dict butter_cookies 64m;
|
|
38
|
+
lua_shared_dict butter_limit 10m;
|
|
39
|
+
|
|
40
|
+
init_by_lua_block {
|
|
41
|
+
require("butter").init([==[{{CONFIG_FILE}}]==])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# X-Butter-Headers carries the page's request headers as JSON.
|
|
45
|
+
large_client_header_buffers 8 64k;
|
|
46
|
+
client_max_body_size 100m;
|
|
47
|
+
|
|
48
|
+
server {
|
|
49
|
+
listen {{LISTEN}};
|
|
50
|
+
server_name _;
|
|
51
|
+
|
|
52
|
+
root {{SITE_ROOT}};
|
|
53
|
+
index index.html;
|
|
54
|
+
|
|
55
|
+
location / {
|
|
56
|
+
try_files $uri $uri/ =404;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# The built-in frontend, always available (also served at / when no
|
|
60
|
+
# --site is given).
|
|
61
|
+
location = /app {
|
|
62
|
+
return 301 /app/;
|
|
63
|
+
}
|
|
64
|
+
location ^~ /app/ {
|
|
65
|
+
alias {{APP_DIR}}/;
|
|
66
|
+
index index.html;
|
|
67
|
+
add_header Cache-Control "no-cache" always;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# --- butter-col endpoints --------------------------------------------
|
|
71
|
+
|
|
72
|
+
location = /butter/sw.js {
|
|
73
|
+
alias {{DIST_DIR}}/sw.js;
|
|
74
|
+
add_header Service-Worker-Allowed "/" always;
|
|
75
|
+
add_header Cache-Control "no-cache" always;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
location = /butter/config.js {
|
|
79
|
+
content_by_lua_block { require("butter.transport").config_js() }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
location = /butter/health {
|
|
83
|
+
content_by_lua_block { require("butter.transport").health() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
location = /butter/cookie {
|
|
87
|
+
client_max_body_size 16k;
|
|
88
|
+
client_body_buffer_size 16k;
|
|
89
|
+
content_by_lua_block { require("butter.transport").cookie() }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
location = /butter/fetch {
|
|
93
|
+
set $butter_scheme "";
|
|
94
|
+
set $butter_addr "";
|
|
95
|
+
set $butter_uri "";
|
|
96
|
+
set $butter_host "";
|
|
97
|
+
set $butter_sni "";
|
|
98
|
+
access_by_lua_block { require("butter.transport").access_fetch() }
|
|
99
|
+
|
|
100
|
+
include butter-proxy.conf;
|
|
101
|
+
proxy_set_header Connection "";
|
|
102
|
+
proxy_read_timeout 120s;
|
|
103
|
+
proxy_send_timeout 60s;
|
|
104
|
+
|
|
105
|
+
header_filter_by_lua_block { require("butter.transport").header_filter_fetch() }
|
|
106
|
+
error_page 502 504 @butter_upstream_error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
location = /butter/ws {
|
|
110
|
+
set $butter_scheme "";
|
|
111
|
+
set $butter_addr "";
|
|
112
|
+
set $butter_uri "";
|
|
113
|
+
set $butter_host "";
|
|
114
|
+
set $butter_sni "";
|
|
115
|
+
access_by_lua_block { require("butter.transport").access_ws() }
|
|
116
|
+
|
|
117
|
+
include butter-proxy.conf;
|
|
118
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
119
|
+
proxy_set_header Connection "upgrade";
|
|
120
|
+
proxy_read_timeout 1h;
|
|
121
|
+
proxy_send_timeout 1h;
|
|
122
|
+
|
|
123
|
+
header_filter_by_lua_block { require("butter.transport").header_filter_ws() }
|
|
124
|
+
error_page 502 504 @butter_upstream_error;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
location @butter_upstream_error {
|
|
128
|
+
content_by_lua_block { require("butter.transport").upstream_error() }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Transport scripts: the built-in ones plus any added with
|
|
132
|
+
# `butter-col transport add`, copied here at startup.
|
|
133
|
+
location ^~ /butter/transports/ {
|
|
134
|
+
root {{RUNTIME_DIR}}/www;
|
|
135
|
+
add_header Cache-Control "no-cache" always;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Proxied URLs only reach the server when the service worker is not
|
|
139
|
+
# running yet; the bootstrap page installs it and reloads.
|
|
140
|
+
location ^~ /butter/go/ {
|
|
141
|
+
root {{DIST_DIR}};
|
|
142
|
+
rewrite ^ /bootstrap.html break;
|
|
143
|
+
add_header Cache-Control "no-store" always;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Built assets: client.js, controller.mjs, formatter.mjs, source maps.
|
|
147
|
+
location ^~ /butter/ {
|
|
148
|
+
alias {{DIST_DIR}}/;
|
|
149
|
+
add_header Cache-Control "no-cache" always;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- butter-col server configuration (the package default).
|
|
2
|
+
-- Copy this file to butter.config.lua in your project and edit it; anything
|
|
3
|
+
-- left out falls back to the defaults in lua/butter/config.lua.
|
|
4
|
+
-- Run `butter-col server reload` after editing.
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
-- URL codec for proxied URLs: "none", "plain", "xor" or "base64".
|
|
8
|
+
codec = "plain",
|
|
9
|
+
|
|
10
|
+
-- Transports offered to browsers; visitors can switch between them with
|
|
11
|
+
-- ButterController.setTransport(). Add more with `butter-col transport add`.
|
|
12
|
+
transports = {
|
|
13
|
+
{ name = "butter" }, -- built in: this server
|
|
14
|
+
-- { name = "bare", options = { server = "https://bare.example.com/" } },
|
|
15
|
+
},
|
|
16
|
+
default_transport = "butter",
|
|
17
|
+
|
|
18
|
+
dns = {
|
|
19
|
+
nameservers = { "1.1.1.1", "8.8.8.8" },
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
guard = {
|
|
23
|
+
-- Never set this to true on a server other people can reach: it lets
|
|
24
|
+
-- visitors reach your local network and cloud metadata endpoints.
|
|
25
|
+
allow_private = false,
|
|
26
|
+
blocked_hosts = { "localhost", "local", "internal", "localdomain", "home.arpa" },
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
rate_limit = {
|
|
30
|
+
enabled = true,
|
|
31
|
+
requests = 1200,
|
|
32
|
+
window = 60,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
-- Configuration: defaults merged with server/config.lua (if present).
|
|
2
|
+
|
|
3
|
+
local M = {}
|
|
4
|
+
|
|
5
|
+
M.VERSION = "0.1.0"
|
|
6
|
+
|
|
7
|
+
local CODECS = { none = true, plain = true, xor = true, base64 = true }
|
|
8
|
+
|
|
9
|
+
local defaults = {
|
|
10
|
+
-- Where butter-col's own endpoints and assets live. The nginx locations in
|
|
11
|
+
-- conf/nginx.conf use this path too; change both together.
|
|
12
|
+
base = "/butter/",
|
|
13
|
+
-- Prefix of proxied URLs; must live under `base` (see the bootstrap location).
|
|
14
|
+
prefix = "/butter/go/",
|
|
15
|
+
-- URL codec: "none" | "plain" | "xor" | "base64"
|
|
16
|
+
codec = "plain",
|
|
17
|
+
|
|
18
|
+
-- Transports offered to browsers. Each entry: { name, url?, options? }.
|
|
19
|
+
-- `url` defaults to <base>transports/<name>.js; `options` is sent to the
|
|
20
|
+
-- transport's factory as JSON. Visitors can switch between these with
|
|
21
|
+
-- ButterController.setTransport().
|
|
22
|
+
transports = {
|
|
23
|
+
{ name = "butter" },
|
|
24
|
+
},
|
|
25
|
+
-- Used until a visitor picks another one.
|
|
26
|
+
default_transport = "butter",
|
|
27
|
+
|
|
28
|
+
-- Reject transport requests that a browser marks as cross-site, so other
|
|
29
|
+
-- websites cannot use visitors' browsers to drive the proxy.
|
|
30
|
+
require_same_origin = true,
|
|
31
|
+
-- Allow clients that send neither Sec-Fetch-Site nor Origin (curl, scripts).
|
|
32
|
+
allow_non_browser = true,
|
|
33
|
+
-- Override the upstream User-Agent (nil forwards the browser's own).
|
|
34
|
+
user_agent = nil,
|
|
35
|
+
|
|
36
|
+
dns = {
|
|
37
|
+
nameservers = { "1.1.1.1", "8.8.8.8" },
|
|
38
|
+
timeout = 2000, -- ms per attempt
|
|
39
|
+
retrans = 2,
|
|
40
|
+
ipv6 = false, -- also look up AAAA records when there is no A record
|
|
41
|
+
min_ttl = 30,
|
|
42
|
+
max_ttl = 3600,
|
|
43
|
+
negative_ttl = 30,
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
guard = {
|
|
47
|
+
-- Allow upstream connections to private / loopback / link-local addresses.
|
|
48
|
+
-- Leave this off unless the proxy only serves you on a trusted network.
|
|
49
|
+
allow_private = false,
|
|
50
|
+
-- Allow the ports browsers refuse to fetch (SMTP, IRC, ...).
|
|
51
|
+
allow_bad_ports = false,
|
|
52
|
+
-- Hosts that may never be proxied; "example.com" also covers subdomains.
|
|
53
|
+
blocked_hosts = { "localhost", "local", "internal", "localdomain", "home.arpa" },
|
|
54
|
+
-- If set, only these hosts (and their subdomains) may be proxied.
|
|
55
|
+
allowed_hosts = nil,
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
cookies = {
|
|
59
|
+
enabled = true,
|
|
60
|
+
max_per_session = 500,
|
|
61
|
+
session_ttl = 7 * 24 * 3600, -- seconds a jar is kept after its last change
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
rate_limit = {
|
|
65
|
+
enabled = true,
|
|
66
|
+
requests = 1200, -- per client IP ...
|
|
67
|
+
window = 60, -- ... per this many seconds
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
local current
|
|
72
|
+
|
|
73
|
+
local function is_array(t)
|
|
74
|
+
return type(t) == "table" and t[1] ~= nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
--- Deep-merge `override` into `base`. Lists (and scalars) replace wholesale.
|
|
78
|
+
local function merge(base, override)
|
|
79
|
+
if override == nil then return base end
|
|
80
|
+
if type(base) ~= "table" or type(override) ~= "table" or is_array(base) or is_array(override) then
|
|
81
|
+
return override
|
|
82
|
+
end
|
|
83
|
+
local out = {}
|
|
84
|
+
for k, v in pairs(base) do out[k] = v end
|
|
85
|
+
for k, v in pairs(override) do out[k] = merge(base[k], v) end
|
|
86
|
+
return out
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
local function validate(cfg)
|
|
90
|
+
assert(type(cfg.base) == "string" and cfg.base:match("^/.*/$"), "config.base must start and end with '/'")
|
|
91
|
+
assert(type(cfg.prefix) == "string" and cfg.prefix:match("^/.*/$"), "config.prefix must start and end with '/'")
|
|
92
|
+
assert(cfg.prefix:sub(1, #cfg.base) == cfg.base and cfg.prefix ~= cfg.base, "config.prefix must live under config.base")
|
|
93
|
+
assert(CODECS[cfg.codec], "config.codec must be one of none, plain, xor, base64")
|
|
94
|
+
assert(type(cfg.dns.nameservers) == "table" and #cfg.dns.nameservers > 0, "config.dns.nameservers must not be empty")
|
|
95
|
+
assert(cfg.rate_limit.window > 0 and cfg.rate_limit.requests > 0, "config.rate_limit values must be positive")
|
|
96
|
+
|
|
97
|
+
assert(type(cfg.transports) == "table" and #cfg.transports > 0, "config.transports must list at least one transport")
|
|
98
|
+
local seen = {}
|
|
99
|
+
for i, entry in ipairs(cfg.transports) do
|
|
100
|
+
assert(type(entry) == "table", "config.transports[" .. i .. "] must be a table")
|
|
101
|
+
assert(type(entry.name) == "string" and entry.name:match("^[a-z0-9][a-z0-9._-]*$") and #entry.name <= 64,
|
|
102
|
+
"config.transports[" .. i .. "].name must be lower-case letters, digits, '.', '_' or '-'")
|
|
103
|
+
assert(not seen[entry.name], "config.transports lists \"" .. entry.name .. "\" twice")
|
|
104
|
+
assert(entry.url == nil or type(entry.url) == "string", "config.transports[" .. i .. "].url must be a string")
|
|
105
|
+
seen[entry.name] = true
|
|
106
|
+
end
|
|
107
|
+
assert(seen[cfg.default_transport], "config.default_transport must name one of config.transports")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
--- Load defaults merged with the user file at `path` (missing file = defaults).
|
|
111
|
+
function M.load(path)
|
|
112
|
+
local user = {}
|
|
113
|
+
if path then
|
|
114
|
+
local chunk, err = loadfile(path)
|
|
115
|
+
if not chunk and not tostring(err):find("cannot open", 1, true) then
|
|
116
|
+
error("butter: cannot parse " .. path .. ": " .. tostring(err))
|
|
117
|
+
end
|
|
118
|
+
if chunk then
|
|
119
|
+
local ok, result = pcall(chunk)
|
|
120
|
+
if not ok then error("butter: error in " .. path .. ": " .. tostring(result)) end
|
|
121
|
+
if type(result) ~= "table" then error("butter: " .. path .. " must return a table") end
|
|
122
|
+
user = result
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
local cfg = merge(defaults, user)
|
|
126
|
+
validate(cfg)
|
|
127
|
+
current = cfg
|
|
128
|
+
return cfg
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
function M.get()
|
|
132
|
+
return current or M.load()
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
--- The subset sent to browsers as /butter/config.js.
|
|
136
|
+
function M.public()
|
|
137
|
+
local cfg = M.get()
|
|
138
|
+
local base = cfg.base
|
|
139
|
+
local transports = {}
|
|
140
|
+
for i, entry in ipairs(cfg.transports) do
|
|
141
|
+
transports[i] = {
|
|
142
|
+
name = entry.name,
|
|
143
|
+
url = entry.url or (base .. "transports/" .. entry.name .. ".js"),
|
|
144
|
+
options = entry.options,
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
return {
|
|
148
|
+
version = M.VERSION,
|
|
149
|
+
prefix = cfg.prefix,
|
|
150
|
+
base = base,
|
|
151
|
+
codec = cfg.codec,
|
|
152
|
+
transport = { fetch = base .. "fetch", ws = base .. "ws", cookie = base .. "cookie" },
|
|
153
|
+
client = base .. "client.js",
|
|
154
|
+
config = base .. "config.js",
|
|
155
|
+
transports = transports,
|
|
156
|
+
defaultTransport = cfg.default_transport,
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
return M
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
-- Server-side cookie jar logic (RFC 6265 subset). Pure Lua, no ngx dependency:
|
|
2
|
+
-- storage lives in butter.store; this module only transforms cookie lists.
|
|
3
|
+
--
|
|
4
|
+
-- A cookie is a table:
|
|
5
|
+
-- { name, value, domain, host_only, path, secure, http_only, same_site,
|
|
6
|
+
-- expires (epoch seconds or nil for session cookies), created }
|
|
7
|
+
|
|
8
|
+
local url_util = require "butter.url"
|
|
9
|
+
|
|
10
|
+
local M = {}
|
|
11
|
+
|
|
12
|
+
local function trim(s)
|
|
13
|
+
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
local MONTHS = { jan = 1, feb = 2, mar = 3, apr = 4, may = 5, jun = 6, jul = 7, aug = 8, sep = 9, oct = 10, nov = 11, dec = 12 }
|
|
17
|
+
|
|
18
|
+
local function days_from_civil(y, m, d)
|
|
19
|
+
if m <= 2 then y = y - 1 end
|
|
20
|
+
local era = math.floor(y / 400)
|
|
21
|
+
local yoe = y - era * 400
|
|
22
|
+
local mp = (m + 9) % 12
|
|
23
|
+
local doy = math.floor((153 * mp + 2) / 5) + d - 1
|
|
24
|
+
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
|
|
25
|
+
return era * 146097 + doe - 719468
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
--- Parse a cookie date (RFC 6265 section 5.1.1). Returns epoch seconds or nil.
|
|
29
|
+
function M.parse_date(s)
|
|
30
|
+
local hour, minute, second, day, month, year
|
|
31
|
+
for token in s:gmatch("[^%s,;%-/]+") do
|
|
32
|
+
local h, mi, se = token:match("^(%d%d?):(%d%d?):(%d%d?)")
|
|
33
|
+
if h and not hour then
|
|
34
|
+
hour, minute, second = tonumber(h), tonumber(mi), tonumber(se)
|
|
35
|
+
elseif not day and token:match("^%d%d?$") then
|
|
36
|
+
day = tonumber(token)
|
|
37
|
+
elseif not month and MONTHS[token:sub(1, 3):lower()] then
|
|
38
|
+
month = MONTHS[token:sub(1, 3):lower()]
|
|
39
|
+
elseif not year and token:match("^%d%d%d?%d?$") then
|
|
40
|
+
year = tonumber(token)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
if not (hour and day and month and year) then return nil end
|
|
44
|
+
if year >= 70 and year <= 99 then year = year + 1900 elseif year < 70 then year = year + 2000 end
|
|
45
|
+
if day < 1 or day > 31 or year < 1601 or hour > 23 or minute > 59 or second > 59 then return nil end
|
|
46
|
+
return days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
--- Default-path of a request path (RFC 6265 section 5.1.4).
|
|
50
|
+
function M.default_path(path)
|
|
51
|
+
path = url_util.path_only(path or "/")
|
|
52
|
+
if path:sub(1, 1) ~= "/" then return "/" end
|
|
53
|
+
local last = path:match("^.*()/")
|
|
54
|
+
if not last or last == 1 then return "/" end
|
|
55
|
+
return path:sub(1, last - 1)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local function is_ip(host)
|
|
59
|
+
return host:match("^%d+%.%d+%.%d+%.%d+$") ~= nil or host:find(":", 1, true) ~= nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
function M.domain_match(host, domain)
|
|
63
|
+
if host == domain then return true end
|
|
64
|
+
if is_ip(host) then return false end
|
|
65
|
+
return #host > #domain and host:sub(-#domain - 1) == "." .. domain
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
function M.path_match(request_path, cookie_path)
|
|
69
|
+
if request_path == cookie_path then return true end
|
|
70
|
+
if request_path:sub(1, #cookie_path) ~= cookie_path then return false end
|
|
71
|
+
return cookie_path:sub(-1) == "/" or request_path:sub(#cookie_path + 1, #cookie_path + 1) == "/"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
--- Parse one Set-Cookie header received from `target` (a butter.url table).
|
|
75
|
+
-- @return table|nil, string|nil
|
|
76
|
+
function M.parse_set_cookie(header, target, now)
|
|
77
|
+
if type(header) ~= "string" then return nil, "not a string" end
|
|
78
|
+
local pair, attrs = header:match("^([^;]*)(.*)$")
|
|
79
|
+
local name, value
|
|
80
|
+
local eq = pair:find("=", 1, true)
|
|
81
|
+
if eq then
|
|
82
|
+
name, value = trim(pair:sub(1, eq - 1)), trim(pair:sub(eq + 1))
|
|
83
|
+
else
|
|
84
|
+
name, value = "", trim(pair)
|
|
85
|
+
end
|
|
86
|
+
if name == "" and value == "" then return nil, "empty cookie" end
|
|
87
|
+
if #name + #value > 4096 then return nil, "cookie too large" end
|
|
88
|
+
if (name .. value):find("%c") then return nil, "control characters in cookie" end
|
|
89
|
+
|
|
90
|
+
local cookie = { name = name, value = value, secure = false, http_only = false }
|
|
91
|
+
local max_age, expires, domain, path
|
|
92
|
+
for attr in attrs:gmatch(";([^;]*)") do
|
|
93
|
+
local k, v = attr:match("^%s*([^=]-)%s*=%s*(.-)%s*$")
|
|
94
|
+
if not k then
|
|
95
|
+
k, v = trim(attr), ""
|
|
96
|
+
end
|
|
97
|
+
k = k:lower()
|
|
98
|
+
if k == "expires" then
|
|
99
|
+
expires = M.parse_date(v) or expires
|
|
100
|
+
elseif k == "max-age" then
|
|
101
|
+
if v:match("^%-?%d+$") then max_age = tonumber(v) end
|
|
102
|
+
elseif k == "domain" then
|
|
103
|
+
v = v:gsub("^%.", ""):lower()
|
|
104
|
+
if v ~= "" then domain = v end
|
|
105
|
+
elseif k == "path" then
|
|
106
|
+
if v:sub(1, 1) == "/" then path = v end
|
|
107
|
+
elseif k == "secure" then
|
|
108
|
+
cookie.secure = true
|
|
109
|
+
elseif k == "httponly" then
|
|
110
|
+
cookie.http_only = true
|
|
111
|
+
elseif k == "samesite" then
|
|
112
|
+
cookie.same_site = v:lower()
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if max_age then
|
|
117
|
+
cookie.expires = max_age <= 0 and 0 or now + max_age
|
|
118
|
+
elseif expires then
|
|
119
|
+
cookie.expires = expires
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
local host = target.host
|
|
123
|
+
if domain then
|
|
124
|
+
if not M.domain_match(host, domain) then return nil, "domain does not match" end
|
|
125
|
+
-- Without a public suffix list, at least refuse single-label domains ("com").
|
|
126
|
+
if domain ~= host and not domain:find(".", 1, true) then return nil, "domain too broad" end
|
|
127
|
+
cookie.domain, cookie.host_only = domain, false
|
|
128
|
+
else
|
|
129
|
+
cookie.domain, cookie.host_only = host, true
|
|
130
|
+
end
|
|
131
|
+
cookie.path = path or M.default_path(target.path)
|
|
132
|
+
|
|
133
|
+
if cookie.secure and not target.secure then return nil, "secure cookie from insecure origin" end
|
|
134
|
+
if name:sub(1, 9) == "__Secure-" and not cookie.secure then return nil, "__Secure- prefix requires Secure" end
|
|
135
|
+
if name:sub(1, 7) == "__Host-" and not (cookie.secure and cookie.host_only and cookie.path == "/") then
|
|
136
|
+
return nil, "__Host- prefix requirements not met"
|
|
137
|
+
end
|
|
138
|
+
return cookie
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
--- Insert or replace `cookie` in `list`, dropping expired entries.
|
|
142
|
+
-- opts.from_script: the cookie comes from document.cookie (cannot touch HttpOnly)
|
|
143
|
+
-- opts.max: maximum cookies kept (oldest are evicted)
|
|
144
|
+
-- @return table the new list
|
|
145
|
+
function M.upsert(list, cookie, now, opts)
|
|
146
|
+
opts = opts or {}
|
|
147
|
+
if opts.from_script and cookie.http_only then return list end
|
|
148
|
+
local out, created = {}, nil
|
|
149
|
+
for _, c in ipairs(list) do
|
|
150
|
+
if c.expires and c.expires <= now then
|
|
151
|
+
-- expired: drop
|
|
152
|
+
elseif c.name == cookie.name and c.domain == cookie.domain and c.path == cookie.path then
|
|
153
|
+
if opts.from_script and c.http_only then return list end
|
|
154
|
+
created = c.created
|
|
155
|
+
else
|
|
156
|
+
out[#out + 1] = c
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
cookie.created = created or now
|
|
160
|
+
if not (cookie.expires and cookie.expires <= now) then out[#out + 1] = cookie end
|
|
161
|
+
|
|
162
|
+
local max = opts.max or 500
|
|
163
|
+
if #out > max then
|
|
164
|
+
table.sort(out, function(a, b) return (a.created or 0) > (b.created or 0) end)
|
|
165
|
+
for i = #out, max + 1, -1 do out[i] = nil end
|
|
166
|
+
end
|
|
167
|
+
return out
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
--- Cookie header value for a request to `target`.
|
|
171
|
+
-- opts.for_script: only cookies visible to document.cookie (no HttpOnly)
|
|
172
|
+
function M.header(list, target, now, opts)
|
|
173
|
+
local for_script = opts and opts.for_script
|
|
174
|
+
local path = url_util.path_only(target.path)
|
|
175
|
+
local matches = {}
|
|
176
|
+
for _, c in ipairs(list) do
|
|
177
|
+
if not (c.expires and c.expires <= now)
|
|
178
|
+
and ((c.host_only and c.domain == target.host) or (not c.host_only and M.domain_match(target.host, c.domain)))
|
|
179
|
+
and M.path_match(path, c.path)
|
|
180
|
+
and (not c.secure or target.secure)
|
|
181
|
+
and not (for_script and c.http_only)
|
|
182
|
+
then
|
|
183
|
+
matches[#matches + 1] = c
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
table.sort(matches, function(a, b)
|
|
187
|
+
if #a.path ~= #b.path then return #a.path > #b.path end
|
|
188
|
+
return (a.created or 0) < (b.created or 0)
|
|
189
|
+
end)
|
|
190
|
+
local parts = {}
|
|
191
|
+
for i, c in ipairs(matches) do
|
|
192
|
+
parts[i] = c.name ~= "" and (c.name .. "=" .. c.value) or c.value
|
|
193
|
+
end
|
|
194
|
+
return table.concat(parts, "; ")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
return M
|