1
0

Compare commits

...

12 Commits

Author SHA1 Message Date
9ecd2b4fb4 think 2025-03-25 23:55:00 +00:00
1a6160bcab firewall: show how to ratelimit icmp6 to 5% of available bandwidth
it's a little klunky as yet, requires setting properties.bandwidth on
the interface service
2025-03-25 23:53:02 +00:00
b1bf13bb01 add svc:directory, mostly for use in debugging messages 2025-03-25 23:47:01 +00:00
c3f550698d watch-outputs fix update logic
it was only working by accident, when it worked, which was by no
means all of the time

note that we unconditionally perform the action (restart or whatever)
once we've started and got the initial state of the outputs. That's
because we have no idea whether the outputs changed in the interval
between the controlled service initially starting and watch-outputs
starting, so updates in that interval could be lost
2025-03-25 23:44:21 +00:00
05991225de anoia.svc allow open of a service that is not yet running
we change the inotify watcher so that it attempts to monitor
/run/service as well as /run/service/foo. If foo doesn't yet exist
then that call to addwatch fails, so we need to be looking at the
parent if we are to be told when foo gets created
2025-03-25 23:37:58 +00:00
7ce1c6bb7d add realpath to lualinux 2025-03-24 22:39:59 +00:00
8440378a39 anoia: make dirname handle tralning / like posix 2025-03-24 22:37:24 +00:00
e5cfd41013 add nft_limit kmodule for rate limiting in firewall 2025-03-21 21:19:48 +00:00
0ae5689a40 support maps in firewallgen 2025-03-21 21:19:18 +00:00
45047dc023 squahs falls back 2025-03-21 21:09:05 +00:00
3673804b93 think 2025-03-21 21:08:17 +00:00
be03e9e8c8 service outputs falls back to properties (untested) 2025-03-18 18:38:04 +00:00
13 changed files with 380 additions and 32 deletions

View File

@ -7196,3 +7196,185 @@ we may still need to template the rest of the firewall if we want to have
other variables (rate limits) in it, because the rules for that need to be
inserted ahead of the rules for accepting icmp, and there's no way to
do that without
Sun Mar 9 21:46:30 GMT 2025
OK, we have updating firewall zones that are good neough to pass the test
(and may even work ...). We need
* to write a rule with a rate limit for incoming icmp6 for each interface,
capping it to 5% of the interface bandwidth
* some place to say what the bandwidth is, which I am thinking we could do with
a passthru attribute of some kind on services
{
run = "mdcnvdfngkj";
name = "dtret";
properties.bandwidthKbps = 80000;
}
and insert logic in the up/run commands to copy the properties to
outputs. Or we could use `data` or `env` directories, which means
that the properties would be there even while the service is down, if
that's a concern, but would need a different way to look them up. And
also, would we allow them to change? What if there's some kind of interface
where we _can_ interrogate the bandwidth at runtime?
Mon Mar 10 21:00:17 GMT 2025
The idea that occured to me at lunchtime is "what if we made the
(svc:output ) method fall back to properties if no output was present".
To do this, we'd have to
(1) arrange for /nix/store/eeee-service/.properties to exist
- add properties attribute to service functions
- write them to .properties in liminix-tools/services/builder.sh
- make sure they get passed through whne provided to all the service
builder functions
[done] (2) pass the store directory to svc.open instead of ..../.outputs
(3) make service:output look in both places
(4) write the damn firewall rule
Mon Mar 17 21:13:36 GMT 2025
Argh why is it never simple?
We need to write a rate-limiting firewall rule for each interface to
restrict icmp on that interface. This is not easy to reconcile with
putting them in default-rules because how do we generate multiple
array elements by config file templating?
There are two things in my mind now:
1) could we have some better way of manipulating the firewall rules
such that the rules from different modules are composable
this is complicated somewhat by ordering: if every rule in a chain is
"drop" or "accept" then it's easy to add another, but if the same
chain does first one then the other, doing the other and then the one
will not work
today we do e.g.
input-ip6
-> reject-bogons
-> accept non-bogus-icmp
-> process per-zone allowlist
-> allow established,related on wan
-> allow all on lan (so why did we need an allowlist?)
could we express this in a less sequential form? the
specification of what's allowed
input-ip6 for wan: is input-ip6 with the wan allowlist
input-ip6 for lan: is input-ip6 with the lan allowlist
input-ip6 for ppp0: is input-ip6 with the wan allowlist with a rate
limit for icmp
ssh module wishes to modify the allowlist for lan/wan/both so that
it includes port 22
am wondering if we could do default deny and _all_ the rules
(except for bogons) are allows
maybe we have the concept of "subtraction": a rule can be an allow
preceded by some number of drops which (at least by convention; this is
probably hard to enforce) are "carve outs" of the packets that are
being allowed.
... it's hard to express the forward-ipv6 in these terms, though.
we end up with "some drops and then multiple accepts"
we have
(and (not (or drop1 drop2 ...)) (or accept1 accept2 ...))
and to add ssh we need to break into the second clause instead of
composing at the top level
(and (not (or drop1 drop2 ...))
(or accept1 accept2 accept-ssh...))
then the icmp bogons composite rule is "drop weird icmp and then
allow what's left"
(side note: maybe we could use a map to do interface name -> bandwidth
for rate limiting)
a composite rule might be a bunch of denies and then an allow
for anything the
2) some kinda syntax for referencing outputs (or properties) that's not
just string interpolation
----
I think we could address the immediate problem by writing a rule for
rate-limiting that looks up the rate in a map, and some maps (with
extraText) that get the rates from service properties? And that would
suffice for addressing the RoS audit, at least
Tue Mar 18 18:48:22 GMT 2025
Unless the interface exists, we do not (at least, may not) know its
name because that's an output. So the fact that it has a permanent
property is not per se terribly useful
limit rate 50000 bytes / minute accept
nft add rule ip nat postrouting snat to ip saddr map { 192.168.1.0/24 : 10.0.0.1, 192.168.2.0/24 : 10.0.0.2 }
nft add rule table-ip input-ip4 ip daddr 2.2.2.3/32 limit rate iifname map { "eth0": 10, "ppp0": 20 } kbytes/second accept
nft add map 'table-ip intf-limits { typeof 5000000 ; elements = { lan: 50000000, ppp0: 3500000 } ; }'
OK, we can't do rate lookup in a map because the nftables grammar only
supports a numeric literal for limit_rate_bytes. so we're back to writing
a collection of rules, one for each interface with an ifname output,
that sets the limit for that interface
* we could do this all in one element of a rules list, with newlines
between each actual rule
* we could add extraText to the ruleset syntax - but does it go at the
start of the rules or the end or somewhere in the middle? this is
almost worse
* we could pick up where we left off on march 17 and redesign the
firewall module
gonna be option 1 isn't it?
Tue Mar 25 00:13:31 GMT 2025
the logic for watch-outputs is not correct
(1) when we first read outputs, how do we know if the controlled
service is up to date wrt those outputs? we should perform the action
for the first time after reading initial state and before waiting
(2) it isn't writing the new outputs back into trees, so will refresh
continually after the fisrt change
set tree to empty for each service
loop:
for each service
read new tree
if different
do action
set tree = new tree
wait for changes
next iteration

View File

@ -37,6 +37,7 @@ let
"nft_fib"
"nft_fib_ipv4"
"nft_fib_ipv6"
"nft_limit"
"nft_log"
"nft_masq"
"nft_nat"
@ -114,6 +115,7 @@ in
NFT_CT = "m";
NFT_FIB_IPV4 = "m";
NFT_FIB_IPV6 = "m";
NFT_LIMIT = "m";
NFT_LOG = "m";
NFT_MASQ = "m";
NFT_NAT = "m";

View File

@ -45,9 +45,36 @@ let
}}
'';
};
rateHook =
let rules =
map
(x: ''
{{;
local s = "${x}";
local n = output(s, "ifname");
local bw = output(s, "bandwidth");
if n and bw then
return "meta l4proto icmpv6 iifname ".. n .. " limit rate over " .. (math.floor (tonumber(bw) / 20)) .. " bytes/second drop"
else
return "# " .. (n or "not n") .. " " .. (bw or "not bw")
end
}}
'')
(concatLists (builtins.attrValues zones));
in {
type = "filter"; family = "ip6";
hook = "input"; priority = "-1"; policy = "accept";
inherit rules;
};
sets = (mapAttrs' (n: _: mkSet "ip" n) zones) //
(mapAttrs' (n: _: mkSet "ip6" n) zones);
allRules = lib.recursiveUpdate extraRules (lib.recursiveUpdate sets rules);
allRules =
{ icmp6-ratehook = rateHook; } //
(lib.recursiveUpdate
extraRules
(lib.recursiveUpdate sets rules));
script = firewallgen "firewall1.nft" allRules;
watchArg = z: intfs: map (i: "${z}:${i}") intfs;
name = "firewall";

View File

@ -115,6 +115,7 @@ let
${command}
'';
notification-fd = 10;
# properties.bandwidth = 3 * 1000 * 1000;
timeout-up =
if lcpEcho.failure != null then (10 + lcpEcho.failure * lcpEcho.interval) * 1000 else 60 * 1000;
inherit dependencies;

View File

@ -9,6 +9,7 @@ check:
ln -s . anoia
fennel ./run-tests.fnl $(CHECK)
fennel test.fnl
mkdir -p $(outputdir)
fennel test-svc.fnl $(servicedir)
find $(outputdir) -ls
test -f $(outputdir)/fish

View File

@ -25,8 +25,27 @@
(fn basename [path]
(string.match path ".*/([^/]-)$"))
(fn dirname [path]
(string.match path "(.*)/[^/]-$"))
(let [stripped (string.match path "(.-)/*$")]
(pick-values
1
(if (not path) "."
(= path "") "."
(not stripped) "."
(= stripped "") "/"
(string.match stripped ".+/.-") (stripped:gsub "(.*)(/.*)" "%1")
(string.match stripped "/") "/"
"."))))
(define-tests
;; these are examples from dirname(3)
(expect= (dirname "/usr/lib") "/usr")
(expect= (dirname "/usr/") "/")
(expect= (dirname "usr") ".")
(expect= (dirname "/") "/")
(expect= (dirname ".") ".")
(expect= (dirname "..") "."))
(fn append-path [dirname filename]
(let [base (or (string.match dirname "(.*)/$") dirname)

View File

@ -1,13 +1,36 @@
(local inotify (require :inotify))
(local { : file-exists? : dirname : append-path } (require :anoia))
(local { : file-type : dir : mktree &as fs } (require :anoia.fs))
(local { : readlink } (require :lualinux))
(fn read-line [name]
(with-open [f (assert (io.open name :r) (.. "can't open file " name))]
(f:read "*l")))
;; If the directory is missing, we cannot add a inotify watch
;; for it. We need a watch that opens the parent if the pathname is missing,
;; and it needs to be the resolved path not the syntactic parent.
;; If the directory is not missing, then having a watch on the
;; parent may result in extra wakeups but should only affect
;; efficiency not correctness
;; Each time the directory may have been added we need to update
;; watches. If it's been removed, the watch will be removed
;; automatically. inotify_add_watch when it already exists will modify
;; instead of making a new one, so we can treat it as idempotent
(fn resolve-link [pathname]
(if (= (file-type pathname) :link)
(readlink pathname)
pathname))
(fn watch-fsevents [directory-name]
(let [handle (inotify.init)]
(let [handle (inotify.init)
parent-name (dirname (resolve-link directory-name))
refresh (fn []
(handle:addwatch directory-name
inotify.IN_CREATE
inotify.IN_MOVE
@ -16,7 +39,18 @@
inotify.IN_MOVED_FROM
inotify.IN_MOVED_TO
inotify.IN_CLOSE_WRITE)
handle))
(handle:addwatch parent-name
inotify.IN_CREATE
inotify.IN_DELETE))]
;; if you are using poll() to check for events on this
;; watcher and on other events at the same time, be sure
;; to call fileno each time around the loop instead
;; of only once
{
:fileno #(do (refresh) (handle:fileno))
:wait #(do (refresh) (handle:read))
:close #(handle:close)
}))
(fn write-value [pathname value]
(mktree (dirname pathname))
@ -49,20 +83,26 @@
(self:wait))))
(fn open [directory]
(let [watcher (watch-fsevents directory)
(let [outputs-dir (append-path directory ".outputs")
has-file? #(file-exists? (append-path directory $1))
outputs-dir (append-path directory ".outputs")]
watcher (watch-fsevents outputs-dir)
properties-dir (append-path directory ".properties")]
{
:wait #(watcher:read)
:ready? (fn [self]
(and (has-file? ".outputs/state")
(not (has-file? ".outputs/.lock"))))
:property (fn [_ filename]
(read-value (append-path properties-dir filename)))
:output (fn [_ filename new-value]
(if new-value
(write-value (append-path outputs-dir filename) new-value)
(read-value (append-path outputs-dir filename))))
(or
(read-value (append-path outputs-dir filename))
(read-value (append-path properties-dir filename)))))
:wait #(watcher:wait)
:close #(watcher:close)
:fileno #(watcher:fileno)
: directory
: events
}))

View File

@ -13,8 +13,8 @@ let
optionalString
;
inherit (lib.lists) groupBy;
inherit (lib.attrsets) mapAttrsToList;
inherit (builtins) map head tail;
inherit (lib.attrsets) attrsToList mapAttrsToList;
inherit (builtins) elemAt map head tail toString;
indentLines =
offset: lines:
@ -68,6 +68,25 @@ let
}
'';
domap =
{
name,
type,
elements ? [ ],
extraText ? null,
...
}:
let
colonize = v:
let ty = elemAt (attrsToList v) 0; in "${ty.name}: ${ty.value}";
in ''
map ${name} {
type ${colonize type}
${if elements != [ ] then "elements = { ${concatStringsSep ", " (mapAttrsToList (k: v : "${k}: ${toString v}") elements)} }" else ""}
${optionalString (extraText != null) extraText}
}
'';
dochainorset =
{
kind ? "chain",
@ -76,6 +95,7 @@ let
{
chain = dochain;
set = doset;
map = domap;
}
.${kind}
params;

View File

@ -151,6 +151,18 @@ in
"eth0"
"eth1"
];
};
map-intf-limits-ip6 = {
name = "intf-limits";
kind = "map";
family = "ip6";
type = { ifname = "bytes"; };
elements = {
# XXX keys need to be generated from interface outputs
ppp0 = builtins.floor (70*1000*1000 * 0.05); # 5% of 70MB fttp connection
lan = builtins.floor (1000*1000*1000 * 0.05); # GB ethernet
};
};
}

View File

@ -0,0 +1,36 @@
diff --git a/lualinux.c b/lualinux.c
index f3d1a4d..9c5dc9c 100644
--- a/lualinux.c
+++ b/lualinux.c
@@ -387,6 +387,18 @@ static int ll_readlink(lua_State *L) {
RET_STRN(buf, n);
}
+static int ll_realpath(lua_State *L) {
+ const char *pname = luaL_checkstring(L, 1);
+ char * resolved = realpath(pname, NULL); /* mallocs */
+ if (resolved == 0) {
+ RET_ERRNO;
+ } else {
+ lua_pushstring(L, resolved);
+ free(resolved);
+ return 1;
+ }
+}
+
static int ll_lstat3(lua_State *L) {
// lua api: lstat3(path [,statflag:int])
// if statflag=1: do stat(). default: do lstat
@@ -924,6 +936,7 @@ static const struct luaL_Reg lualinuxlib[] = {
{"readdir", ll_readdir},
{"closedir", ll_closedir},
{"readlink", ll_readlink},
+ {"realpath", ll_realpath},
{"lstat3", ll_lstat3},
{"lstat", ll_lstat},
{"utime", ll_utime},
@@ -969,4 +982,3 @@ int luaopen_lualinux (lua_State *L) {
lua_settable (L, -3);
return 1;
}
-

View File

@ -13,6 +13,11 @@ lua.pkgs.buildLuaPackage {
version = "0.1"; # :shrug:
inherit src;
patches = [
./0001-realpath.patch
];
postPatch = ''
sed -i -e '/strip/d' Makefile
'';

View File

@ -25,7 +25,10 @@
myenv {
: string
: table
: math
: ipairs
: tonumber
:output
(fn [service-path path default]
(let [s (assert (svc.open service-path))]

View File

@ -38,7 +38,6 @@
"/nix/store/s2" [["out1"] ["out2" "ifname"]]}}
))
(fn changed? [paths old-tree new-tree]
(accumulate [changed? false
_ path (ipairs paths)]
@ -52,8 +51,6 @@
(expect (not (changed? [["mtu"]] {:ifname "true"} {:ifname "false"})))
)
(fn do-action [action service]
(case action
:restart (system (%% "s6-svc -r /run/service/%s" service))
@ -80,16 +77,19 @@
: controlled-service
: action
: watched-service
: paths } (parse-args arg)]
: paths } (parse-args arg)
services (open-services output-references)]
(while true
(let [services (open-services output-references)
trees (collect [s _ (pairs services)]
(values s (s:output ".")))]
(wait-for-change services)
(each [service paths (pairs services)]
(when
(accumulate [changed false
service paths (pairs services)]
(let [new-tree (service:output ".")]
(when (changed? paths (. trees service) new-tree)
(do-action action controlled-service))))))))
(if (changed? paths (or (. trees service) {}) new-tree)
(do (tset trees service new-tree) true)
changed)))
(do-action action controlled-service))
(wait-for-change services))))