From e0f806a2f44bc08ff8931f8e5dab09579850154d Mon Sep 17 00:00:00 2001
From: Peter Bex <peter@more-magic.net>
Date: Sun, 14 Feb 2016 19:16:04 +0100
Subject: First version of vps image builder.

---
 assets/boot/extlinux.conf               |   5 +
 assets/firewall/ferm.conf               |  38 +++++
 assets/fstab                            |   3 +
 assets/modprobe/blacklist.conf          |   2 +
 assets/network/cloudstack-guest-setup   |  30 ++++
 assets/network/interfaces               |  18 +++
 assets/package-manager/apt/apt-settings |   4 +
 vps-builder.scm                         | 268 ++++++++++++++++++++++++++++++++
 8 files changed, 368 insertions(+)
 create mode 100644 assets/boot/extlinux.conf
 create mode 100644 assets/firewall/ferm.conf
 create mode 100644 assets/fstab
 create mode 100644 assets/modprobe/blacklist.conf
 create mode 100644 assets/network/cloudstack-guest-setup
 create mode 100644 assets/network/interfaces
 create mode 100644 assets/package-manager/apt/apt-settings
 create mode 100644 vps-builder.scm

diff --git a/assets/boot/extlinux.conf b/assets/boot/extlinux.conf
new file mode 100644
index 0000000..c340924
--- /dev/null
+++ b/assets/boot/extlinux.conf
@@ -0,0 +1,5 @@
+default linux
+timeout 1
+label linux
+kernel {KERNEL}
+append initrd={RAMDISK} root=/dev/vda1 console=tty0 console=ttyS0,115200 ro quiet
\ No newline at end of file
diff --git a/assets/firewall/ferm.conf b/assets/firewall/ferm.conf
new file mode 100644
index 0000000..e9a1e88
--- /dev/null
+++ b/assets/firewall/ferm.conf
@@ -0,0 +1,38 @@
+# -*- shell-script -*-
+#
+#  Configuration file for ferm(1).
+#
+
+@def $PUBLIC_SERVICES=(ssh);
+@def $BADGUYS=();
+
+domain (ip ip6) table filter {
+	chain (INPUT OUTPUT FORWARD) {
+		# connection tracking
+		mod state state INVALID DROP;
+		mod state state (ESTABLISHED RELATED) ACCEPT;
+	}
+
+	chain INPUT {
+		policy DROP;
+
+		# drop blacklisted connections
+		saddr @ipfilter($BADGUYS) DROP;
+
+		# allow local packet
+		interface lo ACCEPT;
+
+		# respond to ping
+		proto icmp ACCEPT; 
+
+		proto tcp dport $PUBLIC_SERVICES ACCEPT;
+	}
+
+	chain OUTPUT {
+		policy ACCEPT;
+	}
+
+	chain FORWARD {
+		policy DROP;
+	}
+}
diff --git a/assets/fstab b/assets/fstab
new file mode 100644
index 0000000..703881b
--- /dev/null
+++ b/assets/fstab
@@ -0,0 +1,3 @@
+# /etc/fstab: static file system information.
+proc	/proc	proc	nodev,noexec,nosuid	0	0
+/dev/vda1	/	ext3	errors=remount-ro	0	1
diff --git a/assets/modprobe/blacklist.conf b/assets/modprobe/blacklist.conf
new file mode 100644
index 0000000..4a36d77
--- /dev/null
+++ b/assets/modprobe/blacklist.conf
@@ -0,0 +1,2 @@
+# disable pc speaker
+blacklist pcspkr
\ No newline at end of file
diff --git a/assets/network/cloudstack-guest-setup b/assets/network/cloudstack-guest-setup
new file mode 100644
index 0000000..3583afe
--- /dev/null
+++ b/assets/network/cloudstack-guest-setup
@@ -0,0 +1,30 @@
+#!/bin/sh
+#
+# From https://github.com/shankerbalan/cloudstack-scripts
+# Stripped out the non-Debian and Plesk stuff, the cron
+# randomisation and the 127.0.1.1 removal in /etc/hosts.
+#
+# TODO: Rewrite in CHICKEN?
+#
+# /etc/dhcp/dhclient-exit-hooks.d/cloudstack-guest-setup (debian/ubuntu)
+# runs on firstboot after acquiring DHCP lease
+
+if [ "$reason" != BOUND ] && [ "$reason" != RENEW ] && [ "$reason" != REBIND ] && [ "$reason" != REBOOT ]; then
+    return
+fi
+
+# set hostname
+logger -t "cloudstack" "Setting hostname to \"${new_host_name}\""
+hostname "$new_host_name" > /dev/null 2>&1
+echo "$new_host_name" > /etc/hostname
+
+# add hostname to /etc/hosts and remove previous localhost-style
+sed -i "/127.0.0.1/a $new_ip_address $new_host_name.$new_domain_name $new_host_name" /etc/hosts > /dev/null 2>&1
+
+# generate ssh host keys
+logger -t "cloudstack" "Generating ssh host keys"
+ssh-keygen -A && rm /etc/ssh/ssh_host_key /etc/ssh/ssh_host_key.pub
+# remove self
+rm /etc/dhcp/dhclient-exit-hooks.d/cloudstack-guest-setup > /dev/null 2>&1
+
+exit 0
diff --git a/assets/network/interfaces b/assets/network/interfaces
new file mode 100644
index 0000000..b40848c
--- /dev/null
+++ b/assets/network/interfaces
@@ -0,0 +1,18 @@
+# This file describes the network interfaces available on your system
+# and how to activate them. For more information, see interfaces(5).
+
+# The loopback network interface
+auto lo
+iface lo inet loopback
+
+# The normal eth0
+auto eth0
+iface eth0 inet dhcp
+
+# Maybe the VM has 2 NICs?
+allow-hotplug eth1
+iface eth1 inet dhcp
+
+# Maybe the VM has 3 NICs?
+allow-hotplug eth2
+iface eth2 inet dhcp
diff --git a/assets/package-manager/apt/apt-settings b/assets/package-manager/apt/apt-settings
new file mode 100644
index 0000000..32ee0c8
--- /dev/null
+++ b/assets/package-manager/apt/apt-settings
@@ -0,0 +1,4 @@
+// Avoid installing unnecessary packages, to keep a lean system.
+// This is installed into /etc/apt/apt.conf.d/90custom-config
+APT::Install-Recommends "0";
+APT::Install-Suggests "0";
diff --git a/vps-builder.scm b/vps-builder.scm
new file mode 100644
index 0000000..7c4b60f
--- /dev/null
+++ b/vps-builder.scm
@@ -0,0 +1,268 @@
+;;;
+;;; Lightweight VPS image builder, by Peter Bex.
+;;;
+;;; This program is hereby placed in the public domain.
+;;;
+;;
+;; This provides a way of building custom images to run on VPSes which
+;; can automatically configure themselves, without requiring
+;; heavyweight, difficult to install solutions like Hashicorp's
+;; "Packer" (which has no Debian package) and/or CloudInit (which in
+;; Debian requires not one, but two versions of Python to be
+;; installed!)
+;;
+;; Heavily inspired by Debian's "build-openstack-debian-image" shell
+;; script, provided by the "openstack-debian-images" package.
+;;
+;; Currently only Debian Jessie and CloudStack are supported.
+;; Auto-resizing is NOT supported at the moment, you'll need to know
+;; in advance how big the VM's root disk will be.  In fact, you'll
+;; need to hack this script to change what it does.
+;;
+;; Maybe later, if I use this script more, it can be turned into
+;; a proper customisable program/library.
+;;
+;; To run, this requires the following packges:
+;; qemu-utils, kpartx, parted, mbr
+;;
+;; It depends on the "scsh-process" egg
+;;
+;; Before running it, you can create a "users" directory containing
+;; the public keys of all the users you want to prepopulate the image
+;; with.  The names of these files need to be USER:GROUP1,...  so for
+;; example, to create a user "peter" who is a member of groups "sudo"
+;; and "adm", copy his id_rsa.pub file to "users/peter:sudo,adm".
+
+(module vps-builder ()
+
+  (import chicken scheme)
+  (use data-structures extras files posix scsh-process)
+
+  (define debug? #t)
+
+  (reset-handler (lambda () #f))
+  
+  ;; Might it be useful to put this in scsh-process?
+  (define-syntax run*
+    (syntax-rules ()
+      ((_ ?pf ?redir ...)
+       (begin
+         (when debug?
+           (fprintf (current-error-port) "$ ~S\n" `?pf))
+         (receive (status normal? pid)
+             (run ?pf ?redir ...)
+           (unless (and normal? (zero? status))
+             (error (sprintf "Pipeline ~S exited abnormally, with code: ~A"
+                      `(run ?pf ?redir ...) status))))))))
+
+  (define minimal-packages
+    `("sudo" "locales" "extlinux" "openssh-server" "file" "kbd"
+      ;; TODO: What about other architectures?
+      "linux-image-amd64"))
+
+  ;; It's necessary to be root due to chroot and mount calls.
+  ;; TODO: Maybe support using sudo instead?  For now, just call
+  ;; the whole script with sudo.  Otherwise we'll need to mess
+  ;; with PATH and so on, too.
+  (define (check-privileges)
+    (unless (zero? (current-effective-user-id))
+      (error "Sorry, you must be root to build an image!")))
+
+  (define (check-users)
+    (when (or (not (directory? "users")) (null? (glob "users/*:*")))
+      (error (conc "You need to put public ssh keys in the "
+                   "\"users\" directory, named like "
+                   "USERNAME:GROUP1,..."))))
+
+  (define (create-image-file raw-file size-in-gb)
+    (let ((size (sprintf "~AG" size-in-gb)))
+      (run* (qemu-img create ,raw-file ,size))))
+
+  (define (convert-to-qcow2 raw-file qcow-file)
+    ;; TODO: Figure out if compat option is really needed
+    (run* (qemu-img convert -c -f raw -O qcow2 -o compat=0.10
+                    ,raw-file ,qcow-file)))
+
+  (define (partition-image image-file)
+    (run* (parted -s ,image-file mktable msdos))
+    (run* (parted -s ,image-file -a optimal
+             mkpart primary ext3 1Mi 100%))
+    (run* (parted -s ,image-file set 1 boot on))
+    (run* (install-mbr ,image-file)))
+
+  (define (prepare-image raw-image size-in-gb)
+    (create-image-file raw-image size-in-gb)
+    (partition-image raw-image))
+
+  (define (call-with-devmapped-image image-file proc)
+    (let ((kpartx-output #f))
+      (dynamic-wind
+          (lambda ()
+            ;; TODO: run/string doesn't (and can't) check exit status!
+            (set! kpartx-output (run/string (kpartx -asv ,image-file))))
+          (lambda ()
+            ;; Yeah this is stupid...
+            (let* ((parts (string-split kpartx-output " "))
+                   (loopback-dev (list-ref parts 2)))
+              (proc (make-pathname "/dev/mapper" loopback-dev))))
+          (lambda ()
+            (set! kpartx-output #f)
+            ;; For some reason kpartx will sometimes(?) fail
+            (handle-exceptions exn #f
+              (run* (kpartx -d ,image-file)))))))
+
+  (define (prepare-filesystem device)
+    ;; Apparently, operating on ext2 is much faster than ext3, so we
+    ;; make it ext2, and convert to ext3 later
+    (run* (mkfs.ext2 ,device)))
+
+  (define (finalize-filesystem device)
+    ;; Add journal, turning it into ext3
+    (run* (tune2fs -j ,device)))
+
+  (define (call-with-mounted-device device proc)
+    (let ((tempdir #f))
+      (dynamic-wind
+          (lambda ()
+            (set! tempdir (create-temporary-directory))
+            (run* (mount -o loop ,device ,tempdir)))
+          (lambda () (proc tempdir))
+          (lambda ()
+            (run* (umount ,tempdir))
+            (delete-directory tempdir #t)
+            (set! tempdir #f)))))
+
+  ;; Create a very basic setup which allows running programs inside
+  ;; a chroot (mount essential filesystems).
+  (define (with-running-system root-dir thunk)
+    (dynamic-wind
+        (lambda () (run* (chroot ,root-dir mount /proc)))
+        (lambda () (thunk))
+        (lambda () (run* (chroot ,root-dir umount /proc)))))
+
+  (define (install-basic-system target-dir package-list)
+    (let ((include (sprintf "--include=~A"
+                     (string-intersperse package-list ","))))
+      (run* (debootstrap
+             --verbose ,include "jessie" ,target-dir
+             "http://ftp.surfnet.nl/os/Linux/distr/debian"))))
+
+  (define (install-file root-dir source target owner group mode)
+    (let* ((full-path (make-pathname root-dir target))
+           (full-path (if (directory? full-path)
+                       (make-pathname full-path
+                                      (pathname-strip-directory source))
+                       full-path)))
+      (file-copy source full-path #t) ; allow clobber
+      ;; Must run in chroot to use correct passwd/group db
+      (run* (chroot ,root-dir chown ,(conc owner ":" group) ,target))
+      (change-file-mode full-path mode)))
+
+  (define (install-directory root-dir target owner group mode)
+    (let ((full-path (make-pathname root-dir target)))
+      (create-directory full-path)
+      ;; Must run in chroot to use correct passwd/group db
+      (run* (chroot ,root-dir chown ,(conc owner ":" group) ,target))
+      (change-file-mode full-path mode)))
+
+  (define (install-packages root-dir . packages)
+    (run* (chroot ,root-dir apt-get install -y ,@packages)))
+
+  (define (configure-basic-system root-dir)
+    ;;;; Configure apt, FS and disable console bleeping (just in case)
+    (install-file root-dir "assets/package-manager/apt/apt-settings"
+                  "/etc/apt/apt.conf.d/90custom-config"
+                  "root" "root" #o644)
+    (install-file root-dir "assets/fstab"
+                  "/etc/fstab" "root" "root" #o644)
+    (install-file root-dir "assets/modprobe/blacklist.conf"
+                  "/etc/modprobe.d/blacklist.conf" "root" "root" #o644)
+
+    ;;;; Setup network and related settings
+    (delete-file* (make-pathname
+                   root-dir "etc/udev/rules.d/70-persistent-net.rules"))
+    (install-file root-dir "assets/network/interfaces"
+                  "/etc/network/interfaces" "root" "root" #o644)
+    (install-file root-dir "assets/network/cloudstack-guest-setup"
+                  "/etc/dhcp/dhclient-exit-hooks.d" "root" "root" #o644))
+
+  (define (update-packages root-dir)
+    (run* (chroot ,root-dir apt-get update))
+    (run* (chroot ,root-dir apt-get upgrade -y)))
+
+  (define (make-bootable root-dir)
+
+    (define (rooted-glob dir pattern)
+      (let ((f (car (glob (make-pathname `(,root-dir ,dir) pattern)))))
+        (make-pathname `("/" ,dir) (pathname-strip-directory f))))
+
+    (let* ((kernel (rooted-glob "boot" "vmlinuz-*"))
+           (ramdisk (rooted-glob "boot" "initrd.img-*"))
+           (template (with-input-from-file
+                         "assets/boot/extlinux.conf" read-string))
+           (conf (string-translate* template
+                                    `(("{KERNEL}" . ,kernel)
+                                      ("{RAMDISK}" . ,ramdisk))))
+           (tgt (make-pathname `(,root-dir "boot" "extlinux")
+                               "extlinux.conf")))
+      (install-directory root-dir "/boot/extlinux" "root" "root" #o755)
+      (with-output-to-file tgt (lambda () (write-string conf)))
+      (run* (extlinux --install ,(make-pathname root-dir "boot")))))
+  
+  (define (setup-firewall root-dir)
+    (install-packages root-dir "ferm" "sshguard")
+    (install-file root-dir "assets/firewall/ferm.conf"
+                  "/etc/ferm/ferm.conf" "root" "adm" #o644))
+
+  ;; Create user and copy matching users/*:* file to .ssh/authorized_keys
+  (define (create-users root-dir)
+    (for-each (lambda (pubkey)
+                (let* ((fn (pathname-strip-directory pubkey ":"))
+                       (user+cs-groups (string-split fn))
+                       (user (car user+cs-groups))
+                       (cs-groups (cadr user+cs-groups))
+                       (.ssh (make-pathname `("/" "home" ,user) ".ssh"))
+                       (keys (make-pathname .ssh "authorized_keys")))
+                  (run* (chroot ,root-dir useradd -m -G ,cs-groups ,user))
+                  (install-directory root-dir .ssh user user #o700)
+                  (install-file root-dir pubkey keys user user #o600)))
+              (glob "users/*:*")))
+
+  (define (build-image image-base-name size-in-gb)
+    (let ((raw-image (make-pathname '() image-base-name ".raw"))
+          (qcow-image (make-pathname '() image-base-name ".qcow2")))
+      (check-privileges)
+      (check-users)
+      (prepare-image raw-image size-in-gb)
+
+      (call-with-devmapped-image
+       raw-image
+       (lambda (dev)
+         (prepare-filesystem dev)
+
+         (call-with-mounted-device
+          dev
+          (lambda (mountpoint)
+            (install-basic-system mountpoint minimal-packages)
+            (configure-basic-system mountpoint)
+
+            (with-running-system
+             mountpoint
+             (lambda ()
+               (update-packages mountpoint)
+               (make-bootable mountpoint)
+
+               (setup-firewall mountpoint)
+
+               (create-users mountpoint)))))
+
+         (finalize-filesystem dev)))
+
+      (convert-to-qcow2 raw-image qcow-image)
+      (printf "Success!  Images finished, raw: ~A, qcow2: ~A\n"
+        raw-image qcow-image)))
+
+;; Just do it!
+(handle-exceptions exn (signal exn) ; re-throw, to unwind dynamic extents
+  (build-image "debian-jessie" 20))
+)
-- 
cgit v1.2.3