1#! @perl@
2
3use strict;
4use POSIX;
5use File::Path;
6use File::Slurp;
7use Fcntl ':flock';
8use Getopt::Long qw(:config gnu_getopt);
9
10my $nsenter = "@utillinux@/bin/nsenter";
11my $su = "@su@";
12
13# Ensure a consistent umask.
14umask 0022;
15
16# Parse the command line.
17
18sub showHelp {
19 print <<EOF;
20Usage: nixos-container list
21 nixos-container create <container-name> [--system-path <path>] [--config <string>] [--ensure-unique-name] [--auto-start]
22 nixos-container destroy <container-name>
23 nixos-container start <container-name>
24 nixos-container stop <container-name>
25 nixos-container status <container-name>
26 nixos-container update <container-name> [--config <string>]
27 nixos-container login <container-name>
28 nixos-container root-login <container-name>
29 nixos-container run <container-name> -- args...
30 nixos-container show-ip <container-name>
31 nixos-container show-host-key <container-name>
32EOF
33 exit 0;
34}
35
36my $systemPath;
37my $ensureUniqueName = 0;
38my $autoStart = 0;
39my $extraConfig;
40
41GetOptions(
42 "help" => sub { showHelp() },
43 "ensure-unique-name" => \$ensureUniqueName,
44 "auto-start" => \$autoStart,
45 "system-path=s" => \$systemPath,
46 "config=s" => \$extraConfig
47 ) or exit 1;
48
49my $action = $ARGV[0] or die "$0: no action specified\n";
50
51
52# Execute the selected action.
53
54mkpath("/etc/containers", 0, 0755);
55mkpath("/var/lib/containers", 0, 0700);
56
57if ($action eq "list") {
58 foreach my $confFile (glob "/etc/containers/*.conf") {
59 $confFile =~ /\/([^\/]+).conf$/ or next;
60 print "$1\n";
61 }
62 exit 0;
63}
64
65my $containerName = $ARGV[1] or die "$0: no container name specified\n";
66$containerName =~ /^[a-zA-Z0-9\-]+$/ or die "$0: invalid container name\n";
67
68sub writeNixOSConfig {
69 my ($nixosConfigFile) = @_;
70
71 my $nixosConfig = <<EOF;
72{ config, lib, pkgs, ... }:
73
74with lib;
75
76{ boot.isContainer = true;
77 networking.hostName = mkDefault "$containerName";
78 networking.useDHCP = false;
79 $extraConfig
80}
81EOF
82
83 write_file($nixosConfigFile, $nixosConfig);
84}
85
86if ($action eq "create") {
87 # Acquire an exclusive lock to prevent races with other
88 # invocations of ‘nixos-container create’.
89 my $lockFN = "/run/lock/nixos-container";
90 open(my $lock, '>>', $lockFN) or die "$0: opening $lockFN: $!";
91 flock($lock, LOCK_EX) or die "$0: could not lock $lockFN: $!";
92
93 my $confFile = "/etc/containers/$containerName.conf";
94 my $root = "/var/lib/containers/$containerName";
95
96 # Maybe generate a unique name.
97 if ($ensureUniqueName) {
98 my $base = $containerName;
99 for (my $nr = 0; ; $nr++) {
100 $confFile = "/etc/containers/$containerName.conf";
101 $root = "/var/lib/containers/$containerName";
102 last unless -e $confFile || -e $root;
103 $containerName = "$base-$nr";
104 }
105 }
106
107 die "$0: container ‘$containerName’ already exists\n" if -e $confFile;
108
109 # Due to interface name length restrictions, container names must
110 # be restricted too.
111 die "$0: container name ‘$containerName’ is too long\n" if length $containerName > 11;
112
113 # Get an unused IP address.
114 my %usedIPs;
115 foreach my $confFile2 (glob "/etc/containers/*.conf") {
116 my $s = read_file($confFile2) or die;
117 $usedIPs{$1} = 1 if $s =~ /^HOST_ADDRESS=([0-9\.]+)$/m;
118 $usedIPs{$1} = 1 if $s =~ /^LOCAL_ADDRESS=([0-9\.]+)$/m;
119 }
120
121 my ($ipPrefix, $hostAddress, $localAddress);
122 for (my $nr = 1; $nr < 255; $nr++) {
123 $ipPrefix = "10.233.$nr";
124 $hostAddress = "$ipPrefix.1";
125 $localAddress = "$ipPrefix.2";
126 last unless $usedIPs{$hostAddress} || $usedIPs{$localAddress};
127 $ipPrefix = undef;
128 }
129
130 die "$0: out of IP addresses\n" unless defined $ipPrefix;
131
132 my @conf;
133 push @conf, "PRIVATE_NETWORK=1\n";
134 push @conf, "HOST_ADDRESS=$hostAddress\n";
135 push @conf, "LOCAL_ADDRESS=$localAddress\n";
136 push @conf, "AUTO_START=$autoStart\n";
137 write_file($confFile, \@conf);
138
139 close($lock);
140
141 print STDERR "host IP is $hostAddress, container IP is $localAddress\n";
142
143 # The per-container directory is restricted to prevent users on
144 # the host from messing with guest users who happen to have the
145 # same uid.
146 my $profileDir = "/nix/var/nix/profiles/per-container";
147 mkpath($profileDir, 0, 0700);
148 $profileDir = "$profileDir/$containerName";
149 mkpath($profileDir, 0, 0755);
150
151 # Build/set the initial configuration.
152 if (defined $systemPath) {
153 system("nix-env", "-p", "$profileDir/system", "--set", $systemPath) == 0
154 or die "$0: failed to set initial container configuration\n";
155 } else {
156 mkpath("$root/etc/nixos", 0, 0755);
157
158 my $nixosConfigFile = "$root/etc/nixos/configuration.nix";
159 writeNixOSConfig $nixosConfigFile;
160
161 system("nix-env", "-p", "$profileDir/system",
162 "-I", "nixos-config=$nixosConfigFile", "-f", "<nixpkgs/nixos>",
163 "--set", "-A", "system") == 0
164 or die "$0: failed to build initial container configuration\n";
165 }
166
167 print "$containerName\n" if $ensureUniqueName;
168 exit 0;
169}
170
171my $root = "/var/lib/containers/$containerName";
172my $profileDir = "/nix/var/nix/profiles/per-container/$containerName";
173my $gcRootsDir = "/nix/var/nix/gcroots/per-container/$containerName";
174my $confFile = "/etc/containers/$containerName.conf";
175if (!-e $confFile) {
176 if ($action eq "destroy") {
177 exit 0;
178 } elsif ($action eq "status") {
179 print "gone\n";
180 }
181 die "$0: container ‘$containerName’ does not exist\n" ;
182}
183
184sub isContainerRunning {
185 my $status = `systemctl show 'container\@$containerName'`;
186 return $status =~ /ActiveState=active/;
187}
188
189sub stopContainer {
190 system("systemctl", "stop", "container\@$containerName") == 0
191 or die "$0: failed to stop container\n";
192}
193
194# Return the PID of the init process of the container.
195sub getLeader {
196 my $s = `machinectl show "$containerName" -p Leader`;
197 chomp $s;
198 $s =~ /^Leader=(\d+)$/ or die "unable to get container's main PID\n";
199 return int($1);
200}
201
202# Run a command in the container.
203sub runInContainer {
204 my @args = @_;
205 my $leader = getLeader;
206 exec($nsenter, "-t", $leader, "-m", "-u", "-i", "-n", "-p", "--", @args);
207 die "cannot run ‘nsenter’: $!\n";
208}
209
210# Remove a directory while recursively unmounting all mounted filesystems within
211# that directory and unmounting/removing that directory afterwards as well.
212#
213# NOTE: If the specified path is a mountpoint, its contents will be removed,
214# only mountpoints underneath that path will be unmounted properly.
215sub safeRemoveTree {
216 my ($path) = @_;
217 system("find", $path, "-mindepth", "1", "-xdev",
218 "(", "-type", "d", "-exec", "mountpoint", "-q", "{}", ";", ")",
219 "-exec", "umount", "-fR", "{}", "+");
220 system("rm", "--one-file-system", "-rf", $path);
221 if (-e $path) {
222 system("umount", "-fR", $path);
223 system("rm", "--one-file-system", "-rf", $path);
224 }
225}
226
227if ($action eq "destroy") {
228 die "$0: cannot destroy declarative container (remove it from your configuration.nix instead)\n"
229 unless POSIX::access($confFile, &POSIX::W_OK);
230
231 stopContainer if isContainerRunning;
232
233 safeRemoveTree($profileDir) if -e $profileDir;
234 safeRemoveTree($gcRootsDir) if -e $gcRootsDir;
235 safeRemoveTree($root) if -e $root;
236 unlink($confFile) or die;
237}
238
239elsif ($action eq "start") {
240 system("systemctl", "start", "container\@$containerName") == 0
241 or die "$0: failed to start container\n";
242}
243
244elsif ($action eq "stop") {
245 stopContainer;
246}
247
248elsif ($action eq "status") {
249 print isContainerRunning() ? "up" : "down", "\n";
250}
251
252elsif ($action eq "update") {
253 my $nixosConfigFile = "$root/etc/nixos/configuration.nix";
254
255 # FIXME: may want to be more careful about clobbering the existing
256 # configuration.nix.
257 writeNixOSConfig $nixosConfigFile if (defined $extraConfig && $extraConfig ne "");
258
259 system("nix-env", "-p", "$profileDir/system",
260 "-I", "nixos-config=$nixosConfigFile", "-f", "<nixpkgs/nixos>",
261 "--set", "-A", "system") == 0
262 or die "$0: failed to build container configuration\n";
263
264 if (isContainerRunning) {
265 print STDERR "reloading container...\n";
266 system("systemctl", "reload", "container\@$containerName") == 0
267 or die "$0: failed to reload container\n";
268 }
269}
270
271elsif ($action eq "login") {
272 exec("machinectl", "login", "--", $containerName);
273}
274
275elsif ($action eq "root-login") {
276 runInContainer("@su@", "root", "-l");
277}
278
279elsif ($action eq "run") {
280 shift @ARGV; shift @ARGV;
281 # Escape command.
282 my $s = join(' ', map { s/'/'\\''/g; "'$_'" } @ARGV);
283 runInContainer("@su@", "root", "-l", "-c", "exec " . $s);
284}
285
286elsif ($action eq "show-ip") {
287 my $s = read_file($confFile) or die;
288 $s =~ /^LOCAL_ADDRESS=([0-9\.]+)$/m or die "$0: cannot get IP address\n";
289 print "$1\n";
290}
291
292elsif ($action eq "show-host-key") {
293 my $fn = "$root/etc/ssh/ssh_host_ed25519_key.pub";
294 $fn = "$root/etc/ssh/ssh_host_ecdsa_key.pub" unless -e $fn;
295 exit 1 if ! -f $fn;
296 print read_file($fn);
297}
298
299else {
300 die "$0: unknown action ‘$action’\n";
301}