微生物たちの言葉遊び [index]
- spore
微生物たちの言葉遊び [index]
- spore
With NixOS: With nix-darwin: Nix flakes must be copied into the store before being evaluated. And for a git
repository, even if the flake is referred to with a path ( This is quite annoying while working on other people’s project. I use dev
flakes to provide personal dev environments, I don’t want to copy the whole
project into my nix store frequently, and nor do I want to There’s a solution for this: it’s possible to force a flake to work in “plain
directory mode” that only copies the directory containing it, by prefixing
For example, if I want to set up a dev shell with nix-direnv, I can use this as
my or with an absolute path: Then, with a file structure like I can now have a flake that doesn’t copy extra files to the store and doesn’t
need to be added to git index. Note that you may need to manually call TL;DR: Self-hosted Tailscale (with
Headscale), with Nebula
as a fall-back solution. I have an insecure LAN. I have machines in other cities. I need to access my
computer at home while being outside. So I need a virtual, peer-to-peer network
that connects all my devices together. Better, two of them in parallel so I can
put one in maintenance mode while using the other one. I found Nebula at first. It works well, requiring
no central server or public SSL certificate. Conversely admins need to
sign and distribute their own certificates with The minimal requirement to run a Nebula network is a machine with a public,
static IP and a port available (a cheapest VPS would work). This machine will
serve as a “lighthouse” which other nodes use to find each other. You can have
multiple lighthouses and the network is available with at least one of them
online, which is handy during migration. Nebula will try its best at NAT traversal, but if that fails it try to relay
connections through nodes configured to work as relays. I simply use
lighthouses as relays because they are accessible from every node.1
A simple (NixOS-based, check official docs for regular ones)
lighthouse configuration looks like this: And a client configuration is a bit more complicated than that, because it
needs to know where to find a lighthouse, and to configure its
firewall2 to accept connections. The mobile app works similarly, just set the same information up with the GUI.
Beware that the mobile client doesn’t allow any inbound connections and the
firewall is not configurable as of now (2025-07). I think it’s also possible to have port-forwarded relays inside
LAN to provide public-facing access to all the nodes inside local network,
without depending on NAT traversal. Disclaimer: I haven’t tried this in
practice. Note that the “lighthouse” is also a regular node in the network, and its
firewall can be configured in the same way to allow inbound access with
nebula. Nebula has its shortcomings: its NAT traversal strategy is weak, so I often end
up squeezing videos through a 5Mbps relay connection, and rotating certificates
for all devices without automation is gruesome. Tailscale solves the problems coming with
Nebula, at the expense of a central server, and
additionally requiring a valid SSL certificate. Normally their clients connect
to the Tailscale coordination server to give a smooth onboarding experience,
but I don’t want to depend on freemium services, and they do
mess up sometimes. Luckily
there’s a self-hosted solution called Headscale for
personal use scenarios. In Tailscale, the coordination server is not a regular node anymore. It is a
dedicated public service that handles not only service discovery, but also
client registration, key distribution and access control. And relays (DERP
servers) are a standalone service that can be hosted separately from the
coordination server. Headscale is a coordination server implementation that has
simplified access control and account system, with an embedded DERP server. To run Headscale with embedded DERP server1 2 (again in
NixOS, refer to official docs otherwise): This is a very basic config, so you may want to checkout the
reference config file
for more options available. Once the Headscale service is up and running you need to first set it up. This user name is required in following auth process of clients. In contrast it’s much simpler to register and run a Tailscale client. The
vanilla client is FOSS so I can just go with it: This needs some explanation. Basically with this config, Nix generates a
systemd service, that starts Tailscale daemon with There are two flows to register a new client with Headscale
(official docs here). Non-interactively with a pre-generated key: This generates a one-time key that you can put in aforementioned
Interactively without setting the key: This leads to a web page on the server that shows a key, then you need to
register the client with the key. In both cases you only need to do this once per client/device. On mobile devices it’s even simpler, you need to go out of the way to set your
login server but that’s basically it. Upon first login the interactive login
page shows up with the key, and again you run Sometimes you don’t have access to ports Configure both Headscale and its DERP server to run on some ports other than
and on the client, login with The Nix Note that you still need a domain name for this, which is not free. It may no
longer be true with Let’s Encrypt IP certificates in the future, but with
those limitations posed I’m not sure it will work. By the way, if you don’t have a domain but your server is able to do a HTTP-01 challenge, you can consider using a IP-based domain service like sslip.io. Only IPv4 DERP is supported in my config, set Headscale config has undergone some breaking changes, so this might be
outdated when you see it in the future. Mesh VPN solutions give me a static IP map of
all my devices, so that I can have encrypted access to each of my devices with
address like Since I’m a Neovim user (btw), plain old SSH is mostly enough for coding and
ops. But sometimes I want a graphic session to play games remotely or
temporarily peek through device cameras. Most of my use cases involve my phones (they are incapable enough to make
remote desktops always useful), so RDP doesn’t help. There are several RDP
client implementations on Android, but unfortunately none of them handles
H.264 encoded streams (yes, especially the Microsoft one which just crashes),
and all of the RDP servers on Linux use H.264 as the only option. So I’ve got to use VNC on Linux. There’s a nice, simple VNC client on Android
called AVNC, which covers all the features
I need. Server side, I use a headless Sway session combined with
wayvnc. They were a bit unstable last year
(the fact that I have an NVIDIA optimus card didn’t help), but have been
working quite well since 2025. Using a separate Wayland session has a few
advantages, like I can spin it up entirely remotely in tmux, without physical
access. It’s also a common situation that the remote connection has limited
bandwidth, and this dedicated Sway session is configured to be at 720p@30FPS by
default. I use a simple script to launch the headless session with VNC1: I also have a few special Sway bindings for this scenario: An apparent caveat is that VNC doesn’t handle audio by itself. On my old
machine I have remote audio with
Simple Protocol Player and
pulseaudio, but I’m not yet motivated enough to set it up. Also I’m rather sure
that there’s a better solution with pipewire, that I’m just too lazy to find
out. Occasionally I run a Windows VM inside my Linux laptop. I need to access it
remotely as well. Luckily enough, a tunnel to the virtual machine network can
be opened with a one-liner using SSH: This maps Connection is not secure, nor password-protected with this
config. In my case encryption is handled by Tailscale and I only accept
connections from Tailscale clients by setting Why Fennel? Because Lua is too verbose to my taste.
And among the alternatives, Fennel For instance: in Lua is in Fennel. Now you can see the benefits, as I have many similar cases in my
actual config. I use a little plugin called nfnl1, which
automatically compiles Fennel code to Lua on save. After installing the plugin, a Neovim config in Fennel is as simple as: Upon saving, Since this plugin compiles Fennel to Lua ahead of time, it’s not needed to load
a config developed with it. Actually there’s no need to load it at all in
filetypes other than Fennel. In my config with
lazy.nvim I wrote: And it works as intended. Neovim gets its Lua, I get my Fennel and they lived
happily thereafter. There are other solutions like aniseed and hotpot.nvim. I chose Fennel itself already ships with
many useful macros and forms. But I still
find them not covering some pain points of mine. So here is my secret sauce.
Bon appétit! In my opinion, it is an anti-feature that Lua mixes arrays and maps, seeing
them as tables. Fennel authors agree with me, so they didn’t include a syntax
for mixed tables. Most of the time it’s fine, until you find that some
libraries and plugins consume data in this shape. It’s okay for me if the data looks like Usage: which translates to in Lua. The implementation is trivial, but it took me some time to come up with this
name which is short and pretty enough to be practical. Lua and many other languages support “chaining” field access and function
calls, with infix dot operator and postfix application syntax. And
unsurprisingly, some APIs are designed around this convenience. Fennel doesn’t fully support this: “direct” dot access is only available for
identifiers, so for example These restrictions make sense, since so many niceties of S-expressions would be
gone if they were allowed. On the other hand I never agree with the idea that
Lisps need to have extra amount of parens comparing to other languages. The
only thing missing here is a syntax that’s concise and intuitive enough. Let’s figure out what can be done, starting with this Lua expression mixing
field accesses and calls: Naively translating the above expression we get this in Fennel: which screams “NOOOO” to me in every character. What about some built-in
macros? A smaller NO. The threading macro in Fennel, unlike its origin in Clojure,
doesn’t support placeholders. So the thread is forced to be broken when we
apply It’s finally readable, but still much more verbose than the original version.
If only we can use the exact same syntax from Lua… Oh wait, can’t we? A macro1 that treats symbols as fields and parens as calls, chaining
them together – that’s exactly the same behavior as the Lua syntax, in Lisp
flavor. Turns out it’s not hard to write a syntax transformation for this.
Now I can finally have my peace, looking at in my config. In my opinion it’s a wrong choice to conflate indexing with field access in
Fennel: indeed they are the same thing under the hood, but accessing fields
with a static symbol is a very different pattern than indexing with a value. I
mean, that’s precisely why Lua have them separated. Forcing I still appreciate that Fennel has a good macro system, so that I can help
myself with the situation. But since it have already inherited half the
syntactic mess from Clojure, I’d like it a bit more “magical” to deal with
interops better. It would be ideal for me if Fennel can follow Lua more
closely, having This macro is similar to the Clojure Note that macros presented in this section are assumed to be in
Fennel macro modules, defined
as at top of the file.
See its README for this Upcoming: Fennel REPL; fennel-ls 1. Nix & NixOS & nix-darwin [nix]
1.1. Nix tricks: lock
nixpkgs
to the version of your system [nix/trick-lock-current]nix flake lock --override-input nixpkgs github:NixOS/nixpkgs/$(nixos-version --hash)
nix flake lock --override-input nixpkgs github:NixOS/nixpkgs/$(darwin-version --nixpkgs-revision)
1.2. Nix tricks:
path:
flakes [nix/trick-path-flake].#
and alike), all
(and only) the indexed files (files that are git add
ed and committed) are
copied.add
the files to
be accidentally committed.path:
before the flake path when referring to it..envrc
:use flake path:./.flake
use flake path:$(pwd)/.flake
- <project root>/
- .envrc
- .flake/
- flake.nix
- flake.lock
nix lock
to create a lock file for
this kind of flake, because nix refuses to do that with a relative path, and
has problem dealing with unicode in absolute paths. 2. Networking and Remote Access [network]
2.1. Interconnecting My Devices [network/interconnection]
Mesh VPN with Nebula [network/nebula]
nebula-cert
,
and they are used to authorize and encrypt connections.services.nebula.networks."<name>" = {
ca = "/etc/nebula/ca.crt"; # it would be better to use agenix or sops-nix
cert = "/etc/nebula/host.crt"; # but I'm too lazy to touch a working config
key = "/etc/nebula/host.key";
isLighthouse = true;
isRelay = true;
listen.port = 4242;
};
networking.firewall = {
allowedTCPPorts = [ 4242 ];
allowedUDPPorts = [ 4242 ];
};
services.nebula.networks."<name>" = {
ca = "/etc/nebula/ca.crt";
cert = "/etc/nebula/host.crt";
key = "/etc/nebula/host.key";
staticHostMap = {
# nebula ip is the one you assigned to the machine while signing its cert.
# you can check it with `nebula-cert print -path host.crt`
"<lighthouse-nebula-ip>" = [ "<lighthouse-public-address>:<port>" ];
};
lighthouses = [ "<lighthouse-nebula-ip>" ];
relays = [ "<lighthouse-nebula-ip>" ];
# I only have trusted devices in the network,
# if that's not true use a more strict config
firewall = let any = [{
host = "any";
port = "any";
proto = "any";
}]; in {
inbound = any;
outbound = any;
};
};
networking.firewall = {
allowedTCPPorts = [ 4242 ];
allowedUDPPorts = [ 4242 ];
};
Mesh VPN with Tailscale (Headscale) [network/tailscale]
services.headscale = {
enable = true;
address = "0.0.0.0"; # this is the listen address
settings = {
server_url = "<public-url-of-the-server>";
derp = {
server = {
enabled = true;
region_id = 999;
region_code = "hsc";
region_name = "Headscale embedded";
stun_listen_addr = "<listen-addr>:<listen-port>";
ipv4 = "<public-ipv4-addr>";
};
urls = []; # this disables DERP servers provided by Tailscale
};
# set these if you don't acquire certs with headscale
# note you may also want to set `services.headscale.group` to access certs
tls_cert_path = "<tls-cert>";
tls_key_path = "<tls-key>";
dns = {
# note that this shouldn't be the same as your server's public domain
# I use an nonexistent TLD like .tail
base_domain = "<magic-dns-domain>";
};
};
};
headscale users create <user-name>
services.tailscale = {
enable = true;
openFirewall = true;
extraUpFlags = [
"--login-server"
"<your-headscale-address>"
];
extraDaemonFlags = [
"--no-logs-no-support" # this disables telemetry
];
# this configures your system to be able to serve as an exit node
# (with `sudo tailscale set --advertise-exit-node`,
# or put `--advertise-exit-node` in `extraSetFlags`)
useRoutingFeatures = "server";
# see below
authKeyFile = "<auth-key>";
};
# prevent tailscale from using nebula interface
systemd.services.tailscaled.serviceConfig = {
RestrictNetworkInterfaces = "lo <interfaces-other-than-nebula>";
};
tailscaled --no-log-no-support <other-flags-omitted>
then calls tailscale up
once with
the given auth key. Some intervention is needed because Tailscale doesn’t avoid
Nebula interfaces and this may lead to
various issues
(Nebula is smart enough to avoid routing through Tailscale so the reverse
doesn’t hold).
# on the server
headscale preauthkeys create --user <user-name>
authKeyFile
(effective for one hour by default). Deploy the config with
the key on your device to log it into the network.# on the client device
tailscale login --login-server <user-name>
# on the server
headscale nodes register --user <user-name> --key <key-on-the-page>
headscale nodes register
to
register the device.Special notes: in case you have no access to :80 and :443 [network/tailscale-dns-challenge]
:80
and :443
for certain reasons.
I won’t ask why, but here’s a possible solution::80
and :443
, and use Let’s Encrypt DNS challenge to acquire the SSL
certificate:security.acme.acceptTerms = true;
security.acme.certs."<your-domain>" = {
email = "<your-email>";
dnsProvider = "<dns-provider>";
# agenix path to the systemd environmentFile in my case,
# free to use all other means to provide access keys
environmentFile = config.age.secrets.acme-env.path;
};
services.headscale = {
# choose a atypical port
port = 11366;
# other options stays the same, omitted
settings = {
server_url = "https://<your-domain>:11366";
derp = {
server = {
# anything different from the headscale server port
stun_listen_addr = "0.0.0.0:11367";
# ...
};
# ...
};
};
# Also open the corresponding ports in firewall!
tailscale login --login-server https://<your-domain>:11366
.security.acme
option uses
Lego for ACME challenges, which
integrates with a great many domainproviders. Check it out on how to
configure yours!derp.server.ipv6
if
needed. 2.2. Remote Desktop with Sway Headless and Wayvnc [network/remote-desktop]
100.64.0.1
or athyrium.spore.tail
.#!/usr/bin/env bash
# run in headless mode, and show a software (remote) cursor
export WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1
sway --unsupported-gpu &
sleep 2
# `wayland-0` if you have no other graphical sessions running
# probably not needed if you run wayvnc in the Sway config
export WAYLAND_DISPLAY=wayland-1
wayvnc -g <listen-ip> &
wait
# This switches the resolution on the fly. Refresh rate can be changed too.
bindsym <key> output HEADLESS-1 mode 1920x1080@30Hz
# This rotates the screen to be in portrait mode.
bindsym <key> output HEADLESS-1 transform 90
# This resets the rotation.
bindsym <key> output HEADLESS-1 transform normal
Bonus Section: Connecting to a Windows VM [network/remote-desktop-windows-vm]
ssh -L 3389:192.168.122.84:3389 <device-running-vm>
192.168.122.84:3389
(IP address of the virtual machine in my case,
and the default port of Microsoft RDP service) on the remote host to
localhost:3389
. An RDP client can then connect to 127.0.0.1
to access the
remote virtual machine. This time I can just use the Microsoft RDP client on
mobile to log in.<listen-ip>
appropriately. 3. Neovim, and Other Editor-Related Thoughts [editor]
3.1. Neovim Configuration in Fennel [editor/fennel-on-neovim]
3.1.1. Getting Started (with nfnl) [editor/fennel-getting-started]
{
config = function()
if vim.o.columns < 60 then
return "short"
else
return "full"
end
end
}
{:config #(if (< vim.o.columns 60)
"short"
"full")}
# in the config directory
# you can also use an empty file, which saves you from future permission prompts
echo {} > .nfnl.fnl
nvim init.fnl
nfnl
will generate init.lua
from init.fnl
(it won’t override
pre-existing files though). If you are migrating from a Lua-based config, you
may find it easier to write an init_.fnl
and check the compiled Lua before
swapping it in place. It’s rather trivial to write Fennel code equivalent to
Lua, so it won’t take long after you get used to the language. And the
generated file is decently readable, so check it out when in doubt.[{1 "Olical/nfnl" :ft "fennel"}]
nfnl
because it’s minimal and robust. 3.1.2. Useful Macros [editor/fennel-macros]
3.1.2.1.
|
, for Mixed Array and Map [editor/fennel-macros/bar]{elem_at_1, key = value, ...}
since
{1 elem_at_1 :key value ...}
is not too bad, but I’m not going to accept {1 x 2 y 3 z :k v}
because it feels redundant and noisy. So I made a simple macro
for this case:(fn | [& args]
(let [len (length args)]
(table.move args 1 (- len 1) 1 (. args len))))
(| 1 2 3 {:a 4 :b 5})
{1, 2, 3, a = 4, b = 5}
3.1.2.2.
dot
, for Chaining Field Accesses and Calls [editor/fennel-macros/dot]The side where grass is greener
require("plugin").setup({ ... })
(vim.lsp.buf.hover)
works but ((require :plugin).setup ...)
does not; the alternative looks like ((. (require :plugin) :setup) x)
which is horrendous. (Note that here setup
becomes a
string and extra parens is required for dot access with the .
macro.)There shall be a way
f(a).b(x).c
(. ((. (f a) :b) x) :c)
;; with threading macros (no placeholders)
(-> ((-> (f a) (. :b)) x)
(. :c))
x
to function f(a).b
. With a better threading macro it can be as good
as:;; with SRFI-197 `chain` style macros
(chain (f a)
(. _ :b)
(_ x)
(. _ :c))
;; what if...?
(dot (f a) (b x) c)
(dot x)
maps to x
, (dot x a)
is x.a
, (dot x (f a))
is x.f(a)
, and
more arguments do the same transformation to the previous expression.(fn dot [x & args]
(fn transform [x form]
(case form
;; (dot x y) => (. x "y")
(where form (sym? form)) `(. ,x ,(tostring form))
;; bonus rule: (dot x [y z]) => (. x y z)
(where exprs (sequence? exprs)) `(. ,x ,(unpack exprs))
;; (dot x (f a)) => ((. x "f") a)
[symbol & rest] `((. ,x ,(tostring symbol))
,(unpack rest))
;; (dot x ()) => (x)
[] `(,x)))
(case args
;; (dot x form rest ...) => (dot (dot x form) rest ...)
[form & rest] (dot (transform x form) (unpack rest))
;; (dot x) => x
[] x))
(dot (require :plugin) (setup {}))
Final thoughts
.
to have
expressions (evaluating to strings) as parameters forfeits the chance of
accepting anything special, leaving the only choice to be nesting parens
outside..
as my dot
macro and renaming the original .
to
something like ref
...
, but that name is taken by
a Lua operator so I didn’t pick a symbolic name for it.fn
and imported with import-macros
. Additionally, due to the compilation
mechanism of nfnl
, macro modules need to have a special header like;; [nfnl-macro]
nfnl
-specific detail.
Powered by kodama.