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,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
|