-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathip_block.lua
More file actions
240 lines (203 loc) · 8.42 KB
/
Copy pathip_block.lua
File metadata and controls
240 lines (203 loc) · 8.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
--[[
ip_block.lua — Apache 2.4 mod_lua access hook for ip-block.com.
Wire it up as an access checker in httpd.conf:
LuaHookAccessChecker /etc/httpd/ip-block/ip_block.lua check_access
For every request, check_access(r):
* resolves the real client IP (r.useragent_ip; honours mod_remoteip),
* skips whitelisted IPs,
* consults a small on-disk per-IP decision cache,
* on a miss, POSTs to https://api.ip-block.com/v1/check (api_key in the BODY),
* blocks (403 or redirect) ONLY when action == "block",
* FAILS OPEN (allow) on any error/timeout unless configured otherwise.
Outbound HTTPS uses LuaSec (require "ssl.https"). If LuaSec is not installed the
script fails open and logs a warning — see README.md for the LuaSec-free
mod_rewrite + checker.php alternative.
Configuration is read from Apache environment variables (SetEnv in httpd.conf)
with sane defaults, so no code edits are needed:
SetEnv IPB_ENABLED 1
SetEnv IPB_SITE_ID your-site-id
SetEnv IPB_API_KEY your-api-key
SetEnv IPB_API_URL https://api.ip-block.com/v1/check
SetEnv IPB_FAIL_OPEN 1
SetEnv IPB_CACHE_TTL 300
SetEnv IPB_CACHE_DIR /tmp/ip-block-cache
SetEnv IPB_BLOCK_ACTION 403 # 403 | redirect
SetEnv IPB_BLOCK_REDIRECT https://www.ip-block.com/blocked.php
SetEnv IPB_BLOCK_MESSAGE "Access denied."
SetEnv IPB_WHITELIST "127.0.0.1,::1"
]]
require "apache2"
-- ---------------------------------------------------------------------------
-- config helpers
-- ---------------------------------------------------------------------------
-- Read a config value from Apache subprocess_env (SetEnv), then the OS env,
-- then a default. r.subprocess_env is populated by SetEnv/mod_env.
local function cfg(r, name, default)
local v
if r and r.subprocess_env then
v = r.subprocess_env[name]
end
if v == nil or v == "" then v = os.getenv(name) end
if v == nil or v == "" then return default end
return v
end
local function truthy(v)
return v == "1" or v == "true" or v == "yes" or v == "on"
end
-- ---------------------------------------------------------------------------
-- JSON (dependency-free): manual encode of a flat object + action extraction.
-- ---------------------------------------------------------------------------
local function json_escape(s)
s = tostring(s or "")
s = s:gsub('\\', '\\\\')
:gsub('"', '\\"')
:gsub('\n', '\\n')
:gsub('\r', '\\r')
:gsub('\t', '\\t')
-- strip other control chars
s = s:gsub('[%z\1-\8\11\12\14-\31]', '')
return s
end
local function build_body(api_key, site_id, ip, ua, referrer)
return string.format(
'{"api_key":"%s","site_id":"%s","ip":"%s","user_agent":"%s","referrer":"%s"}',
json_escape(api_key), json_escape(site_id), json_escape(ip),
json_escape(ua), json_escape(referrer))
end
-- Extract the "action" string from a JSON response without a full parser.
local function parse_action(body)
if not body then return nil end
return body:match('"action"%s*:%s*"([^"]*)"')
end
-- ---------------------------------------------------------------------------
-- disk cache: one file per site+IP. First line = expiry epoch, second = decision.
-- ---------------------------------------------------------------------------
local function cache_path(dir, site_id, ip)
-- filesystem-safe key
local key = (site_id .. "_" .. ip):gsub("[^%w%._%-]", "_")
return dir .. "/" .. key
end
local function cache_get(dir, site_id, ip)
local path = cache_path(dir, site_id, ip)
local fh = io.open(path, "r")
if not fh then return nil end
local expiry = tonumber(fh:read("*l") or "")
local decision = fh:read("*l")
fh:close()
if not expiry or os.time() >= expiry then return nil end
return decision
end
local function cache_set(dir, site_id, ip, decision, ttl)
if ttl <= 0 then return end
-- best-effort mkdir (works if a parent exists); ignore errors
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
local path = cache_path(dir, site_id, ip)
local fh = io.open(path, "w")
if not fh then return end
fh:write(tostring(os.time() + ttl), "\n", decision, "\n")
fh:close()
end
-- ---------------------------------------------------------------------------
-- outbound HTTPS via LuaSec
-- ---------------------------------------------------------------------------
-- Returns "block" | "allow" | nil (nil == infrastructure error -> fail-open).
local function call_api(r, api_url, body, timeout_s)
local ok_https, https = pcall(require, "ssl.https")
local ok_ltn12, ltn12 = pcall(require, "ltn12")
if not ok_https or not ok_ltn12 then
r:info("ip-block: LuaSec/ltn12 not available; failing open")
return nil
end
-- Enforce the 1s budget. LuaSec reads the module-level TIMEOUT when it
-- creates the underlying socket.
https.TIMEOUT = timeout_s
local resp = {}
local ok, code = https.request{
url = api_url,
method = "POST",
headers = {
["Content-Type"] = "application/json",
["Content-Length"] = tostring(#body),
},
source = ltn12.source.string(body),
sink = ltn12.sink.table(resp),
protocol = "any",
options = "all",
verify = "peer",
}
if not ok then
return nil -- transport error / timeout
end
if type(code) ~= "number" or code < 200 or code >= 300 then
return nil -- non-2xx
end
local action = parse_action(table.concat(resp))
if action == nil then return nil end -- missing action
if action == "block" then return "block" end
return "allow"
end
-- ---------------------------------------------------------------------------
-- block responses
-- ---------------------------------------------------------------------------
local function do_block(r, block_action, redirect_url, message)
if block_action == "redirect" then
r.headers_out["Location"] = redirect_url
return apache2.HTTP_MOVED_TEMPORARILY -- 302
end
r.content_type = "text/plain"
r:puts(message or "Access denied.")
return apache2.HTTP_FORBIDDEN -- 403
end
-- ---------------------------------------------------------------------------
-- main hook
-- ---------------------------------------------------------------------------
function check_access(r)
if not truthy(cfg(r, "IPB_ENABLED", "1")) then
return apache2.DECLINED
end
local site_id = cfg(r, "IPB_SITE_ID", "")
local api_key = cfg(r, "IPB_API_KEY", "")
local api_url = cfg(r, "IPB_API_URL", "https://api.ip-block.com/v1/check")
local fail_open = truthy(cfg(r, "IPB_FAIL_OPEN", "1"))
local cache_ttl = tonumber(cfg(r, "IPB_CACHE_TTL", "300")) or 300
local cache_dir = cfg(r, "IPB_CACHE_DIR", "/tmp/ip-block-cache")
local timeout_s = tonumber(cfg(r, "IPB_TIMEOUT_S", "1")) or 1
local block_action = cfg(r, "IPB_BLOCK_ACTION", "403")
local redirect_url = cfg(r, "IPB_BLOCK_REDIRECT", "https://www.ip-block.com/blocked.php")
local block_message = cfg(r, "IPB_BLOCK_MESSAGE", "Access denied.")
local whitelist_raw = cfg(r, "IPB_WHITELIST", "")
-- Real client IP. r.useragent_ip already respects mod_remoteip when it is
-- configured for your trusted proxies (RemoteIPHeader X-Forwarded-For).
local ip = r.useragent_ip or r.remote_addr
if not ip or ip == "" then
if fail_open then return apache2.DECLINED end
return do_block(r, block_action, redirect_url, block_message)
end
-- Whitelist (never checked).
for entry in whitelist_raw:gmatch("[^,%s]+") do
if entry == ip then return apache2.DECLINED end
end
-- Cache lookup.
local cached = cache_get(cache_dir, site_id, ip)
if cached == "block" then
return do_block(r, block_action, redirect_url, block_message)
elseif cached == "allow" then
return apache2.DECLINED
end
-- Cache miss -> call the API.
local ua = r.headers_in["User-Agent"] or ""
local referrer = r.headers_in["Referer"] or ""
local body = build_body(api_key, site_id, ip, ua, referrer)
local decision = call_api(r, api_url, body, timeout_s)
if decision == nil then
-- infrastructure error: fail-open policy; do not cache
r:info("ip-block: check failed, fail_open=" .. tostring(fail_open))
if fail_open then return apache2.DECLINED end
return do_block(r, block_action, redirect_url, block_message)
end
cache_set(cache_dir, site_id, ip, decision, cache_ttl)
if decision == "block" then
return do_block(r, block_action, redirect_url, block_message)
end
return apache2.DECLINED -- allow -> let normal processing continue
end