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.
Files changed (42) hide show
  1. package/dist/index.js +680 -104
  2. package/dist/server/index.js +17063 -8214
  3. package/dist/server/lua/geo_country.lua +101 -0
  4. package/dist/server/lua/mgmt_api.lua +291 -0
  5. package/dist/server/lua/pipe_log.lua +71 -0
  6. package/dist/server/lua/pipe_stream.lua +90 -0
  7. package/dist/server/lua/rules_guard.lua +118 -0
  8. package/dist/server/lua/rules_lib.lua +176 -0
  9. package/dist/server/lua/site_logger.lua +182 -0
  10. package/dist/server/lua/webhook_handler.lua +126 -0
  11. package/dist/server/migrations/0035_thankful_morgan_stark.sql +1 -0
  12. package/dist/server/migrations/0036_gorgeous_scalphunter.sql +17 -0
  13. package/dist/server/migrations/0037_clever_makkari.sql +28 -0
  14. package/dist/server/migrations/0038_young_magneto.sql +1 -0
  15. package/dist/server/migrations/0039_secret_lady_ursula.sql +15 -0
  16. package/dist/server/migrations/0040_sharp_red_wolf.sql +16 -0
  17. package/dist/server/migrations/0041_round_spyke.sql +4 -0
  18. package/dist/server/migrations/0042_dapper_havok.sql +1 -0
  19. package/dist/server/migrations/0043_fast_sharon_ventura.sql +15 -0
  20. package/dist/server/migrations/0044_amused_shape.sql +8 -0
  21. package/dist/server/migrations/0045_amusing_korath.sql +1 -0
  22. package/dist/server/migrations/0046_white_harrier.sql +35 -0
  23. package/dist/server/migrations/0047_sloppy_strong_guy.sql +2 -0
  24. package/dist/server/migrations/0048_funny_emma_frost.sql +2 -0
  25. package/dist/server/migrations/0049_nappy_piledriver.sql +3 -0
  26. package/dist/server/migrations/meta/0035_snapshot.json +7610 -0
  27. package/dist/server/migrations/meta/0036_snapshot.json +7752 -0
  28. package/dist/server/migrations/meta/0037_snapshot.json +7978 -0
  29. package/dist/server/migrations/meta/0038_snapshot.json +7985 -0
  30. package/dist/server/migrations/meta/0039_snapshot.json +8090 -0
  31. package/dist/server/migrations/meta/0040_snapshot.json +8198 -0
  32. package/dist/server/migrations/meta/0041_snapshot.json +8211 -0
  33. package/dist/server/migrations/meta/0042_snapshot.json +8217 -0
  34. package/dist/server/migrations/meta/0043_snapshot.json +8316 -0
  35. package/dist/server/migrations/meta/0044_snapshot.json +8360 -0
  36. package/dist/server/migrations/meta/0045_snapshot.json +8367 -0
  37. package/dist/server/migrations/meta/0046_snapshot.json +8657 -0
  38. package/dist/server/migrations/meta/0047_snapshot.json +8669 -0
  39. package/dist/server/migrations/meta/0048_snapshot.json +8683 -0
  40. package/dist/server/migrations/meta/0049_snapshot.json +8702 -0
  41. package/dist/server/migrations/meta/_journal.json +105 -0
  42. package/package.json +4 -1
@@ -0,0 +1,101 @@
1
+ -- geo_country.lua
2
+ -- MaxMind GeoLite2-Country lookup with per-worker LRU cache.
3
+ --
4
+ -- DB and cache are per-worker only: one load per worker, shared by all
5
+ -- requests. No reload per stream or per request.
6
+ --
7
+ -- Dependencies (installed by deployLuaScripts):
8
+ -- opm: anjia0532/lua-resty-maxminddb
9
+ -- apt: libmaxminddb0 libmaxminddb-dev (C library for FFI)
10
+ -- Database: /usr/share/GeoIP/GeoLite2-Country.mmdb
11
+ --
12
+ -- Falls back gracefully - if the library or database is missing,
13
+ -- get_country_code() returns nil. Both site_logger.lua and pipe_log.lua
14
+ -- wrap this module in pcall(require, ...) so a missing DB never crashes
15
+ -- the request pipeline.
16
+
17
+ local _M = {}
18
+
19
+ local DB_PATH = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
20
+
21
+ local mmdb_ok, maxmind = pcall(require, "resty.maxminddb")
22
+ local lrucache_ok, lrucache = pcall(require, "resty.lrucache")
23
+
24
+ local cache -- one LRU per worker
25
+ local geo_initted = false -- DB opened once per worker on first lookup
26
+
27
+ -- Init MaxMind once per worker; subsequent lookups (any stream) reuse.
28
+ local function ensure_geo_init()
29
+ if geo_initted then
30
+ return true
31
+ end
32
+ if not mmdb_ok or not maxmind then
33
+ return false
34
+ end
35
+ local ok, err = maxmind.init(DB_PATH)
36
+ if not ok then
37
+ ngx.log(ngx.WARN,
38
+ "[geo_country] GeoLite2 database not found at " .. DB_PATH ..
39
+ " - geo lookups disabled: " .. (err or "unknown"))
40
+ return false
41
+ end
42
+ geo_initted = true
43
+ ngx.log(ngx.INFO, "[geo_country] GeoLite2-Country loaded from " .. DB_PATH)
44
+ return true
45
+ end
46
+
47
+ -- Create LRU cache once per worker (max 50000 IPs).
48
+ local function get_cache()
49
+ if cache then
50
+ return cache
51
+ end
52
+ if not lrucache_ok or not lrucache then
53
+ return nil
54
+ end
55
+ local c, err = lrucache.new(50000)
56
+ if not c then
57
+ return nil
58
+ end
59
+ cache = c
60
+ return cache
61
+ end
62
+
63
+ --- Get country ISO code for IP. Uses in-memory LRU cache then MaxMind DB.
64
+ -- @param ip string (e.g. "8.8.8.8")
65
+ -- @return string|nil country code (e.g. "US") or nil
66
+ function _M.get_country_code(ip)
67
+ if not ip or ip == "" then
68
+ return nil
69
+ end
70
+
71
+ local c = get_cache()
72
+ if c then
73
+ local cached = c:get(ip)
74
+ if cached ~= nil then
75
+ -- false means "looked up, no result" - avoids repeated DB misses
76
+ return (cached == false) and nil or cached
77
+ end
78
+ end
79
+
80
+ if not ensure_geo_init() then
81
+ if c then c:set(ip, false) end
82
+ return nil
83
+ end
84
+
85
+ local ok, res, err = pcall(maxmind.lookup, ip)
86
+ local code = nil
87
+ if ok and res and res.country and res.country.iso_code then
88
+ code = res.country.iso_code
89
+ end
90
+
91
+ if c then
92
+ c:set(ip, code or false)
93
+ end
94
+ return code
95
+ end
96
+
97
+ function _M.ensure_geo_init()
98
+ return ensure_geo_init()
99
+ end
100
+
101
+ return _M
@@ -0,0 +1,291 @@
1
+ -- mgmt_api.lua
2
+ -- REST analytics endpoints backed by ngx.shared.dict.
3
+ -- Internal management port only (127.0.0.1:9145).
4
+
5
+ local cjson = require "cjson.safe"
6
+
7
+ local analytics = ngx.shared.analytics
8
+ local request_data = ngx.shared.request_data
9
+ local uri = ngx.var.uri
10
+
11
+ local function json(data, code)
12
+ ngx.status = code or 200
13
+ ngx.header["Content-Type"] = "application/json"
14
+ ngx.say(cjson.encode(data))
15
+ return ngx.exit(ngx.status)
16
+ end
17
+
18
+ local function bad(msg)
19
+ return json({ error = msg }, 400)
20
+ end
21
+
22
+ -- ── GET /health ──────────────────────────────────────────────────────────────
23
+
24
+ if uri == "/health" then
25
+ ngx.say("ok")
26
+ return ngx.exit(200)
27
+ end
28
+
29
+ -- ── /rules - per-route rules cache (written by the API, read by rules_guard) ──
30
+ -- POST /rules body: { "host": "...", "rules": [ { "pathPrefix": "/", "spec": {...} } ] }
31
+ -- → replaces the ruleset for that host in the `rules` shared dict (reload-free).
32
+ -- An empty/absent `rules` array clears the host.
33
+ -- GET /rules?host=... → the stored ruleset (debug).
34
+ -- DELETE /rules?host=... → clear the host's ruleset.
35
+ -- Handled BEFORE the analytics guard: rules use their own dict.
36
+ if uri == "/rules" then
37
+ local rules = ngx.shared.rules
38
+ if not rules then return json({ error = "rules dict unavailable" }, 503) end
39
+ local method = ngx.req.get_method()
40
+
41
+ if method == "GET" then
42
+ local host = ngx.var.arg_host
43
+ if not host or host == "" then return bad("missing ?host=") end
44
+ host = host:lower()
45
+ local raw = rules:get(host)
46
+ return json({ host = host, rules = raw and cjson.decode(raw) or {} })
47
+ end
48
+
49
+ if method == "DELETE" then
50
+ local host = ngx.var.arg_host
51
+ if not host or host == "" then return bad("missing ?host=") end
52
+ rules:delete(host:lower())
53
+ return json({ ok = true })
54
+ end
55
+
56
+ if method == "POST" then
57
+ ngx.req.read_body()
58
+ local body = ngx.req.get_body_data()
59
+ if not body then return bad("empty body") end
60
+ local payload = cjson.decode(body)
61
+ if type(payload) ~= "table" or not payload.host then
62
+ return bad("expected { host, rules: [...] }")
63
+ end
64
+ local host = tostring(payload.host):lower()
65
+ local list = payload.rules
66
+ if type(list) ~= "table" or #list == 0 then
67
+ rules:delete(host) -- empty set = clear the host
68
+ return json({ ok = true, host = host, count = 0 })
69
+ end
70
+ local ok, err = rules:set(host, cjson.encode(list))
71
+ if not ok then return json({ error = "set failed: " .. (err or "?") }, 500) end
72
+ return json({ ok = true, host = host, count = #list })
73
+ end
74
+
75
+ return json({ error = "method not allowed" }, 405)
76
+ end
77
+
78
+ if not analytics then
79
+ return json({ error = "analytics dict unavailable" }, 503)
80
+ end
81
+
82
+ -- ── GET /analytics - minute-bucket time series ───────────────────────────────
83
+ -- ?domain=example.com&from=EPOCH_MIN&to=EPOCH_MIN
84
+
85
+ if uri == "/analytics" then
86
+ local domain = ngx.var.arg_domain
87
+ if not domain or domain == "" then return bad("missing ?domain=") end
88
+ domain = domain:lower()
89
+
90
+ local from_m = tonumber(ngx.var.arg_from)
91
+ local to_m = tonumber(ngx.var.arg_to)
92
+ if not from_m or not to_m then
93
+ return bad("missing ?from= and ?to= (epoch minutes)")
94
+ end
95
+ -- Cap at 24h
96
+ if to_m - from_m > 1440 then to_m = from_m + 1440 end
97
+
98
+ local buckets = {}
99
+ for m = from_m, to_m do
100
+ local p = "s:" .. domain .. ":" .. m
101
+ local r = analytics:get(p .. ":r")
102
+ if r then
103
+ -- Response time stored as microseconds, convert to seconds (float)
104
+ local rt_us = analytics:get(p .. ":t") or 0
105
+ local bucket = {
106
+ minute = m,
107
+ requests = r,
108
+ unique_requests = analytics:get(p .. ":u") or 0,
109
+ bandwidth_in = analytics:get(p .. ":i") or 0,
110
+ bandwidth_out = analytics:get(p .. ":o") or 0,
111
+ response_time = rt_us / 1000000,
112
+ }
113
+
114
+ -- Collect per-minute country data if present
115
+ local cpfx = "c:" .. domain .. ":" .. m .. ":"
116
+ local all_keys = analytics:get_keys(10000)
117
+ local countries = {}
118
+ local has_countries = false
119
+ for _, k in ipairs(all_keys) do
120
+ if k:sub(1, #cpfx) == cpfx then
121
+ countries[k:sub(#cpfx + 1)] = analytics:get(k) or 0
122
+ has_countries = true
123
+ end
124
+ end
125
+ if has_countries then
126
+ bucket.countries = countries
127
+ end
128
+
129
+ buckets[#buckets + 1] = bucket
130
+ end
131
+ end
132
+
133
+ return json({ domain = domain, buckets = buckets })
134
+ end
135
+
136
+ -- ── POST /analytics/flush - read + delete minute buckets ─────────────────────
137
+ -- Same as GET /analytics but deletes the returned buckets from shared memory.
138
+ -- Used by the scraper to atomically move data from OpenResty → DB.
139
+ -- ?domain=example.com&from=EPOCH_MIN&to=EPOCH_MIN
140
+
141
+ if uri == "/analytics/flush" and ngx.req.get_method() == "POST" then
142
+ local domain = ngx.var.arg_domain
143
+ if not domain or domain == "" then return bad("missing ?domain=") end
144
+ domain = domain:lower()
145
+
146
+ local from_m = tonumber(ngx.var.arg_from)
147
+ local to_m = tonumber(ngx.var.arg_to)
148
+ if not from_m or not to_m then
149
+ return bad("missing ?from= and ?to= (epoch minutes)")
150
+ end
151
+ if to_m - from_m > 1440 then to_m = from_m + 1440 end
152
+
153
+ local buckets = {}
154
+ local flushed = 0
155
+ for m = from_m, to_m do
156
+ local p = "s:" .. domain .. ":" .. m
157
+ local r = analytics:get(p .. ":r")
158
+ if r then
159
+ local rt_us = analytics:get(p .. ":t") or 0
160
+ local bucket = {
161
+ minute = m,
162
+ requests = r,
163
+ unique_requests = analytics:get(p .. ":u") or 0,
164
+ bandwidth_in = analytics:get(p .. ":i") or 0,
165
+ bandwidth_out = analytics:get(p .. ":o") or 0,
166
+ response_time = rt_us / 1000000,
167
+ }
168
+
169
+ -- Collect per-minute country data
170
+ local cpfx = "c:" .. domain .. ":" .. m .. ":"
171
+ local all_keys = analytics:get_keys(10000)
172
+ local countries = {}
173
+ local has_countries = false
174
+ for _, k in ipairs(all_keys) do
175
+ if k:sub(1, #cpfx) == cpfx then
176
+ countries[k:sub(#cpfx + 1)] = analytics:get(k) or 0
177
+ analytics:delete(k)
178
+ has_countries = true
179
+ end
180
+ end
181
+ if has_countries then bucket.countries = countries end
182
+
183
+ buckets[#buckets + 1] = bucket
184
+
185
+ -- Delete the minute-bucket counter keys
186
+ analytics:delete(p .. ":r")
187
+ analytics:delete(p .. ":i")
188
+ analytics:delete(p .. ":o")
189
+ analytics:delete(p .. ":t")
190
+ analytics:delete(p .. ":u")
191
+ flushed = flushed + 1
192
+ end
193
+ end
194
+
195
+ return json({ domain = domain, buckets = buckets, flushed = flushed })
196
+ end
197
+
198
+ -- ── GET /analytics/totals - lifetime counters ────────────────────────────────
199
+ -- ?domain=example.com → single domain
200
+ -- (no domain) → all known domains
201
+
202
+ if uri == "/analytics/totals" then
203
+ local domain = ngx.var.arg_domain
204
+
205
+ if not domain or domain == "" then
206
+ local keys = analytics:get_keys(10000)
207
+ local domains = {}
208
+ for _, k in ipairs(keys) do
209
+ local d = k:match("^d:(.+)$")
210
+ if d then
211
+ domains[#domains + 1] = {
212
+ domain = d,
213
+ requests = analytics:get("t:" .. d .. ":r") or 0,
214
+ bandwidth_in = analytics:get("t:" .. d .. ":i") or 0,
215
+ bandwidth_out = analytics:get("t:" .. d .. ":o") or 0,
216
+ }
217
+ end
218
+ end
219
+ return json({ domains = domains })
220
+ end
221
+
222
+ domain = domain:lower()
223
+ return json({
224
+ domain = domain,
225
+ requests = analytics:get("t:" .. domain .. ":r") or 0,
226
+ bandwidth_in = analytics:get("t:" .. domain .. ":i") or 0,
227
+ bandwidth_out = analytics:get("t:" .. domain .. ":o") or 0,
228
+ })
229
+ end
230
+
231
+ -- ── GET /analytics/geo - country breakdown for a day ─────────────────────────
232
+ -- ?domain=example.com&day=YYYYMMDD
233
+
234
+ if uri == "/analytics/geo" then
235
+ local domain = ngx.var.arg_domain
236
+ if not domain or domain == "" then return bad("missing ?domain=") end
237
+ domain = domain:lower()
238
+
239
+ local day = ngx.var.arg_day
240
+ if not day or #day ~= 8 then
241
+ day = os.date("!%Y%m%d")
242
+ end
243
+
244
+ local prefix = "g:" .. domain .. ":" .. day .. ":"
245
+ local keys = analytics:get_keys(10000)
246
+ local countries = {}
247
+ for _, k in ipairs(keys) do
248
+ if k:sub(1, #prefix) == prefix then
249
+ countries[k:sub(#prefix + 1)] = analytics:get(k) or 0
250
+ end
251
+ end
252
+
253
+ return json({ domain = domain, day = day, countries = countries })
254
+ end
255
+
256
+ -- ── GET /logs/recent - raw request ring buffer ───────────────────────────────
257
+ -- ?domain=example.com&limit=50
258
+
259
+ if uri == "/logs/recent" then
260
+ if not request_data then
261
+ return json({ error = "request_data dict unavailable" }, 503)
262
+ end
263
+
264
+ local domain = ngx.var.arg_domain
265
+ if not domain or domain == "" then return bad("missing ?domain=") end
266
+ domain = domain:lower()
267
+
268
+ local limit = math.min(tonumber(ngx.var.arg_limit) or 50, 1000)
269
+ local seq = request_data:get("rlog:" .. domain .. ":seq") or 0
270
+ local out = {}
271
+
272
+ for i = 0, limit - 1 do
273
+ local slot = (seq - i) % 1000
274
+ if slot < 0 then slot = slot + 1000 end
275
+ local raw = request_data:get("rlog:" .. domain .. ":" .. slot)
276
+ if raw then out[#out + 1] = raw end
277
+ end
278
+
279
+ -- Return pre-encoded JSON entries as a JSON array
280
+ ngx.header["Content-Type"] = "application/json"
281
+ ngx.print("[")
282
+ for idx, entry in ipairs(out) do
283
+ if idx > 1 then ngx.print(",") end
284
+ ngx.print(entry)
285
+ end
286
+ ngx.say("]")
287
+ return ngx.exit(200)
288
+ end
289
+
290
+ -- ── 404 ──────────────────────────────────────────────────────────────────────
291
+ return json({ error = "not found" }, 404)
@@ -0,0 +1,71 @@
1
+ -- pipe_log.lua
2
+ -- Push request log into shared dict queue log_pipe:q:{host} for /stream SSE.
3
+ -- Payload shape matches requestLogger.js (id, ip, country when known,
4
+ -- timestamp, date, uri, method, status, userAgent, requestSize, responseSize,
5
+ -- responseTime).
6
+ --
7
+ -- Called via ngx.timer.at from site_logger (premature is first arg).
8
+
9
+ local cjson = require "cjson.safe"
10
+
11
+ local SUB_PREFIX = "log_pipe:sub:"
12
+ local QUEUE_PREFIX = "log_pipe:q:"
13
+ local QUEUE_MAX_LEN = 2000
14
+
15
+ -- SAFE LOAD: GeoIP (per-worker, cached by require)
16
+ local geo_ok, geo = pcall(require, "openship.geo_country")
17
+ if not geo_ok then geo = nil end
18
+
19
+ -- Reused payload table per worker (cleared after encode to avoid GC pressure)
20
+ local payload = {}
21
+
22
+ local function pipe_request_log(premature, host, ip, ts, ua, uri,
23
+ req_len, bytes, rt, method, status)
24
+ if premature then return end
25
+
26
+ local sh = ngx.shared.request_data
27
+ if not sh then return end
28
+
29
+ -- Double-check subscriber is still active (guard against race)
30
+ if not sh:get(SUB_PREFIX .. host) then return end
31
+
32
+ pcall(function()
33
+ local country = nil
34
+ if geo and geo.get_country_code then
35
+ country = geo.get_country_code(ip)
36
+ end
37
+
38
+ payload.id = string.format("%.3f-%d", ts, math.random(10000, 99999))
39
+ payload.host = host
40
+ payload.ip = ip
41
+ if country and type(country) == "string" and country ~= "" then
42
+ payload.country = country
43
+ end
44
+ payload.timestamp = ts
45
+ payload.date = os.date("!%Y-%m-%d %H:%M:%S", ts)
46
+ payload.uri = uri
47
+ payload.method = method or "GET"
48
+ payload.status = tonumber(status) or 0
49
+ payload.userAgent = ua or ""
50
+ payload.requestSize = tonumber(req_len) or 0
51
+ payload.responseSize = tonumber(bytes) or 0
52
+ payload.responseTime = tonumber(rt) or 0
53
+
54
+ local body = cjson.encode(payload)
55
+
56
+ -- Clear payload table for next reuse
57
+ for k in pairs(payload) do payload[k] = nil end
58
+
59
+ if not body then return end
60
+
61
+ local qkey = QUEUE_PREFIX .. host
62
+ sh:lpush(qkey, body)
63
+ if sh:llen(qkey) > QUEUE_MAX_LEN then
64
+ sh:rpop(qkey)
65
+ end
66
+ end)
67
+ end
68
+
69
+ return {
70
+ pipe_request_log = pipe_request_log,
71
+ }
@@ -0,0 +1,90 @@
1
+ -- pipe_stream.lua
2
+ -- content_by_lua: SSE endpoint for real-time request log streaming.
3
+ -- GET /logs/stream?domain=example.com
4
+ -- Internal only - 127.0.0.1:9145
5
+
6
+ local sh = ngx.shared.request_data
7
+ if not sh then
8
+ ngx.status = 503
9
+ ngx.say("shared dict unavailable")
10
+ return ngx.exit(503)
11
+ end
12
+
13
+ local domain = ngx.var.arg_domain
14
+ if not domain or domain == "" then
15
+ ngx.status = 400
16
+ ngx.say("missing ?domain= parameter")
17
+ return ngx.exit(400)
18
+ end
19
+
20
+ domain = domain:lower()
21
+ if domain:sub(1, 4) == "www." then domain = domain:sub(5) end
22
+
23
+ local SUB_KEY = "log_pipe:sub:" .. domain
24
+ local QUEUE_KEY = "log_pipe:q:" .. domain
25
+
26
+ -- Clear stale queue entries, mark subscriber active
27
+ sh:delete(QUEUE_KEY)
28
+ sh:set(SUB_KEY, true, 30)
29
+
30
+ ngx.header["Content-Type"] = "text/event-stream"
31
+ ngx.header["Cache-Control"] = "no-cache, no-store"
32
+ ngx.header["Connection"] = "keep-alive"
33
+ ngx.header["X-Accel-Buffering"] = "no"
34
+
35
+ -- Send initial SSE comment so the first flush has data.
36
+ -- Without this, ngx.flush on an empty buffer sends chunked-EOF (0\r\n\r\n)
37
+ -- and terminates the response immediately.
38
+ ngx.print(": connected\n\n")
39
+ if not ngx.flush(true) then
40
+ sh:delete(SUB_KEY)
41
+ return
42
+ end
43
+
44
+ local started = ngx.now()
45
+ local last_hb = started
46
+ local last_ref = started
47
+
48
+ while true do
49
+ -- Max 1 hour per connection
50
+ if ngx.now() - started > 3600 then
51
+ sh:delete(SUB_KEY)
52
+ return
53
+ end
54
+
55
+ -- Drain up to 100 queued entries per cycle
56
+ local sent = 0
57
+ while sent < 100 do
58
+ local entry = sh:rpop(QUEUE_KEY)
59
+ if not entry then break end
60
+ ngx.print("event: request\ndata: ", entry, "\n\n")
61
+ sent = sent + 1
62
+ end
63
+
64
+ if sent > 0 then
65
+ if not ngx.flush(true) then
66
+ sh:delete(SUB_KEY)
67
+ return
68
+ end
69
+ end
70
+
71
+ local now = ngx.now()
72
+
73
+ -- Heartbeat every 15s
74
+ if now - last_hb > 15 then
75
+ ngx.print(": ping\n\n")
76
+ if not ngx.flush(true) then
77
+ sh:delete(SUB_KEY)
78
+ return
79
+ end
80
+ last_hb = now
81
+ end
82
+
83
+ -- Refresh subscriber TTL every 10s
84
+ if now - last_ref > 10 then
85
+ sh:set(SUB_KEY, true, 30)
86
+ last_ref = now
87
+ end
88
+
89
+ ngx.sleep(0.05)
90
+ end
@@ -0,0 +1,118 @@
1
+ -- rules_guard.lua
2
+ -- access_by_lua driver: enforce per-route rules (method · IP/CIDR allow-deny ·
3
+ -- country allow/ban · bad user-agent · hotlink · rate-limit) for the request's
4
+ -- host. Rules are pushed reload-free by mgmt_api `POST /rules`; the DB
5
+ -- `route_rule` table is the source of truth. All parsing/compiling/matching
6
+ -- lives in openship.rules_lib (loaded ONCE per worker) — this file is re-run
7
+ -- every request so it stays minimal. See rules_lib.lua for the compiled shape.
8
+ --
9
+ -- Enforcement uses the connecting peer (ngx.var.remote_addr); front a real_ip
10
+ -- config if behind a trusted proxy. No user-supplied value is ever used as a
11
+ -- Lua pattern or written into a response header.
12
+
13
+ local rules = ngx.shared.rules
14
+ if not rules then return end
15
+
16
+ local host = ngx.var.host
17
+ if not host then return end
18
+
19
+ local raw = rules:get(host)
20
+ if not raw then return end -- fast path: nothing configured for this host
21
+
22
+ local lib = require "openship.rules_lib"
23
+ local entries = lib.get(host, raw)
24
+ local n = #entries
25
+ if n == 0 then return end
26
+
27
+ -- ── Longest matching pathPrefix wins ──
28
+ local uri = ngx.var.uri or "/"
29
+ local chosen, chosen_len = nil, -1
30
+ for i = 1, n do
31
+ local e = entries[i]
32
+ local p = e.pathPrefix
33
+ if p == nil or p == "" or p == "/" then
34
+ if chosen_len < 0 then chosen, chosen_len = e, 0 end
35
+ elseif uri == p or string.sub(uri, 1, #p) == p then
36
+ if #p > chosen_len then chosen, chosen_len = e, #p end
37
+ end
38
+ end
39
+ if not chosen then return end
40
+
41
+ local spec = chosen.spec
42
+ local deny_status = spec.blockStatus or 403
43
+
44
+ local ips = ngx.var.remote_addr or "0.0.0.0"
45
+ local ipi = lib.ipv4_to_int(ips)
46
+
47
+ -- Country resolved at most once per request (only when a country rule exists).
48
+ local country_cache
49
+ local function country_of()
50
+ if country_cache == nil then
51
+ local ok, geo = pcall(require, "openship.geo_country")
52
+ country_cache = (ok and geo and geo.get_country_code(ips)) or false
53
+ end
54
+ return country_cache or nil
55
+ end
56
+
57
+ -- ── 1. Access: method allow-list, IP allow/deny, country allow-list ──
58
+ local access = spec.access
59
+ if access then
60
+ if access.methods and not access.methods[ngx.req.get_method()] then
61
+ return ngx.exit(deny_status)
62
+ end
63
+ if lib.match_compiled(access.deny, ipi, ips) then return ngx.exit(deny_status) end
64
+ if access.allow and not lib.match_compiled(access.allow, ipi, ips) then
65
+ return ngx.exit(deny_status)
66
+ end
67
+ -- Allow-list: an unresolved country is not on the list → blocked (default-deny).
68
+ if access.allowCountries and not access.allowCountries[country_of() or ""] then
69
+ return ngx.exit(deny_status)
70
+ end
71
+ end
72
+
73
+ -- ── 2. Ban: IP/CIDR, country, user-agent ──
74
+ local ban = spec.ban
75
+ if ban then
76
+ if lib.match_compiled(ban.ips, ipi, ips) or lib.match_compiled(ban.cidrs, ipi, ips) then
77
+ return ngx.exit(deny_status)
78
+ end
79
+ if ban.countries then
80
+ local cc = country_of()
81
+ if cc and ban.countries[cc] then return ngx.exit(deny_status) end
82
+ end
83
+ if ban.emptyUA or ban.uas then
84
+ local ua = ngx.var.http_user_agent
85
+ if ban.emptyUA and (ua == nil or ua == "") then return ngx.exit(deny_status) end
86
+ if ban.uas and ua and ua ~= "" then
87
+ local ual = string.lower(ua)
88
+ for i = 1, #ban.uas do
89
+ if string.find(ual, ban.uas[i], 1, true) then return ngx.exit(deny_status) end
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ -- ── 3. Hotlink: only listed referer hosts (empty referer per allowEmpty) ──
96
+ local hot = spec.hotlink
97
+ if hot then
98
+ local ref = ngx.var.http_referer
99
+ if ref == nil or ref == "" then
100
+ if not hot.allowEmpty then return ngx.exit(deny_status) end
101
+ else
102
+ local rhost = string.match(ref, "^%w+://([^/]+)") -- authority
103
+ if rhost then rhost = string.lower(string.match(rhost, "^([^:]+)") or rhost) end -- strip :port
104
+ if not (rhost and hot.referers[rhost]) then return ngx.exit(deny_status) end
105
+ end
106
+ end
107
+
108
+ -- ── 4. Rate limit — fixed 1s window per (host, path, ip) ──
109
+ local rl = spec.rl
110
+ if rl then
111
+ local key = "rl:" .. host .. ":" .. (chosen.pathPrefix or "/") .. ":" .. ips
112
+ .. ":" .. math.floor(ngx.now())
113
+ local c = rules:incr(key, 1, 0, 2) -- init 0, 2s TTL → self-expiring buckets
114
+ if c and c > rl.limit then
115
+ ngx.header["Retry-After"] = "1"
116
+ return ngx.exit(rl.status)
117
+ end
118
+ end