openship 0.1.11 → 0.2.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/dist/index.js +680 -104
- package/dist/server/index.js +17063 -8214
- package/dist/server/lua/geo_country.lua +101 -0
- package/dist/server/lua/mgmt_api.lua +291 -0
- package/dist/server/lua/pipe_log.lua +71 -0
- package/dist/server/lua/pipe_stream.lua +90 -0
- package/dist/server/lua/rules_guard.lua +118 -0
- package/dist/server/lua/rules_lib.lua +176 -0
- package/dist/server/lua/site_logger.lua +182 -0
- package/dist/server/lua/webhook_handler.lua +126 -0
- package/dist/server/migrations/0035_thankful_morgan_stark.sql +1 -0
- package/dist/server/migrations/0036_gorgeous_scalphunter.sql +17 -0
- package/dist/server/migrations/0037_clever_makkari.sql +28 -0
- package/dist/server/migrations/0038_young_magneto.sql +1 -0
- package/dist/server/migrations/0039_secret_lady_ursula.sql +15 -0
- package/dist/server/migrations/0040_sharp_red_wolf.sql +16 -0
- package/dist/server/migrations/0041_round_spyke.sql +4 -0
- package/dist/server/migrations/0042_dapper_havok.sql +1 -0
- package/dist/server/migrations/0043_fast_sharon_ventura.sql +15 -0
- package/dist/server/migrations/0044_amused_shape.sql +8 -0
- package/dist/server/migrations/0045_amusing_korath.sql +1 -0
- package/dist/server/migrations/0046_white_harrier.sql +35 -0
- package/dist/server/migrations/0047_sloppy_strong_guy.sql +2 -0
- package/dist/server/migrations/0048_funny_emma_frost.sql +2 -0
- package/dist/server/migrations/0049_nappy_piledriver.sql +3 -0
- package/dist/server/migrations/meta/0035_snapshot.json +7610 -0
- package/dist/server/migrations/meta/0036_snapshot.json +7752 -0
- package/dist/server/migrations/meta/0037_snapshot.json +7978 -0
- package/dist/server/migrations/meta/0038_snapshot.json +7985 -0
- package/dist/server/migrations/meta/0039_snapshot.json +8090 -0
- package/dist/server/migrations/meta/0040_snapshot.json +8198 -0
- package/dist/server/migrations/meta/0041_snapshot.json +8211 -0
- package/dist/server/migrations/meta/0042_snapshot.json +8217 -0
- package/dist/server/migrations/meta/0043_snapshot.json +8316 -0
- package/dist/server/migrations/meta/0044_snapshot.json +8360 -0
- package/dist/server/migrations/meta/0045_snapshot.json +8367 -0
- package/dist/server/migrations/meta/0046_snapshot.json +8657 -0
- package/dist/server/migrations/meta/0047_snapshot.json +8669 -0
- package/dist/server/migrations/meta/0048_snapshot.json +8683 -0
- package/dist/server/migrations/meta/0049_snapshot.json +8702 -0
- package/dist/server/migrations/meta/_journal.json +105 -0
- package/package.json +4 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
-- rules_lib.lua
|
|
2
|
+
-- Per-worker helpers + parsed-rule cache for rules_guard.lua (access phase).
|
|
3
|
+
-- Loaded ONCE per worker via require("openship.rules_lib"). The access file is
|
|
4
|
+
-- re-executed on every request, so all parsing/compiling/matching lives here to
|
|
5
|
+
-- avoid re-creating closures and re-decoding JSON on the hot path.
|
|
6
|
+
--
|
|
7
|
+
-- Shared-dict value `rules[host]` = JSON array of { pathPrefix, spec } where
|
|
8
|
+
-- spec matches @repo/core RouteRuleSpec. parse() decodes + precompiles CIDRs to
|
|
9
|
+
-- integers, lowercases UA/referer needles, and builds O(1) sets for
|
|
10
|
+
-- countries/methods ONCE; the compiled result is LRU-cached per host and reused
|
|
11
|
+
-- until the raw string changes (rule edits are rare, requests are hot).
|
|
12
|
+
--
|
|
13
|
+
-- SECURITY: nothing here turns a rule value into Lua code or a Lua pattern.
|
|
14
|
+
-- Strings are compared with `==`/set-lookups; user-agent matching is
|
|
15
|
+
-- string.find(hay, needle, 1, true) — PLAIN, never a regex.
|
|
16
|
+
|
|
17
|
+
local cjson = require "cjson.safe"
|
|
18
|
+
local lrucache = require "resty.lrucache"
|
|
19
|
+
|
|
20
|
+
local M = {}
|
|
21
|
+
|
|
22
|
+
local cache = lrucache.new(1024) -- compiled rules, per worker, ~1024 hosts
|
|
23
|
+
|
|
24
|
+
-- ── IPv4 helpers ──
|
|
25
|
+
local function ipv4_to_int(s)
|
|
26
|
+
local a, b, c, d = string.match(s, "^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
|
27
|
+
if not a then return nil end
|
|
28
|
+
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
|
29
|
+
if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
|
|
30
|
+
return a * 16777216 + b * 65536 + c * 256 + d
|
|
31
|
+
end
|
|
32
|
+
M.ipv4_to_int = ipv4_to_int
|
|
33
|
+
|
|
34
|
+
-- Precompile a CIDR / bare-IP string into a cheap match descriptor.
|
|
35
|
+
local function compile_cidr(s)
|
|
36
|
+
local base, bits = string.match(s, "^([^/]+)/(%d+)$")
|
|
37
|
+
if base then
|
|
38
|
+
bits = tonumber(bits)
|
|
39
|
+
local basei = ipv4_to_int(base)
|
|
40
|
+
if not basei or bits < 0 or bits > 32 then return { exact = s } end
|
|
41
|
+
if bits == 0 then return { all = true } end
|
|
42
|
+
local div = 2 ^ (32 - bits) -- compare the top `bits` bits
|
|
43
|
+
return { net = math.floor(basei / div), div = div }
|
|
44
|
+
end
|
|
45
|
+
local i = ipv4_to_int(s)
|
|
46
|
+
if i then return { ip = i } end
|
|
47
|
+
return { exact = s } -- IPv6 / unparseable → literal string compare
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local function compile_list(list)
|
|
51
|
+
if type(list) ~= "table" then return nil end
|
|
52
|
+
local out, n = {}, 0
|
|
53
|
+
for _, s in ipairs(list) do
|
|
54
|
+
if type(s) == "string" and s ~= "" then
|
|
55
|
+
n = n + 1
|
|
56
|
+
out[n] = compile_cidr(s)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
return n > 0 and out or nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- Match a client (int form `ipi`, may be nil for non-IPv4; raw `ips`) against a
|
|
63
|
+
-- compiled CIDR/IP list.
|
|
64
|
+
local function match_compiled(compiled, ipi, ips)
|
|
65
|
+
if not compiled then return false end
|
|
66
|
+
for i = 1, #compiled do
|
|
67
|
+
local c = compiled[i]
|
|
68
|
+
if c.all then
|
|
69
|
+
return true
|
|
70
|
+
elseif c.ip then
|
|
71
|
+
if ipi and ipi == c.ip then return true end
|
|
72
|
+
elseif c.net then
|
|
73
|
+
if ipi and math.floor(ipi / c.div) == c.net then return true end
|
|
74
|
+
elseif c.exact == ips then
|
|
75
|
+
return true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
return false
|
|
79
|
+
end
|
|
80
|
+
M.match_compiled = match_compiled
|
|
81
|
+
|
|
82
|
+
local function lower_list(list)
|
|
83
|
+
if type(list) ~= "table" then return nil end
|
|
84
|
+
local out, n = {}, 0
|
|
85
|
+
for _, s in ipairs(list) do
|
|
86
|
+
if type(s) == "string" and s ~= "" then
|
|
87
|
+
n = n + 1
|
|
88
|
+
out[n] = string.lower(s)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
return n > 0 and out or nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
-- Build an O(1) lookup set from a string list (optionally lowercased).
|
|
95
|
+
local function to_set(list, lower)
|
|
96
|
+
if type(list) ~= "table" then return nil end
|
|
97
|
+
local set, any = {}, false
|
|
98
|
+
for _, s in ipairs(list) do
|
|
99
|
+
if type(s) == "string" and s ~= "" then
|
|
100
|
+
set[lower and string.lower(s) or s] = true
|
|
101
|
+
any = true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
return any and set or nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
-- Compile one RouteRuleSpec into a fast-match shape.
|
|
108
|
+
local function compile_spec(spec)
|
|
109
|
+
if type(spec) ~= "table" then return { blockStatus = 403 } end
|
|
110
|
+
local out = {}
|
|
111
|
+
|
|
112
|
+
local rl = spec.rateLimit
|
|
113
|
+
local rps = rl and tonumber(rl.rps)
|
|
114
|
+
if rps and rps > 0 then
|
|
115
|
+
out.rl = {
|
|
116
|
+
limit = math.floor(rps) + math.floor(tonumber(rl.burst) or 0),
|
|
117
|
+
status = tonumber(rl.status) or 429,
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
local ban = spec.ban
|
|
122
|
+
if type(ban) == "table" then
|
|
123
|
+
out.ban = {
|
|
124
|
+
ips = compile_list(ban.ips),
|
|
125
|
+
cidrs = compile_list(ban.cidrs),
|
|
126
|
+
countries = to_set(ban.countries),
|
|
127
|
+
uas = lower_list(ban.userAgents),
|
|
128
|
+
emptyUA = ban.emptyUserAgent == true,
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
local acc = spec.access
|
|
133
|
+
if type(acc) == "table" then
|
|
134
|
+
out.access = {
|
|
135
|
+
allow = compile_list(acc.allowCidrs),
|
|
136
|
+
deny = compile_list(acc.denyCidrs),
|
|
137
|
+
allowCountries = to_set(acc.allowCountries),
|
|
138
|
+
methods = to_set(acc.methods),
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
local hot = spec.hotlink
|
|
143
|
+
if type(hot) == "table" then
|
|
144
|
+
local refs = to_set(hot.allowReferers, true)
|
|
145
|
+
if refs then
|
|
146
|
+
out.hotlink = { referers = refs, allowEmpty = hot.allowEmpty ~= false }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
out.blockStatus = (type(spec.block) == "table" and tonumber(spec.block.status)) or 403
|
|
151
|
+
return out
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
local function parse(raw)
|
|
155
|
+
local entries = cjson.decode(raw)
|
|
156
|
+
if type(entries) ~= "table" then return {} end
|
|
157
|
+
local out, n = {}, 0
|
|
158
|
+
for _, e in ipairs(entries) do
|
|
159
|
+
if type(e) == "table" then
|
|
160
|
+
n = n + 1
|
|
161
|
+
out[n] = { pathPrefix = e.pathPrefix, spec = compile_spec(e.spec) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
return out
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
-- LRU-cached parse: reuse the compiled form until the raw dict string changes.
|
|
168
|
+
function M.get(host, raw)
|
|
169
|
+
local hit = cache:get(host)
|
|
170
|
+
if hit and hit.raw == raw then return hit.parsed end
|
|
171
|
+
local parsed = parse(raw)
|
|
172
|
+
cache:set(host, { raw = raw, parsed = parsed })
|
|
173
|
+
return parsed
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
return M
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
-- site_logger.lua
|
|
2
|
+
-- OpenResty log_by_lua - runs AFTER the response is sent.
|
|
3
|
+
-- Pure shared-dict analytics. No Redis, no timers for counters, no batching.
|
|
4
|
+
-- Every incr() is atomic across workers.
|
|
5
|
+
--
|
|
6
|
+
-- Shared dict key schema (analytics zone):
|
|
7
|
+
-- s:{domain}:{epoch_min}:r request count (TTL 24h)
|
|
8
|
+
-- s:{domain}:{epoch_min}:i bandwidth in bytes (TTL 24h)
|
|
9
|
+
-- s:{domain}:{epoch_min}:o bandwidth out bytes (TTL 24h)
|
|
10
|
+
-- s:{domain}:{epoch_min}:t response time sum (seconds) (TTL 24h)
|
|
11
|
+
-- s:{domain}:{epoch_min}:u unique (non-static) reqs (TTL 24h)
|
|
12
|
+
-- g:{domain}:{YYYYMMDD}:{CC} country hit count (TTL 48h)
|
|
13
|
+
-- c:{domain}:{epoch_min}:{CC} country per minute (TTL 24h)
|
|
14
|
+
-- t:{domain}:r / :i / :o lifetime totals (no TTL)
|
|
15
|
+
-- d:{domain} domain index marker (no TTL)
|
|
16
|
+
--
|
|
17
|
+
-- Shared dict key schema (request_data zone):
|
|
18
|
+
-- rlog:{domain}:seq monotonic write pointer
|
|
19
|
+
-- rlog:{domain}:{slot} JSON entry (ring buf) (TTL 1h)
|
|
20
|
+
-- log_pipe:sub:{domain} live subscriber flag (TTL 30s)
|
|
21
|
+
-- log_pipe:q:{domain} live log queue entries
|
|
22
|
+
|
|
23
|
+
local cjson = require "cjson.safe"
|
|
24
|
+
|
|
25
|
+
-- SAFE LOAD: GeoIP Module (per-worker, cached by require)
|
|
26
|
+
local geo_ok, geo = pcall(require, "openship.geo_country")
|
|
27
|
+
if not geo_ok then
|
|
28
|
+
geo = nil
|
|
29
|
+
ngx.log(ngx.WARN, "[site_logger] geo_country module not available. GeoIP disabled.")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
-- SAFE LOAD: Pipe module (per-worker, cached by require)
|
|
33
|
+
local pipe_ok, pipe_log = pcall(require, "openship.pipe_log")
|
|
34
|
+
if not pipe_ok then pipe_log = nil end
|
|
35
|
+
|
|
36
|
+
local analytics = ngx.shared.analytics
|
|
37
|
+
local request_data = ngx.shared.request_data
|
|
38
|
+
if not analytics or not request_data then return end
|
|
39
|
+
|
|
40
|
+
-- ── Helpers ──────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
local function normalize(host)
|
|
43
|
+
if not host then return nil end
|
|
44
|
+
local h = host:lower()
|
|
45
|
+
if h:sub(1, 4) == "www." then h = h:sub(5) end
|
|
46
|
+
return h
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
local _day = { str = "", ts = 0 }
|
|
50
|
+
local function today()
|
|
51
|
+
local now = ngx.now()
|
|
52
|
+
if now - _day.ts > 60 then
|
|
53
|
+
_day.str = os.date("!%Y%m%d", now)
|
|
54
|
+
_day.ts = now
|
|
55
|
+
end
|
|
56
|
+
return _day.str
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
-- Fast check if URI is a static asset (for unique_requests counter)
|
|
60
|
+
local function is_static_asset(u)
|
|
61
|
+
local lower = u:lower()
|
|
62
|
+
|
|
63
|
+
-- Static file extensions
|
|
64
|
+
if lower:match("%.js$") or lower:match("%.css$") or
|
|
65
|
+
lower:match("%.jpg$") or lower:match("%.jpeg$") or
|
|
66
|
+
lower:match("%.png$") or lower:match("%.gif$") or
|
|
67
|
+
lower:match("%.svg$") or lower:match("%.webp$") or
|
|
68
|
+
lower:match("%.ico$") or lower:match("%.bmp$") or
|
|
69
|
+
lower:match("%.woff2?$") or lower:match("%.ttf$") or
|
|
70
|
+
lower:match("%.eot$") or lower:match("%.otf$") or
|
|
71
|
+
lower:match("%.mp4$") or lower:match("%.webm$") or
|
|
72
|
+
lower:match("%.mp3$") or lower:match("%.wav$") or
|
|
73
|
+
lower:match("%.pdf$") or lower:match("%.zip$") or
|
|
74
|
+
lower:match("%.tar$") or lower:match("%.gz$") or
|
|
75
|
+
lower:match("%.xml$") or lower:match("%.json$") or
|
|
76
|
+
lower:match("%.txt$") or lower:match("%.map$") then
|
|
77
|
+
return true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
-- Common static paths
|
|
81
|
+
if lower:match("^/_next/static/") or
|
|
82
|
+
lower:match("^/static/") or
|
|
83
|
+
lower:match("^/assets/") or
|
|
84
|
+
lower:match("^/public/") or
|
|
85
|
+
lower:match("/favicon%.ico") then
|
|
86
|
+
return true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
local RING = 1000
|
|
93
|
+
local D24H = 86400
|
|
94
|
+
local D48H = 172800
|
|
95
|
+
local D1H = 3600
|
|
96
|
+
|
|
97
|
+
-- ── Capture ──────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
local host = normalize(ngx.var.host)
|
|
100
|
+
if not host then return end
|
|
101
|
+
|
|
102
|
+
local ip = ngx.var.remote_addr or "0.0.0.0"
|
|
103
|
+
local ts = ngx.now()
|
|
104
|
+
local ua = ngx.var.http_user_agent or ""
|
|
105
|
+
local uri = ngx.var.request_uri or "/"
|
|
106
|
+
local req_len = tonumber(ngx.var.request_length) or 0
|
|
107
|
+
local bytes = tonumber(ngx.var.bytes_sent) or 0
|
|
108
|
+
local rt = tonumber(ngx.var.request_time) or 0 -- seconds (float)
|
|
109
|
+
local method = ngx.var.request_method or "GET"
|
|
110
|
+
local status = ngx.var.status or "0"
|
|
111
|
+
|
|
112
|
+
-- ── 1. Minute-bucket counters ────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
local minute = math.floor(ts / 60)
|
|
115
|
+
local p = "s:" .. host .. ":" .. minute
|
|
116
|
+
|
|
117
|
+
analytics:incr(p .. ":r", 1, 0, D24H)
|
|
118
|
+
analytics:incr(p .. ":i", req_len, 0, D24H)
|
|
119
|
+
analytics:incr(p .. ":o", bytes, 0, D24H)
|
|
120
|
+
|
|
121
|
+
-- Response time as float seconds (matches original precision)
|
|
122
|
+
if rt > 0 then
|
|
123
|
+
-- safe_add initializes, incr adds - emulate HINCRBYFLOAT with integer micros
|
|
124
|
+
local rt_us = math.floor(rt * 1000000)
|
|
125
|
+
analytics:incr(p .. ":t", rt_us, 0, D24H)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
-- Unique (non-static) request counter
|
|
129
|
+
if not is_static_asset(uri) then
|
|
130
|
+
analytics:incr(p .. ":u", 1, 0, D24H)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
-- ── 2. Lifetime totals ──────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
analytics:incr("t:" .. host .. ":r", 1, 0)
|
|
136
|
+
analytics:incr("t:" .. host .. ":i", req_len, 0)
|
|
137
|
+
analytics:incr("t:" .. host .. ":o", bytes, 0)
|
|
138
|
+
|
|
139
|
+
-- Domain index (set once, ignore subsequent "exists" errors)
|
|
140
|
+
analytics:safe_add("d:" .. host, 1)
|
|
141
|
+
|
|
142
|
+
-- ── 3. GeoIP ─────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
if geo then
|
|
145
|
+
local cc = geo.get_country_code(ip)
|
|
146
|
+
if cc and cc ~= "" then
|
|
147
|
+
-- Daily geo (for /analytics/geo endpoint)
|
|
148
|
+
analytics:incr("g:" .. host .. ":" .. today() .. ":" .. cc, 1, 0, D48H)
|
|
149
|
+
-- Per-minute geo (for time-series country breakdown)
|
|
150
|
+
analytics:incr("c:" .. host .. ":" .. minute .. ":" .. cc, 1, 0, D24H)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
-- ── 4. Raw request ring buffer ──────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
pcall(function()
|
|
157
|
+
local ok_j, j = pcall(cjson.encode, {
|
|
158
|
+
ip = ip,
|
|
159
|
+
ts = ts,
|
|
160
|
+
method = method,
|
|
161
|
+
status = status,
|
|
162
|
+
uri = uri,
|
|
163
|
+
ua = ua,
|
|
164
|
+
bw_in = req_len,
|
|
165
|
+
bw_out = bytes,
|
|
166
|
+
rt = rt,
|
|
167
|
+
})
|
|
168
|
+
if ok_j and j then
|
|
169
|
+
local seq = request_data:incr("rlog:" .. host .. ":seq", 1, 0)
|
|
170
|
+
local slot = seq % RING
|
|
171
|
+
request_data:set("rlog:" .. host .. ":" .. slot, j, D1H)
|
|
172
|
+
end
|
|
173
|
+
end)
|
|
174
|
+
|
|
175
|
+
-- ── 5. Live-log pipe (only when a subscriber is watching) ───────────────────
|
|
176
|
+
-- Fire via timer to avoid blocking the log phase
|
|
177
|
+
|
|
178
|
+
if pipe_log and pipe_log.pipe_request_log
|
|
179
|
+
and request_data:get("log_pipe:sub:" .. host) then
|
|
180
|
+
ngx.timer.at(0, pipe_log.pipe_request_log,
|
|
181
|
+
host, ip, ts, ua, uri, req_len, bytes, rt, method, status)
|
|
182
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
-- webhook_handler.lua
|
|
2
|
+
-- Handles incoming webhook events forwarded from the SaaS edge.
|
|
3
|
+
-- Internal management port only (127.0.0.1:9145).
|
|
4
|
+
--
|
|
5
|
+
-- Routes:
|
|
6
|
+
-- POST /_hooks/push - receive a forwarded GitHub push event
|
|
7
|
+
-- GET /_hooks/pending - fetch stored events (for local app polling)
|
|
8
|
+
-- POST /_hooks/ack - acknowledge consumed events
|
|
9
|
+
--
|
|
10
|
+
-- Behavior on POST /_hooks/push:
|
|
11
|
+
-- 1. Try to proxy to a local Openship API (configurable via X-Openship-Url header)
|
|
12
|
+
-- 2. If Openship is not reachable, store the event in shared dict for later polling
|
|
13
|
+
|
|
14
|
+
local cjson = require "cjson.safe"
|
|
15
|
+
|
|
16
|
+
local webhook_events = ngx.shared.webhook_events
|
|
17
|
+
local uri = ngx.var.uri
|
|
18
|
+
local method = ngx.req.get_method()
|
|
19
|
+
|
|
20
|
+
local function json_response(data, code)
|
|
21
|
+
ngx.status = code or 200
|
|
22
|
+
ngx.header["Content-Type"] = "application/json"
|
|
23
|
+
ngx.say(cjson.encode(data))
|
|
24
|
+
return ngx.exit(ngx.status)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
-- ── POST /_hooks/push ────────────────────────────────────────────────────────
|
|
28
|
+
-- Receives forwarded webhook payload from SaaS.
|
|
29
|
+
-- Headers:
|
|
30
|
+
-- X-Openship-Url: http://127.0.0.1:3456 (if Openship runs on this server)
|
|
31
|
+
-- X-Hook-Id: the subscription hookId (for correlation)
|
|
32
|
+
|
|
33
|
+
if uri == "/_hooks/push" and method == "POST" then
|
|
34
|
+
ngx.req.read_body()
|
|
35
|
+
local body = ngx.req.get_body_data()
|
|
36
|
+
if not body then
|
|
37
|
+
return json_response({ error = "empty body" }, 400)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local openship_url = ngx.req.get_headers()["x-openship-url"]
|
|
41
|
+
|
|
42
|
+
-- If Openship runs on this server, proxy directly
|
|
43
|
+
if openship_url and openship_url ~= "" then
|
|
44
|
+
local http = require "resty.http"
|
|
45
|
+
local httpc = http.new()
|
|
46
|
+
httpc:set_timeout(5000)
|
|
47
|
+
|
|
48
|
+
local target = openship_url .. "/api/webhooks/push"
|
|
49
|
+
local res, err = httpc:request_uri(target, {
|
|
50
|
+
method = "POST",
|
|
51
|
+
body = body,
|
|
52
|
+
headers = {
|
|
53
|
+
["Content-Type"] = "application/json",
|
|
54
|
+
["X-Hook-Id"] = ngx.req.get_headers()["x-hook-id"] or "",
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if res and res.status < 500 then
|
|
59
|
+
-- Openship handled it (2xx or 4xx)
|
|
60
|
+
return json_response({ ok = true, proxied = true, status = res.status })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
-- Openship unreachable or errored - fall through to store
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
-- Store for later polling by local/desktop app
|
|
67
|
+
local event_id = ngx.now() .. ":" .. math.random(100000, 999999)
|
|
68
|
+
local event = cjson.encode({
|
|
69
|
+
id = event_id,
|
|
70
|
+
hook_id = ngx.req.get_headers()["x-hook-id"] or "",
|
|
71
|
+
payload = body,
|
|
72
|
+
received = ngx.now(),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
-- Use a slot-based ring buffer (max 200 events)
|
|
76
|
+
local max_events = 200
|
|
77
|
+
local idx = webhook_events:incr("_idx", 1, 0) or 0
|
|
78
|
+
local slot = (idx % max_events)
|
|
79
|
+
webhook_events:set("evt:" .. slot, event, 3600) -- 1h TTL
|
|
80
|
+
webhook_events:set("_count", math.min(idx, max_events))
|
|
81
|
+
|
|
82
|
+
return json_response({ ok = true, stored = true, event_id = event_id })
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
-- ── GET /_hooks/pending ──────────────────────────────────────────────────────
|
|
86
|
+
-- Returns stored webhook events for local app to consume.
|
|
87
|
+
|
|
88
|
+
if uri == "/_hooks/pending" and method == "GET" then
|
|
89
|
+
if not webhook_events then
|
|
90
|
+
return json_response({ events = {} })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
local count = webhook_events:get("_count") or 0
|
|
94
|
+
local idx = webhook_events:get("_idx") or 0
|
|
95
|
+
local events = {}
|
|
96
|
+
|
|
97
|
+
-- Read all stored events (newest first)
|
|
98
|
+
local max_events = 200
|
|
99
|
+
local start = math.max(0, idx - count)
|
|
100
|
+
for i = start, idx - 1 do
|
|
101
|
+
local slot = i % max_events
|
|
102
|
+
local raw = webhook_events:get("evt:" .. slot)
|
|
103
|
+
if raw then
|
|
104
|
+
local parsed = cjson.decode(raw)
|
|
105
|
+
if parsed then
|
|
106
|
+
events[#events + 1] = parsed
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return json_response({ events = events })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
-- ── POST /_hooks/ack ─────────────────────────────────────────────────────────
|
|
115
|
+
-- Acknowledge events have been consumed - clears the store.
|
|
116
|
+
|
|
117
|
+
if uri == "/_hooks/ack" and method == "POST" then
|
|
118
|
+
if webhook_events then
|
|
119
|
+
webhook_events:flush_all()
|
|
120
|
+
webhook_events:flush_expired()
|
|
121
|
+
end
|
|
122
|
+
return json_response({ ok = true })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
-- ── Fallback ─────────────────────────────────────────────────────────────────
|
|
126
|
+
return json_response({ error = "not found" }, 404)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "domain" ADD COLUMN "external_ingress" boolean DEFAULT false NOT NULL;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
CREATE TABLE "route_rule" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"organization_id" text NOT NULL,
|
|
4
|
+
"project_id" text NOT NULL,
|
|
5
|
+
"domain_id" text,
|
|
6
|
+
"path_prefix" text,
|
|
7
|
+
"spec" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
8
|
+
"enabled" boolean DEFAULT true NOT NULL,
|
|
9
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
10
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
11
|
+
);
|
|
12
|
+
--> statement-breakpoint
|
|
13
|
+
ALTER TABLE "route_rule" ADD CONSTRAINT "route_rule_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
14
|
+
ALTER TABLE "route_rule" ADD CONSTRAINT "route_rule_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
15
|
+
ALTER TABLE "route_rule" ADD CONSTRAINT "route_rule_domain_id_domain_id_fk" FOREIGN KEY ("domain_id") REFERENCES "public"."domain"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
16
|
+
CREATE INDEX "idx_route_rule_project" ON "route_rule" USING btree ("project_id");--> statement-breakpoint
|
|
17
|
+
CREATE INDEX "idx_route_rule_domain" ON "route_rule" USING btree ("domain_id");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
CREATE TABLE "docker_migration_run" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"organization_id" text NOT NULL,
|
|
4
|
+
"source_server_id" text,
|
|
5
|
+
"target_server_id" text,
|
|
6
|
+
"project_id" text,
|
|
7
|
+
"project_name" text NOT NULL,
|
|
8
|
+
"service_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
9
|
+
"status" text DEFAULT 'queued' NOT NULL,
|
|
10
|
+
"mode" text DEFAULT 'cross_server' NOT NULL,
|
|
11
|
+
"deployment_id" text,
|
|
12
|
+
"kill_originals" boolean DEFAULT false NOT NULL,
|
|
13
|
+
"confirmation_token" text,
|
|
14
|
+
"volume_plan" jsonb DEFAULT '[]'::jsonb,
|
|
15
|
+
"scanned_container_ids" jsonb DEFAULT '{}'::jsonb,
|
|
16
|
+
"bytes_moved" bigint,
|
|
17
|
+
"error_message" text,
|
|
18
|
+
"started_at" timestamp DEFAULT now() NOT NULL,
|
|
19
|
+
"finished_at" timestamp,
|
|
20
|
+
"last_event_at" timestamp DEFAULT now() NOT NULL
|
|
21
|
+
);
|
|
22
|
+
--> statement-breakpoint
|
|
23
|
+
ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
24
|
+
ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_source_server_id_servers_id_fk" FOREIGN KEY ("source_server_id") REFERENCES "public"."servers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
25
|
+
ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_target_server_id_servers_id_fk" FOREIGN KEY ("target_server_id") REFERENCES "public"."servers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
26
|
+
ALTER TABLE "docker_migration_run" ADD CONSTRAINT "docker_migration_run_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
27
|
+
CREATE INDEX "idx_docker_migration_run_org_started" ON "docker_migration_run" USING btree ("organization_id","started_at");--> statement-breakpoint
|
|
28
|
+
CREATE INDEX "idx_docker_migration_run_in_flight" ON "docker_migration_run" USING btree ("status") WHERE "docker_migration_run"."status" IN ('queued','adopting','moving_data','deploying','verifying','awaiting_cutover','cutover');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "domain" ADD COLUMN "manual_ssl" boolean DEFAULT false NOT NULL;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CREATE TABLE "job_run" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"job_id" text NOT NULL,
|
|
4
|
+
"kind" text DEFAULT 'system' NOT NULL,
|
|
5
|
+
"trigger" text DEFAULT 'schedule' NOT NULL,
|
|
6
|
+
"status" text NOT NULL,
|
|
7
|
+
"started_at" timestamp DEFAULT now() NOT NULL,
|
|
8
|
+
"finished_at" timestamp,
|
|
9
|
+
"duration_ms" integer,
|
|
10
|
+
"summary" jsonb,
|
|
11
|
+
"error" text,
|
|
12
|
+
"created_at" timestamp DEFAULT now() NOT NULL
|
|
13
|
+
);
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
CREATE INDEX "job_run_job_started_idx" ON "job_run" USING btree ("job_id","started_at");
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
CREATE TABLE "job" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"key" text NOT NULL,
|
|
4
|
+
"kind" text DEFAULT 'system' NOT NULL,
|
|
5
|
+
"label" text NOT NULL,
|
|
6
|
+
"cron_expression" text NOT NULL,
|
|
7
|
+
"enabled" boolean DEFAULT true NOT NULL,
|
|
8
|
+
"action_type" text DEFAULT 'builtin' NOT NULL,
|
|
9
|
+
"action_config" jsonb,
|
|
10
|
+
"created_by" text,
|
|
11
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
12
|
+
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
13
|
+
CONSTRAINT "job_key_unique" UNIQUE("key")
|
|
14
|
+
);
|
|
15
|
+
--> statement-breakpoint
|
|
16
|
+
CREATE INDEX "job_kind_idx" ON "job" USING btree ("kind");
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
ALTER TABLE "project" ADD COLUMN "is_app" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "project" ADD COLUMN "app_template_id" text;--> statement-breakpoint
|
|
3
|
+
-- Backfill: existing webmail projects are managed apps → move them to the Apps tab.
|
|
4
|
+
UPDATE "project" SET "is_app" = true, "app_template_id" = 'mail-webmail' WHERE "framework" = 'webmail';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "job_run" ADD COLUMN "output" text;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CREATE TABLE "system_notice" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"severity" text DEFAULT 'info' NOT NULL,
|
|
4
|
+
"title" text NOT NULL,
|
|
5
|
+
"message" text NOT NULL,
|
|
6
|
+
"action_label" text,
|
|
7
|
+
"action_url" text,
|
|
8
|
+
"active" boolean DEFAULT true NOT NULL,
|
|
9
|
+
"starts_at" timestamp,
|
|
10
|
+
"ends_at" timestamp,
|
|
11
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
12
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
13
|
+
);
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
CREATE INDEX "idx_system_notice_active" ON "system_notice" USING btree ("active");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ALTER TABLE "job" ALTER COLUMN "cron_expression" DROP NOT NULL;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "job" ADD COLUMN "schedule_type" text DEFAULT 'recurring' NOT NULL;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "job" ADD COLUMN "run_at" timestamp;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "job" ADD COLUMN "depends_on" text[];--> statement-breakpoint
|
|
5
|
+
ALTER TABLE "job" ADD COLUMN "trigger_events" text[];--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "job" ADD COLUMN "notify_config" jsonb;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "job_run" ADD COLUMN "server_id" text;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "job_run" ADD COLUMN "attempt" integer DEFAULT 1 NOT NULL;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "service" ADD COLUMN "public_endpoints" jsonb DEFAULT '[]'::jsonb;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
CREATE TABLE "github_deploy_key" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"server_id" text NOT NULL,
|
|
4
|
+
"organization_id" text NOT NULL,
|
|
5
|
+
"owner" text NOT NULL,
|
|
6
|
+
"repo" text NOT NULL,
|
|
7
|
+
"github_key_id" integer,
|
|
8
|
+
"private_key_encrypted" text NOT NULL,
|
|
9
|
+
"public_key" text NOT NULL,
|
|
10
|
+
"read_only" boolean DEFAULT true NOT NULL,
|
|
11
|
+
"created_at" timestamp DEFAULT now() NOT NULL
|
|
12
|
+
);
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
CREATE TABLE "server_github_auth" (
|
|
15
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
16
|
+
"server_id" text NOT NULL,
|
|
17
|
+
"organization_id" text NOT NULL,
|
|
18
|
+
"mode" text NOT NULL,
|
|
19
|
+
"token_encrypted" text,
|
|
20
|
+
"token_source" text,
|
|
21
|
+
"token_login" text,
|
|
22
|
+
"server_key_private_encrypted" text,
|
|
23
|
+
"server_key_public" text,
|
|
24
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
25
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
26
|
+
);
|
|
27
|
+
--> statement-breakpoint
|
|
28
|
+
ALTER TABLE "github_deploy_key" ADD CONSTRAINT "github_deploy_key_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
29
|
+
ALTER TABLE "github_deploy_key" ADD CONSTRAINT "github_deploy_key_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
30
|
+
ALTER TABLE "server_github_auth" ADD CONSTRAINT "server_github_auth_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
31
|
+
ALTER TABLE "server_github_auth" ADD CONSTRAINT "server_github_auth_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
32
|
+
CREATE UNIQUE INDEX "uq_github_deploy_key_server_repo" ON "github_deploy_key" USING btree ("server_id","owner","repo");--> statement-breakpoint
|
|
33
|
+
CREATE INDEX "idx_github_deploy_key_org_server" ON "github_deploy_key" USING btree ("organization_id","server_id");--> statement-breakpoint
|
|
34
|
+
CREATE UNIQUE INDEX "uq_server_github_auth_server" ON "server_github_auth" USING btree ("server_id");--> statement-breakpoint
|
|
35
|
+
CREATE INDEX "idx_server_github_auth_org" ON "server_github_auth" USING btree ("organization_id");
|