Skip to content
API Documentation
BastionGuard™
bastionguard-firewall.cpp
Go to the documentation of this file.
1/*
2 * BastionGuard™
3 * Copyright (C) 2025–2026 Calogero Scarnà
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, version 3.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 *
17 * BastionGuard™ is a trademark of Calogero Scarnà.
18 * The BastionGuard™ name and branding are not licensed under the GPL.
19 */
20
21#include <arpa/inet.h>
22#include <cerrno>
23#include <cctype>
24#include <cstdlib>
25#include <cstring>
26#include <fcntl.h>
27#include <iostream>
28#include <limits>
29#include <optional>
30#include <string>
31#include <string_view>
32#include <sys/stat.h>
33#include <sys/types.h>
34#include <sys/wait.h>
35#include <unistd.h>
36#include <unordered_set>
37#include <vector>
38#include <glib/gi18n.h>
39
40namespace {
41
42constexpr std::string_view kInputPrefix = "/tmp/bastionguard-firewall-ips-";
43constexpr off_t kMaxInputBytes = 16 * 1024 * 1024;
44constexpr std::size_t kMaxEntries = 250000;
45constexpr std::size_t kMaxLineLength = 128;
46
48 int fd{-1};
49 std::string path;
50
51 SecureInput() = default;
52 SecureInput(int input_fd, std::string input_path)
53 : fd(input_fd), path(std::move(input_path)) {}
54
55 SecureInput(const SecureInput&) = delete;
57
58 SecureInput(SecureInput&& other) noexcept
59 : fd(other.fd), path(std::move(other.path)) {
60 other.fd = -1;
61 }
62
63 SecureInput& operator=(SecureInput&& other) noexcept {
64 if (this != &other) {
65 if (fd >= 0) ::close(fd);
66 fd = other.fd;
67 path = std::move(other.path);
68 other.fd = -1;
69 }
70 return *this;
71 }
72
74 if (fd >= 0) ::close(fd);
75 }
76};
77
78std::optional<uid_t> parse_uid(const char* value) {
79 if (!value || !*value) return std::nullopt;
80
81 unsigned long long parsed = 0;
82 for (const unsigned char ch : std::string(value)) {
83 if (!std::isdigit(ch)) return std::nullopt;
84 parsed = parsed * 10 + static_cast<unsigned long long>(ch - '0');
85 if (parsed > std::numeric_limits<uid_t>::max()) return std::nullopt;
86 }
87
88 return static_cast<uid_t>(parsed);
89}
90
92 if (const auto uid = parse_uid(std::getenv("PKEXEC_UID"))) {
93 return *uid;
94 }
95 return ::getuid();
96}
97
98bool allowed_input_path(const std::string& path) {
99 if (!path.starts_with(kInputPrefix)) return false;
100
101 const std::string_view suffix(path.data() + kInputPrefix.size(),
102 path.size() - kInputPrefix.size());
103 return !suffix.empty() && suffix.find('/') == std::string_view::npos;
104}
105
106std::optional<SecureInput> open_input_securely(const std::string& path,
107 uid_t caller_uid) {
108 if (!allowed_input_path(path)) {
109 std::cerr << _("[Firewall] Percorso input non autorizzato.\n");
110 return std::nullopt;
111 }
112
113 const int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
114 if (fd < 0) {
115 std::cerr << _("[Firewall] Impossibile aprire il file input: ")
116 << std::strerror(errno) << "\n";
117 return std::nullopt;
118 }
119
120 struct stat st {};
121 if (::fstat(fd, &st) != 0) {
122 std::cerr << _("[Firewall] fstat fallita: ")
123 << std::strerror(errno) << "\n";
124 ::close(fd);
125 return std::nullopt;
126 }
127
128 const bool ownership_ok =
129 (caller_uid == 0 && st.st_uid == 0) ||
130 (caller_uid != 0 && st.st_uid == caller_uid);
131
132 if (!S_ISREG(st.st_mode) ||
133 st.st_nlink != 1 ||
134 !ownership_ok ||
135 (st.st_mode & (S_IWGRP | S_IWOTH)) != 0 ||
136 st.st_size <= 0 ||
137 st.st_size > kMaxInputBytes) {
138 std::cerr << _("[Firewall] File input non sicuro o non valido.\n");
139 ::close(fd);
140 return std::nullopt;
141 }
142
143 // The descriptor remains valid after unlink and cannot be replaced through
144 // the pathname while the privileged process is reading it.
145 if (::unlink(path.c_str()) != 0) {
146 std::cerr << _("[Firewall] Impossibile rimuovere il file temporaneo: ")
147 << std::strerror(errno) << "\n";
148 ::close(fd);
149 return std::nullopt;
150 }
151
152 return SecureInput(fd, path);
153}
154
155std::string trim(std::string value) {
156 const auto first = value.find_first_not_of(" \t\r\n");
157 if (first == std::string::npos) return {};
158 const auto last = value.find_last_not_of(" \t\r\n");
159 return value.substr(first, last - first + 1);
160}
161
162bool is_valid_ip(const std::string& value) {
163 if (value.empty() || value.size() > kMaxLineLength) return false;
164
165 in_addr ipv4 {};
166 if (::inet_pton(AF_INET, value.c_str(), &ipv4) == 1) return true;
167
168 in6_addr ipv6 {};
169 return ::inet_pton(AF_INET6, value.c_str(), &ipv6) == 1;
170}
171
172std::vector<std::string> load_and_validate_ips(int fd) {
173 std::string data;
174 data.reserve(64 * 1024);
175
176 char buffer[8192];
177 while (true) {
178 const ssize_t count = ::read(fd, buffer, sizeof(buffer));
179 if (count == 0) break;
180 if (count < 0) {
181 if (errno == EINTR) continue;
182 std::cerr << _("[Firewall] Errore lettura file input: ")
183 << std::strerror(errno) << "\n";
184 return {};
185 }
186
187 if (data.size() + static_cast<std::size_t>(count) >
188 static_cast<std::size_t>(kMaxInputBytes)) {
189 std::cerr << _("[Firewall] File input troppo grande.\n");
190 return {};
191 }
192 data.append(buffer, static_cast<std::size_t>(count));
193 }
194
195 std::vector<std::string> ips;
196 std::unordered_set<std::string> seen;
197 std::size_t start = 0;
198
199 while (start <= data.size()) {
200 const auto end = data.find('\n', start);
201 std::string line = trim(data.substr(
202 start,
203 end == std::string::npos ? std::string::npos : end - start));
204
205 if (!line.empty() && line.front() != '#') {
206 if (!is_valid_ip(line)) {
207 std::cerr << _("[Firewall] Voce IP rifiutata: ") << line << "\n";
208 return {};
209 }
210
211 if (seen.insert(line).second) {
212 ips.push_back(std::move(line));
213 if (ips.size() > kMaxEntries) {
214 std::cerr << _("[Firewall] Troppe voci nel file input.\n");
215 return {};
216 }
217 }
218 }
219
220 if (end == std::string::npos) break;
221 start = end + 1;
222 }
223
224 return ips;
225}
226
227std::optional<std::string> find_program(
228 std::initializer_list<const char*> candidates) {
229 for (const char* candidate : candidates) {
230 if (::access(candidate, X_OK) == 0) return std::string(candidate);
231 }
232 return std::nullopt;
233}
234
235bool run_program(const std::string& executable,
236 const std::vector<std::string>& arguments,
237 bool silence_stderr = false) {
238 std::vector<std::string> storage;
239 storage.reserve(arguments.size() + 1);
240 storage.push_back(executable);
241 storage.insert(storage.end(), arguments.begin(), arguments.end());
242
243 std::vector<char*> argv;
244 argv.reserve(storage.size() + 1);
245 for (auto& value : storage) argv.push_back(value.data());
246 argv.push_back(nullptr);
247
248 const pid_t pid = ::fork();
249 if (pid < 0) return false;
250
251 if (pid == 0) {
252 if (silence_stderr) {
253 const int null_fd = ::open("/dev/null", O_WRONLY | O_CLOEXEC);
254 if (null_fd >= 0) {
255 ::dup2(null_fd, STDERR_FILENO);
256 if (null_fd != STDERR_FILENO) ::close(null_fd);
257 }
258 }
259
260 ::execv(executable.c_str(), argv.data());
261 _exit(127);
262 }
263
264 int status = 0;
265 while (::waitpid(pid, &status, 0) < 0) {
266 if (errno == EINTR) continue;
267 return false;
268 }
269
270 return WIFEXITED(status) && WEXITSTATUS(status) == 0;
271}
272
273bool apply_ufw(const std::vector<std::string>& ips) {
274 const auto ufw = find_program({"/usr/sbin/ufw", "/usr/bin/ufw"});
275 if (!ufw) return false;
276
277 if (!run_program(*ufw, {"--force", "enable"})) return false;
278
279 bool success = true;
280 for (const auto& ip : ips) {
281 if (!run_program(*ufw, {"deny", "from", ip})) success = false;
282 }
283 return success;
284}
285
286bool apply_firewalld(const std::vector<std::string>& ips) {
287 const auto firewall_cmd =
288 find_program({"/usr/bin/firewall-cmd", "/usr/sbin/firewall-cmd"});
289 if (!firewall_cmd) return false;
290
291 // These operations are idempotent from BastionGuard's perspective. An
292 // already existing ipset/source association is not fatal.
293 run_program(*firewall_cmd,
294 {"--permanent", "--new-ipset=bastionguard-blacklist",
295 "--type=hash:ip"},
296 true);
297 run_program(*firewall_cmd,
298 {"--permanent", "--zone=public",
299 "--add-source-ipset=bastionguard-blacklist"},
300 true);
301
302 bool success = true;
303 for (const auto& ip : ips) {
304 if (!run_program(*firewall_cmd,
305 {"--permanent",
306 "--ipset=bastionguard-blacklist",
307 "--add-entry=" + ip},
308 true)) {
309 // Duplicate entries may return a non-zero status. Continue and let
310 // reload determine whether firewalld is operational.
311 success = false;
312 }
313 }
314
315 const bool reloaded = run_program(*firewall_cmd, {"--reload"});
316 return reloaded && (success || !ips.empty());
317}
318
320 if (const auto ufw = find_program({"/usr/sbin/ufw", "/usr/bin/ufw"})) {
321 run_program(*ufw, {"--force", "reset"});
322 }
323
324 if (const auto firewall_cmd =
325 find_program({"/usr/bin/firewall-cmd", "/usr/sbin/firewall-cmd"})) {
326 run_program(*firewall_cmd,
327 {"--permanent", "--delete-ipset=bastionguard-blacklist"},
328 true);
329 run_program(*firewall_cmd, {"--reload"});
330 }
331}
332
333} // namespace
334
335int main(int argc, char* argv[]) {
336 const uid_t caller_uid = original_caller_uid();
337
338 ::clearenv();
339 ::setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin", 1);
340
341 if (::geteuid() != 0) {
342 std::cerr << _("[Firewall] Deve essere eseguito come root tramite pkexec.\n");
343 return 10;
344 }
345
346 if (argc < 2) {
347 std::cerr << "Uso: " << argv[0] << " apply <input-file>|clear\n";
348 return 1;
349 }
350
351 const std::string action = argv[1];
352
353 if (action == "clear") {
354 if (argc != 2) return 1;
356 std::cout << _("[Firewall] Regole rimosse.\n");
357 return 0;
358 }
359
360 if (action != "apply" || argc != 3) {
361 std::cerr << _("[Firewall] Comando non valido.\n");
362 return 2;
363 }
364
365 auto input = open_input_securely(argv[2], caller_uid);
366 if (!input) return 3;
367
368 auto ips = load_and_validate_ips(input->fd);
369 if (ips.empty()) {
370 std::cerr << _("[Firewall] Blacklist vuota o non valida.\n");
371 return 4;
372 }
373
374 const bool ok = apply_firewalld(ips) || apply_ufw(ips);
375
376 if (ok) {
377 std::cout << _("[Firewall] Regole applicate (")
378 << ips.size() << _(" IP).\n");
379 return 0;
380 }
381
382 std::cerr << _("[Firewall] Nessun firewall supportato o applicazione fallita.\n");
383 return 5;
384}
#define _(s)
int main(int argc, char *argv[])
#define AF_INET
Definition bsc_ebpf.c:56
#define AF_INET6
Definition bsc_ebpf.c:57
bool apply_firewalld(const std::vector< std::string > &ips)
std::optional< uid_t > parse_uid(const char *value)
std::optional< SecureInput > open_input_securely(const std::string &path, uid_t caller_uid)
bool apply_ufw(const std::vector< std::string > &ips)
std::optional< std::string > find_program(std::initializer_list< const char * > candidates)
std::vector< std::string > load_and_validate_ips(int fd)
bool run_program(const std::string &executable, const std::vector< std::string > &arguments, bool silence_stderr=false)
#define S_IWOTH
#define S_IWGRP
SecureInput & operator=(const SecureInput &)=delete