services/enthalpy: refactor for better network isolation and usability

This commit is contained in:
Lu Wang 2024-12-02 01:18:21 +08:00
parent 4b2dcb5541
commit a746646d5e
Signed by: rebmit
SSH key fingerprint: SHA256:3px8QV1zEerIrEWHaqtH5rR9kjetyRST5EipOPrd+bU
16 changed files with 320 additions and 340 deletions

View file

@ -5,14 +5,13 @@
];
services.enthalpy = {
users.rebmit = { };
ipsec.interfaces = [ "enp14s0" ];
sing-box = {
enable = true;
clat = {
enable = true;
segment = lib.singleton "fde3:3be3:a244:2676::2";
};
};
gost.enable = true;
};
systemd.network = {

View file

@ -5,17 +5,16 @@
];
services.enthalpy = {
users.rebmit = { };
ipsec = {
interfaces = [ "wlan0" ];
whitelist = [ "rebmit's edge network" ];
};
sing-box = {
enable = true;
clat = {
enable = true;
segment = lib.singleton "fde3:3be3:a244:2676::2";
};
};
gost.enable = true;
};
systemd.network = {

View file

@ -1,4 +1,9 @@
{ profiles, ... }:
{
profiles,
data,
lib,
...
}:
{
imports = with profiles; [
services.enthalpy
@ -8,7 +13,13 @@
ipsec.interfaces = [ "enp3s0" ];
exit = {
enable = true;
prefix = [ "::/0" ];
prefix = [
{
type = "bird";
destination = "::/0";
source = data.enthalpy_network_prefix;
}
];
};
srv6.enable = true;
nat64.enable = true;
@ -46,6 +57,12 @@
dhcpV6Config.RouteMetric = 1024;
ipv6AcceptRAConfig.RouteMetric = 1024;
};
"50-enthalpy" = {
routes = lib.singleton {
Destination = data.enthalpy_network_prefix;
Gateway = "fe80::ff:fe00:0";
};
};
};
};
}

View file

@ -0,0 +1,8 @@
{ ... }:
{
ids.uids = {
rebmit = 1000;
};
ids.gids = { };
}

View file

@ -13,8 +13,8 @@ in
ssh = 2222;
# enthalpy
sing-box = 1080;
enthalpy-ipsec = 13000;
enthalpy-gost = 1080;
};
readOnly = true;
description = ''

View file

@ -28,7 +28,6 @@ in
# enthalpy
localsid = 300;
nat64 = 301;
sing-box = 302;
};
readOnly = true;
description = ''
@ -37,10 +36,7 @@ in
};
routingMarks = mkOption {
type = with types; attrsOf int;
default = {
# enthalpy
sing-box = 1300;
};
default = { };
readOnly = true;
description = ''
A mapping of routing marks, each identified by a unique name.
@ -56,7 +52,6 @@ in
# enthalpy
localsid = 500;
sing-box = 13000;
};
readOnly = true;
description = ''

View file

@ -53,7 +53,6 @@ in
systemd.services.enthalpy-bird2 = {
serviceConfig = {
NetworkNamespacePath = "/run/netns/${cfg.netns}";
Type = "forking";
Restart = "on-failure";
RestartSec = 5;
@ -81,14 +80,12 @@ in
SystemCallFilter = "~@cpu-emulation @debug @keyring @module @mount @obsolete @raw-io";
MemoryDenyWriteExecute = "yes";
};
partOf = [ "enthalpy.service" ];
after = [ "enthalpy.service" ];
requires = [ "enthalpy.service" ];
requiredBy = [ "enthalpy.service" ];
wantedBy = [ "multi-user.target" ];
reloadTriggers = [ config.environment.etc."enthalpy/bird2.conf".source ];
};
services.enthalpy.services.enthalpy-bird2 = { };
services.enthalpy.bird.config = mkBefore ''
router id ${toString cfg.bird.routerId};
ipv6 sadr table sadr6;

View file

@ -0,0 +1,84 @@
# Portions of this file are sourced from
# https://github.com/NickCao/flakes/blob/3b03efb676ea602575c916b2b8bc9d9cd13b0d85/nixos/mainframe/gravity.nix
{
config,
lib,
pkgs,
mylib,
...
}:
with lib;
let
inherit (mylib.network) cidr;
cfg = config.services.enthalpy;
in
{
options.services.enthalpy.clat = {
enable = mkEnableOption "464XLAT for IPv4 connectivity";
address = mkOption {
type = types.str;
default = cidr.host 2 cfg.prefix;
description = ''
IPv6 address used for 464XLAT as the mapped source address.
'';
};
segment = mkOption {
type = types.listOf types.str;
description = ''
SRv6 segments used for NAT64.
'';
};
};
config = mkIf (cfg.enable && cfg.clat.enable) {
systemd.services.enthalpy-clat = {
path = with pkgs; [
iproute2
tayga
];
preStart = ''
ip -6 route replace 64:ff9b::/96 from ${cfg.clat.address} encap seg6 mode encap \
segs ${concatStringsSep "," cfg.clat.segment} dev enthalpy mtu 1280
'';
script = ''
exec tayga --config ${pkgs.writeText "tayga.conf" ''
tun-device clat
prefix 64:ff9b::/96
ipv4-addr 192.0.0.1
map 192.0.0.2 ${cfg.clat.address}
''}
'';
postStart = ''
ip link set clat up
ip -4 addr add 192.0.0.2/32 dev clat
ip -6 route add ${cfg.clat.address} dev clat
ip -4 route add 0.0.0.0/0 dev clat src 192.0.0.2
'';
preStop = ''
ip -6 route del 64:ff9b::/96 from ${cfg.clat.address} encap seg6 mode encap \
segs ${concatStringsSep "," cfg.clat.segment} dev enthalpy mtu 1280
'';
serviceConfig = {
Type = "forking";
Restart = "on-failure";
RestartSec = 5;
DynamicUser = true;
RuntimeDirectory = "enthalpy";
CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
AmbientCapabilities = [ "CAP_NET_ADMIN" ];
ProtectSystem = "full";
ProtectHome = "yes";
ProtectKernelTunables = true;
ProtectControlGroups = true;
PrivateTmp = true;
SystemCallFilter = "~@cpu-emulation @debug @keyring @module @mount @obsolete @raw-io";
MemoryDenyWriteExecute = "yes";
};
wants = [ "network.target" ];
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
};
services.enthalpy.services.enthalpy-clat = { };
};
}

View file

@ -18,21 +18,21 @@ in
prefix = mkOption {
type = types.str;
description = ''
Prefix to be announced for the local node.
Prefix to be announced for the local node in the enthalpy network.
'';
};
address = mkOption {
type = types.str;
default = cidr.host 1 cfg.prefix;
description = ''
Address to be added into the enthalpy network as source address.
'';
};
netns = mkOption {
type = types.str;
default = "enthalpy";
description = ''
Name of the network namespace for interfaces.
'';
};
interface = mkOption {
type = types.str;
default = "enthalpy";
description = ''
Name of the interface to connect to the network namespace.
Name of the network namespace for enthalpy interfaces.
'';
};
network = mkOption {
@ -44,6 +44,11 @@ in
};
config = mkIf cfg.enable {
systemd.network.networks."50-enthalpy" = {
matchConfig.Name = "enthalpy";
linkConfig.RequiredForOnline = false;
};
systemd.services.enthalpy = {
path = with pkgs; [
iproute2
@ -52,34 +57,29 @@ in
];
script = ''
ip netns add ${cfg.netns}
ip link add ${cfg.interface} mtu 1400 address 02:00:00:00:00:01 type veth peer enthalpy mtu 1400 address 02:00:00:00:00:00 netns ${cfg.netns}
ip link set ${cfg.interface} up
ip link add enthalpy mtu 1400 address 02:00:00:00:00:01 type veth peer enthalpy mtu 1400 address 02:00:00:00:00:00 netns ${cfg.netns}
ip -n ${cfg.netns} link set lo up
ip -n ${cfg.netns} link set enthalpy up
ip -n ${cfg.netns} addr add ${cidr.host 0 cfg.prefix}/127 dev enthalpy
ip -n ${cfg.netns} addr add ${cfg.address}/128 dev enthalpy
ip netns exec ${cfg.netns} sysctl -w net.ipv6.conf.default.forwarding=1
ip netns exec ${cfg.netns} sysctl -w net.ipv6.conf.all.forwarding=1
ip netns exec ${cfg.netns} sysctl -w net.ipv4.conf.default.forwarding=0
ip netns exec ${cfg.netns} sysctl -w net.ipv4.conf.all.forwarding=0
'';
preStop = ''
ip link del ${cfg.interface}
ip netns del ${cfg.netns}
'';
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
wants = [ "network.target" ];
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
};
systemd.network.networks."50-enthalpy" = {
matchConfig.Name = cfg.interface;
networkConfig.Address = "${cidr.host 1 cfg.prefix}/127";
routes = singleton {
Destination = cfg.network;
Gateway = "fe80::ff:fe00:0";
};
};
environment.etc."netns/enthalpy/resolv.conf".text = lib.mkDefault ''
nameserver 2606:4700:4700::1111
'';
};
}

View file

@ -4,21 +4,39 @@
config,
lib,
pkgs,
mylib,
...
}:
with lib;
let
inherit (mylib.network) cidr;
cfg = config.services.enthalpy;
internalPrefix = filter (p: cidr.child p cfg.prefix) cfg.exit.prefix;
externalPrefix = subtractLists internalPrefix cfg.exit.prefix;
birdPrefix = filter (p: p.type == "bird") cfg.exit.prefix;
staticPrefix = subtractLists birdPrefix cfg.exit.prefix;
staticRoutes = map (
p: "${p.destination} from ${p.source} via fe80::ff:fe00:1 dev enthalpy"
) staticPrefix;
in
{
options.services.enthalpy.exit = {
enable = mkEnableOption "netns route leaking";
prefix = mkOption {
type = types.listOf types.str;
type = types.listOf (
types.submodule {
options = {
type = mkOption {
type = types.enum [
"bird"
"static"
];
default = "static";
};
destination = mkOption { type = types.str; };
source = mkOption {
type = types.str;
default = "::/0";
};
};
}
);
default = [ ];
description = ''
Prefixes to be announced from the default netns to the enthalpy network.
@ -32,32 +50,24 @@ in
ipv6 sadr;
${
concatMapStringsSep "\n" (p: ''
route ${p} from ${cfg.network} via fe80::ff:fe00:1 dev "enthalpy";
'') externalPrefix
route ${p.destination} from ${p.source} via fe80::ff:fe00:1 dev "enthalpy";
'') birdPrefix
}
}
'';
systemd.services.enthalpy-exit =
let
routes = map (p: "${p} via fe80::ff:fe00:1 dev enthalpy") internalPrefix;
in
mkIf (routes != [ ]) {
systemd.services.enthalpy-exit = mkIf (staticRoutes != [ ]) {
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = builtins.map (route: "${pkgs.iproute2}/bin/ip -n ${cfg.netns} -6 r a ${route}") routes;
ExecStop = builtins.map (route: "${pkgs.iproute2}/bin/ip -n ${cfg.netns} -6 r d ${route}") routes;
ExecStart = builtins.map (route: "${pkgs.iproute2}/bin/ip -6 route add ${route}") staticRoutes;
ExecStop = builtins.map (route: "${pkgs.iproute2}/bin/ip -6 route del ${route}") staticRoutes;
};
partOf = [ "enthalpy.service" ];
after = [
"enthalpy.service"
"network-online.target"
];
requires = [ "enthalpy.service" ];
requiredBy = [ "enthalpy.service" ];
wants = [ "network-online.target" ];
after = [ "network.target" ];
wants = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
};
services.enthalpy.services.enthalpy-exit = mkIf (staticRoutes != [ ]) { };
};
}

View file

@ -0,0 +1,57 @@
# Portions of this file are sourced from
# https://github.com/NickCao/flakes/blob/3b03efb676ea602575c916b2b8bc9d9cd13b0d85/nixos/mainframe/gravity.nix
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.enthalpy;
in
{
options.services.enthalpy.gost = {
enable = mkEnableOption "simple tunnel for accessing the underlay network";
};
config = mkIf (cfg.enable && cfg.gost.enable) {
systemd.network.networks."50-enthalpy" = {
address = singleton "fc00::";
routes = singleton { Destination = cfg.address; };
};
systemd.services.enthalpy-gost = {
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
DynamicUser = true;
RuntimeDirectory = "enthalpy";
ExecStart = "${pkgs.gost}/bin/gost -L=socks5://[fc00::]:${toString config.networking.ports.enthalpy-gost}";
ProtectSystem = "full";
ProtectHome = "yes";
ProtectKernelTunables = true;
ProtectControlGroups = true;
PrivateTmp = true;
PrivateDevices = true;
SystemCallFilter = "~@cpu-emulation @debug @keyring @module @mount @obsolete @raw-io";
MemoryDenyWriteExecute = "yes";
};
wants = [ "network-online.target" ];
after = [
"enthalpy.service"
"network-online.target"
];
requires = [ "enthalpy.service" ];
wantedBy = [ "multi-user.target" ];
};
services.enthalpy.exit.enable = true;
services.enthalpy.exit.prefix = singleton {
type = "static";
destination = "fc00::/128";
source = "${cfg.address}/128";
};
};
}

View file

@ -60,21 +60,34 @@ in
systemd.services.enthalpy-nat64 = {
serviceConfig = {
ExecStart = "${pkgs.tayga}/bin/tayga -d --config ${pkgs.writeText "tayga.conf" ''
Type = "forking";
Restart = "on-failure";
RestartSec = 5;
DynamicUser = true;
RuntimeDirectory = "enthalpy";
ExecStart = "${pkgs.tayga}/bin/tayga --config ${pkgs.writeText "tayga.conf" ''
tun-device nat64
ipv6-addr fc00::
ipv4-addr 100.127.0.1
prefix ${cfg.nat64.prefix}
dynamic-pool ${cfg.nat64.dynamicPool}
''}";
CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
AmbientCapabilities = [ "CAP_NET_ADMIN" ];
ProtectSystem = "full";
ProtectHome = "yes";
ProtectKernelTunables = true;
ProtectControlGroups = true;
PrivateTmp = true;
SystemCallFilter = "~@cpu-emulation @debug @keyring @module @mount @obsolete @raw-io";
MemoryDenyWriteExecute = "yes";
};
partOf = [ "enthalpy.service" ];
wants = [ "network.target" ];
after = [
"enthalpy.service"
"network.target"
];
requires = [ "enthalpy.service" ];
requiredBy = [ "enthalpy.service" ];
wantedBy = [ "multi-user.target" ];
};

View file

@ -0,0 +1,57 @@
# Portions of this file are sourced from
# https://github.com/NickCao/flakes/blob/3b03efb676ea602575c916b2b8bc9d9cd13b0d85/modules/gravity/default.nix
{
config,
lib,
...
}:
with lib;
let
cfg = config.services.enthalpy;
in
{
options.services.enthalpy = {
services = mkOption {
type = types.attrsOf (
types.submodule {
options = {
overrideStrategy = mkOption {
type = types.str;
default = "asDropinIfExists";
};
};
}
);
default = { };
description = ''
Services that need to run inside the enthalpy network namespace.
'';
};
users = mkOption {
type = types.attrsOf (types.submodule { });
default = { };
description = ''
Users utilizing the enthalpy network namespace.
'';
};
};
config = mkIf cfg.enable {
systemd.services = mapAttrs (_name: value: {
inherit (value) overrideStrategy;
serviceConfig = {
NetworkNamespacePath = "/run/netns/${cfg.netns}";
BindReadOnlyPaths = "/etc/netns/${cfg.netns}/resolv.conf:/etc/resolv.conf:norbind";
};
after = [ "enthalpy.service" ];
requires = [ "enthalpy.service" ];
}) cfg.services;
services.enthalpy.services = mapAttrs' (
name: _value:
nameValuePair "user@${toString config.users.users.${name}.uid}" {
overrideStrategy = "asDropin";
}
) cfg.users;
};
}

View file

@ -1,232 +0,0 @@
# Portions of this file are sourced from
# https://github.com/NickCao/flakes/blob/3b03efb676ea602575c916b2b8bc9d9cd13b0d85/nixos/mainframe/gravity.nix
{
config,
lib,
pkgs,
mylib,
...
}:
with lib;
let
inherit (mylib.network) cidr;
cfg = config.services.enthalpy;
in
{
options.services.enthalpy.sing-box = {
enable = mkEnableOption "sing-box universal proxy platform";
tableName = mkOption {
type = types.str;
default = "sing-box";
readOnly = true;
description = ''
Routing table used for sing-box.
'';
};
table = mkOption {
type = types.int;
default = config.networking.routingTables."${cfg.sing-box.tableName}";
readOnly = true;
description = ''
Routing table ID associated with the sing-box routing table.
'';
};
priority = mkOption {
type = types.int;
default = config.networking.routingPolicyPriorities."${cfg.sing-box.tableName}";
readOnly = true;
description = ''
Routing priority assigned to the sing-box routing table.
'';
};
fwmark = mkOption {
type = types.int;
default = config.networking.routingMarks."${cfg.sing-box.tableName}";
readOnly = true;
description = ''
Firewall mark designated for the sing-box routing table.
'';
};
port = mkOption {
type = types.int;
default = config.networking.ports.sing-box;
readOnly = true;
description = ''
Port for the mixed proxy to listen on.
'';
};
clat = {
enable = mkEnableOption "464XLAT for IPv4 connectivity";
address = mkOption {
type = types.str;
default = cidr.host 2 cfg.prefix;
description = ''
IPv6 address used for 464XLAT as the mapped source address.
'';
};
segment = mkOption {
type = types.listOf types.str;
description = ''
SRv6 segments used for NAT64.
'';
};
};
};
config = mkIf (cfg.enable && cfg.sing-box.enable) (mkMerge [
# IPv6
{
systemd.network.networks."50-enthalpy" = {
routes = [
{
Destination = "::/0";
Gateway = "fe80::ff:fe00:0";
Table = cfg.sing-box.table;
Metric = 1024;
}
{
Destination = "::0/0";
Type = "blackhole";
Table = cfg.sing-box.table;
Metric = 4096;
}
];
routingPolicyRules = lib.singleton {
Family = "both";
FirewallMark = cfg.sing-box.fwmark;
Priority = cfg.sing-box.priority;
Table = cfg.sing-box.table;
};
};
services.sing-box = {
enable = true;
settings = {
log = {
level = "info";
};
dns = {
servers = [
{
tag = "cloudflare";
address = "https://[2606:4700:4700::1111]/dns-query";
strategy = "prefer_ipv6";
}
{
tag = "local";
address = "local";
strategy = "prefer_ipv4";
}
];
rules = [
{
geosite = [ "cn" ];
server = "local";
}
];
final = "cloudflare";
};
inbounds = [
{
type = "mixed";
tag = "inbound";
listen = "127.0.0.1";
listen_port = cfg.sing-box.port;
sniff = true;
sniff_override_destination = true;
}
];
outbounds = [
{
type = "direct";
tag = "enthalpy";
routing_mark = cfg.sing-box.fwmark;
domain_strategy = "prefer_ipv6";
}
{
type = "direct";
tag = "direct";
}
];
route = {
rules = [
{
geosite = [ "cn" ];
geoip = [ "cn" ];
ip_cidr = [ "10.0.0.0/8" ];
outbound = "direct";
}
];
final = "enthalpy";
};
};
};
environment.systemPackages = with pkgs; [ gg ];
environment.etc."ggconfig.toml".source = (pkgs.formats.toml { }).generate "ggconfig.toml" {
allow_insecure = false;
no_udp = false;
node = "socks5://127.0.0.1:${toString cfg.sing-box.port}";
proxy_private = false;
test_node_before_use = false;
};
}
# IPv4 (464XLAT)
(mkIf cfg.sing-box.clat.enable {
systemd.network.config = {
networkConfig = {
IPv6Forwarding = true;
ManageForeignRoutes = false;
};
};
systemd.network.networks."50-clat" = {
name = "clat";
linkConfig = {
MTUBytes = "1400";
RequiredForOnline = false;
};
addresses = singleton { Address = "192.0.0.2/32"; };
routes = [
{
Destination = "0.0.0.0/0";
Table = cfg.sing-box.table;
PreferredSource = "192.0.0.2";
Metric = 1024;
}
{ Destination = cfg.sing-box.clat.address; }
];
};
services.enthalpy.exit.enable = true;
services.enthalpy.exit.prefix = singleton "${cfg.sing-box.clat.address}/128";
systemd.services.enthalpy-clatd = {
path = with pkgs; [
iproute2
tayga
];
script = ''
ip r replace 64:ff9b::/96 from ${cfg.sing-box.clat.address} encap seg6 mode encap \
segs ${concatStringsSep "," cfg.sing-box.clat.segment} dev ${cfg.interface} mtu 1280
exec tayga -d --config ${pkgs.writeText "tayga.conf" ''
tun-device clat
prefix 64:ff9b::/96
ipv4-addr 192.0.0.1
map 192.0.0.2 ${cfg.sing-box.clat.address}
''}
'';
partOf = [ "enthalpy.service" ];
after = [
"enthalpy.service"
"network.target"
];
requires = [ "enthalpy.service" ];
requiredBy = [ "enthalpy.service" ];
wantedBy = [ "multi-user.target" ];
};
})
]);
}

View file

@ -15,30 +15,6 @@ in
{
options.services.enthalpy.srv6 = {
enable = mkEnableOption "segment routing over IPv6";
tableName = mkOption {
type = types.str;
default = "localsid";
readOnly = true;
description = ''
Routing table designated for SRv6 SID.
'';
};
table = mkOption {
type = types.int;
default = config.networking.routingTables."${cfg.srv6.tableName}";
readOnly = true;
description = ''
Routing table ID associated with the localsid routing table.
'';
};
priority = mkOption {
type = types.int;
default = config.networking.routingPolicyPriorities."${cfg.srv6.tableName}";
readOnly = true;
description = ''
Routing priority assigned to the localsid routing table.
'';
};
prefix = mkOption {
type = types.str;
default = cidr.subnet 4 6 cfg.prefix;
@ -49,8 +25,8 @@ in
actions = mkOption {
type = types.listOf types.str;
default = [
"${cidr.host 1 cfg.srv6.prefix} encap seg6local action End.DT6 table main dev ${cfg.interface} table ${cfg.srv6.tableName}"
"${cidr.host 2 cfg.srv6.prefix} encap seg6local action End.DT6 table nat64 dev ${cfg.interface} table ${cfg.srv6.tableName}"
"${cidr.host 1 cfg.srv6.prefix} encap seg6local action End.DT6 table main dev enthalpy table localsid"
"${cidr.host 2 cfg.srv6.prefix} encap seg6local action End.DT6 table nat64 dev enthalpy table localsid"
];
description = ''
List of SRv6 actions configured in the default network namespace.
@ -70,12 +46,12 @@ in
routes = singleton {
Destination = "::/0";
Type = "blackhole";
Table = cfg.srv6.table;
Table = config.networking.routingTables.localsid;
};
routingPolicyRules = singleton {
Priority = cfg.srv6.priority;
Priority = config.networking.routingPolicyPriorities.localsid;
Family = "ipv6";
Table = cfg.srv6.table;
Table = config.networking.routingTables.localsid;
From = cfg.network;
To = cfg.srv6.prefix;
};
@ -95,13 +71,11 @@ in
"${pkgs.iproute2}/bin/ip -n ${cfg.netns} -6 r d ${cfg.srv6.prefix} from ${cfg.network} via fe80::ff:fe00:1 dev enthalpy"
];
};
partOf = [ "enthalpy.service" ];
after = [
"enthalpy.service"
"network-online.target"
];
requires = [ "enthalpy.service" ];
requiredBy = [ "enthalpy.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
};

View file

@ -1,11 +1,13 @@
{ config, pkgs, ... }:
let
uid = config.ids.uids.rebmit;
homeDirectory = "/home/rebmit";
in
{
programs.fish.enable = true;
users.users.rebmit = {
inherit uid;
hashedPasswordFile = config.sops.secrets."user-password/rebmit".path;
isNormalUser = true;
shell = pkgs.fish;