This is continuation of Part I where I described the basics of the supervising BEAM applications with systemd and how to create basic, secure service for your Elixir application with it. In this article I will assume that you have read the previous one.
We already have our super simple service description. Just to refresh your memory, it is the hello.service
file once again:
[Unit]
Hello World service
.target
network
[Service]
notify
PORT=80
/opt/hello/bin/hello start
1min
CAP_NET_BIND_SERVICE
true
true
ERL_CRASH_DUMP_SECONDS=0
However there is one small problem. It allows our service to listen on any restricted port, not just 80
that we want to listen on. This can be troublesome as an attacker that gains RCE on our server can then capture any traffic on any port that we do not want to open (for example exposing port 22 using the ssh
module).
It would be nice if we could somehow inject sockets for only the ports we want to listen to into our application.
Socket passing#
Thanks to the systemd.socket
feature we can achieve that with a little work on our side.
First we need to create new unit named hello.socket
next to our hello.service
:
[Unit]
Listening socket
.target
sockets
[Socket]
80
both
true
true
It will create a socket connected to TCP 80 (because we used ListenStream=
, and TCP is the stream protocol). By default it will bind that socket to a service named the same as our socket, so now we need to edit our hello.service
a little bit:
[Unit]
Hello World service
.target
network
[Service]
notify
PORT=80
/opt/hello/bin/hello start
1min
true
true
ERL_CRASH_DUMP_SECONDS=0
And we need to modify our Hello.Application.cowboy_opts/0
to handle the socket which is passed to us a file descriptor:
# hello/application.ex
use Application
fds = :systemd.listen_fds()
children = [
,
]
Supervisor.start_link(children, strategy: :one_for_one)
end
# If there are no sockets passed to the application, then start listening on
# the port specified by the `PORT` environment variable
[port: String.to_integer(System.get_env(, ))]
end
# If there are any socket passed, then use first one
fd =
case socket do
# Sockets can be named, which will be passed as the second element in
# a tuple
-> fd
# Or unnamed, and then it will be just the file descriptor
fd -> fd
end
[
net: :inet6, # (1)
port: 0, # (2)
fd: fd # (3)
]
end
end
- Systemd sockets are IPv6 enabled (we explicitly said that we want to listen on both). That means, that we need to mark our connection as an INET6 connection. This will not affect IPv4 (INET) connections.
- We are required to pass
:port
key, but its value will be ignored, so we just pass0
. - We pass the file descriptor that will be then passed to the Cowboy listener.
Now when we will start our service:
# systemctl start hello.service
It will be available at https://localhost/
while still running as an unprivileged user.
Multiple ports#
The question may arise - how to allow our service to listen on more than one port, for example you want to have your website available as HTTPS alongside "regular" HTTP. This means that our application needs to listen on two restricted ports:
- 80 - for HTTP
- 443 - for HTTPS
Now we need to slightly modify a little our socket service and add another one. First rename our hello.socket
to hello-http.socket
and add a line Service=hello.service
and FileDescriptorName=http
to [Socket]
section, so we end with:
[Unit]
HTTP Socket
.target
sockets
[Socket]
http
80
.service
hello both
true
true
Next we create a similar file, but for HTTPS named hello-https.socket
[Unit]
HTTPS Socket
.target
sockets
[Socket]
https
443
.service
hello both
true
true
And we add the dependency on both of our sockets to the hello.service
:
[Unit]
Hello World service
-http.socket hello-https.socket
hello-http.socket hello-https.socket
hello
[Service]
/opt/hello/bin/hello start
true
true
ERL_CRASH_DUMB_SECONDS=0
Now we need to somehow differentiate between our sockets in the Hello.Application
, so we will be able to pass the proper FD to each of the listeners. The :systemd.listen_fds/0
will return a list of file descriptors, and if they are named, the format will be a 2-tuple where the first element is the file descriptor and the second is the name as a string:
# hello/application.ex
use Application
fds = :systemd.listen_fds()
router = Hello.Router
children = [
,
,
]
Supervisor.start_link(children, strategy: :one_for_one)
end
case List.keyfind(fds, protocol, 1) do
# If there is socket passed for given protocol, then use that one
->
[
net: :inet6,
port: 0,
fd: fd
]
# If there are no sockets passed to the application that match
# the protocol, then start listening on the port specified by
# `PORT_{protocol}` environment variable
_ ->
[
port: String.to_integer(System.get_env(, ))
]
end
end
Now our application will listen on both - HTTP and HTTPS, despite running as unprivileged user.
Socket activation#
Now, that we can inject sockets to our application with ease we can achieve even more fascinating feature - socket activation.
Some of you may used inetd
in the past, that allows you to dynamically start processes on network requests. It is quite an interesting tool that detects traffic on certain ports, then spawns a new process to handle it, passing data to and from that process via STDIN
and STDOUT
. There was a quirk though, it required the spawned process to shutdown after it handled the request and it was starting a new instance for each request. That works poorly with VMs like BEAM that have substantial startup time and are expected to be long-running systems. BEAM is capable of handling network requests on it's own.
Fortunately for us, the way that we have implemented our systemd service is all that we need to have our application dynamically activated. To observe that we just need to shutdown everything:
# systemctl stop hello-http.socket hello-https.socket hello.service
And now relaunch only the sockets:
# systemctl start hello-http.socket hello-https.socket
We can check, that our service is not running:
$ systemctl status hello.service
● hello.service - Hello World service
Loaded: loaded (/usr/local/lib/systemd/system/hello.service; static; vendor preset: enabled)
Active: inactive (dead)
TriggeredBy: ● hello-http.socket ● hello-https.socket
We can see the TriggeredBy
section that tells us, that this service will be started by one of the sockets listed there. Let see what will happen when we will try to request anything from our application:
$ curl http://localhost/
Hello World!
You can see that we got a response from our application. This mean that our application must have started, and indeed when we check:
$ systemctl status hello.service
● hello.service - Hello
Loaded: loaded (/usr/local/lib/systemd/system/hello.service; static; vendor preset: enabled)
Active: active (running) since Thu 2022-02-03 13:20:27 CET; 4s ago
TriggeredBy: ● hello-http.socket ● hello-https.socket
Main PID: 1106 (beam.smp)
Tasks: 19 (limit: 1136)
Memory: 116.7M
CGroup: /system.slice/hello.service
├─1106 /opt/hello/erts-12.2/bin/beam.smp -- -root /opt/hello -progname erl -- -home /run/hello -- -noshell -s elixir start_cli -mode embedded -setcookie CR63SVI6L5JAMJSDL3H4XPNMOPHEWSV2FPHCHCAN65CY6ASHMXBA==== -sname hello -c>
└─1138 erl_child_setup 1024
It seems to be running, and if we stop it, then we will get information that it still can be activated by our sockets:
# systemctl stop hello.service
Warning: Stopping hello.service, but it can still be activated by:
hello-http.socket hello-https.socket
That means, that systemd is still listening on the sockets that we defined, even when our application is down, and will start our application again as soon as there are any incoming requests.
Let test that out again:
$ curl http://localhost/
Hello World!
$ systemctl status hello.service
● hello.service - Hello
Loaded: loaded (/usr/local/lib/systemd/system/hello.service; static; vendor preset: enabled)
Active: active (running) since Thu 2022-02-03 13:22:27 CET; 4s ago
TriggeredBy: ● hello-http.socket ● hello-https.socket
Main PID: 3452 (beam.smp)
Tasks: 19 (limit: 1136)
Memory: 116.7M
CGroup: /system.slice/hello.service
├─3452 /opt/hello/erts-12.2/bin/beam.smp -- -root /opt/hello -progname erl -- -home /run/hello -- -noshell -s elixir start_cli -mode embedded -setcookie CR63SVI6L5JAMJSDL3H4XPNMOPHEWSV2FPHCHCAN65CY6ASHMXBA==== -sname hello -c>
└─3453 erl_child_setup 1024
Our application got launched again, automatically, just by the fact that there was incoming TCP connection.
Does it work for HTTPS connection as well?
# systemctl stop hello.service
$ curl -k https://localhost/
Hello World!
It seems so. Independently of which port we try to reach our application on, it will be automatically launched for us and the connection will be properly handled. Do note that systemd will not shut down our process after serving the request. It will continue to run from that point forward.
Summary#
I know that it took quite while since the last post (ca. 1.5 years), but I hope that I will be able to write the final part much sooner than this.