propellor

propellor config for hosts.
git clone git://git.ricketyspace.net/propellor.git
Log | Files | Refs | LICENSE

JoeySites.hs (49911B)


      1 -- | Specific configuration for Joey Hess's sites. Probably not useful to
      2 -- others except as an example.
      3 
      4 {-# LANGUAGE FlexibleContexts, TypeFamilies #-}
      5 
      6 module Propellor.Property.SiteSpecific.JoeySites where
      7 
      8 import Propellor.Base
      9 import qualified Propellor.Property.Apt as Apt
     10 import qualified Propellor.Property.File as File
     11 import qualified Propellor.Property.ConfFile as ConfFile
     12 import qualified Propellor.Property.Gpg as Gpg
     13 import qualified Propellor.Property.Ssh as Ssh
     14 import qualified Propellor.Property.Git as Git
     15 import qualified Propellor.Property.Cron as Cron
     16 import qualified Propellor.Property.Service as Service
     17 import qualified Propellor.Property.User as User
     18 import qualified Propellor.Property.Group as Group
     19 import qualified Propellor.Property.Sudo as Sudo
     20 import qualified Propellor.Property.Borg as Borg
     21 import qualified Propellor.Property.Apache as Apache
     22 import qualified Propellor.Property.Postfix as Postfix
     23 import qualified Propellor.Property.Systemd as Systemd
     24 import qualified Propellor.Property.Network as Network
     25 import qualified Propellor.Property.Fail2Ban as Fail2Ban
     26 import qualified Propellor.Property.LetsEncrypt as LetsEncrypt
     27 import qualified Propellor.Property.Mount as Mount
     28 import Utility.Split
     29 
     30 import Data.List
     31 import System.Posix.Files
     32 
     33 scrollBox :: Property (HasInfo + DebianLike)
     34 scrollBox = propertyList "scroll server" $ props
     35 	& User.accountFor (User "scroll")
     36 	& Git.cloned (User "scroll") "git://git.kitenet.net/scroll" (d </> "scroll") Nothing
     37 	& Apt.installed ["ghc", "make", "cabal-install", "libghc-vector-dev",
     38 		"libghc-bytestring-dev", "libghc-mtl-dev", "libghc-ncurses-dev",
     39 		"libghc-random-dev", "libghc-monad-loops-dev", "libghc-text-dev",
     40 		"libghc-ifelse-dev", "libghc-case-insensitive-dev",
     41 		"libghc-data-default-dev", "libghc-optparse-applicative-dev"]
     42 	& userScriptProperty (User "scroll")
     43 		[ "cd " ++ d </> "scroll"
     44 		, "git pull"
     45 		, "cabal configure"
     46 		, "make"
     47 		]
     48 		`assume` MadeChange
     49 	& s `File.hasContent`
     50 		[ "#!/bin/sh"
     51 		, "set -e"
     52 		, "echo Preparing to run scroll!"
     53 		, "cd " ++ d
     54 		, "mkdir -p tmp"
     55 		, "TMPDIR= t=$(tempfile -d tmp)"
     56 		, "export t"
     57 		, "rm -f \"$t\""
     58 		, "mkdir \"$t\""
     59 		, "cd \"$t\""
     60 		, "echo"
     61 		, "echo Note that games on this server are time-limited to 2 hours"
     62 		, "echo 'Need more time? Run scroll locally instead!'"
     63 		, "echo"
     64 		, "echo Press Enter to start the game."
     65 		, "read me"
     66 		, "SHELL=/bin/sh script --timing=timing -c " ++ g
     67 		] `onChange` (s `File.mode` (combineModes (ownerWriteMode:readModes ++ executeModes)))
     68 	& g `File.hasContent`
     69 		[ "#!/bin/sh"
     70 		, "if ! timeout --kill-after 1m --foreground 2h ../../scroll/scroll; then"
     71 		, "echo Scroll seems to have ended unexpectedly. Possibly a bug.."
     72 		, "else"
     73 		, "echo Thanks for playing scroll! https://joeyh.name/code/scroll/"
     74 		, "fi"
     75 		, "echo Your game was recorded, as ID:$(basename \"$t\")"
     76 		, "echo if you would like to talk about how it went, email scroll@joeyh.name"
     77 		, "read line"
     78 		] `onChange` (g `File.mode` (combineModes (ownerWriteMode:readModes ++ executeModes)))
     79 	-- prevent port forwarding etc by not letting scroll log in via ssh
     80 	& Ssh.sshdConfig `File.containsLine` ("DenyUsers scroll")
     81 		`onChange` Ssh.restarted
     82 	& User.shellSetTo (User "scroll") s
     83 	& User.hasPassword (User "scroll")
     84 	-- telnetd attracted password crackers, so disabled
     85 	& Apt.removed ["telnetd"]
     86 	& Apt.installed ["shellinabox"]
     87 	& File.hasContent "/etc/default/shellinabox"
     88 		[ "# Deployed by propellor"
     89 		, "SHELLINABOX_DAEMON_START=1"
     90 		, "SHELLINABOX_PORT=4242"
     91 		, "SHELLINABOX_ARGS=\"--disable-ssl --no-beep --service=:scroll:scroll:" ++ d ++ ":" ++ s ++ "\""
     92 		]
     93 		`onChange` Service.restarted "shellinabox"
     94 	& Service.running "shellinabox"
     95   where
     96 	d = "/home/scroll"
     97 	s = d </> "login.sh"
     98 	g = d </> "game.sh"
     99 
    100 oldUseNetServer :: [Host] -> Property (HasInfo + DebianLike)
    101 oldUseNetServer hosts = propertyList "olduse.net server" $ props
    102 	& Apt.installed ["leafnode"]
    103 	& oldUseNetInstalled "oldusenet-server"
    104 	& oldUseNetBackup
    105 	& spoolsymlink
    106 	& "/etc/news/leafnode/config" `File.hasContent`
    107 		[ "# olduse.net configuration (deployed by propellor)"
    108 		, "expire = 1000000" -- no expiry via texpire
    109 		, "server = " -- no upstream server
    110 		, "debugmode = 1"
    111 		, "allowSTRANGERS = 42" -- lets anyone connect
    112 		, "nopost = 1" -- no new posting (just gather them)
    113 		]
    114 	& "/etc/hosts.deny" `File.lacksLine` "leafnode: ALL"
    115 	& Apt.serviceInstalledRunning "openbsd-inetd"
    116 	& File.notPresent "/etc/cron.daily/leafnode"
    117 	& File.notPresent "/etc/cron.d/leafnode"
    118 	& Cron.niceJob "oldusenet-expire" (Cron.Times "11 1 * * *") (User "news") newsspool expirecommand
    119 	& Cron.niceJob "oldusenet-uucp" (Cron.Times "*/5 * * * *") (User "news") "/" uucpcommand
    120 	& Apache.siteEnabled "nntp.olduse.net" nntpcfg
    121   where
    122 	newsspool = "/var/spool/news"
    123 	datadir = "/var/spool/oldusenet"
    124 	expirecommand = intercalate ";"
    125 		[ "find \\( -path ./out.going -or -path ./interesting.groups -or -path './*/.overview' \\) -prune -or -type f -ctime +60  -print | xargs --no-run-if-empty rm"
    126 		, "find -type d -empty | xargs --no-run-if-empty rmdir"
    127 		]
    128 	uucpcommand = "/usr/bin/uucp " ++ datadir
    129 	nntpcfg = apachecfg "nntp.olduse.net"
    130 		[ "  DocumentRoot " ++ datadir ++ "/"
    131 		, "  <Directory " ++ datadir ++ "/>"
    132 		, "    Options Indexes FollowSymlinks"
    133 		, "    AllowOverride None"
    134 		, Apache.allowAll
    135 		, "  </Directory>"
    136 		]
    137 
    138 	spoolsymlink :: Property UnixLike
    139 	spoolsymlink = check (not . isSymbolicLink <$> getSymbolicLinkStatus newsspool)
    140 		(property "olduse.net spool in place" $ makeChange $ do
    141 			removeDirectoryRecursive newsspool
    142 			createSymbolicLink (datadir </> "news") newsspool
    143 		)
    144 
    145 	oldUseNetBackup :: Property (HasInfo + DebianLike)
    146 	oldUseNetBackup = Borg.backup datadir borgrepo 
    147 		(Cron.Times "33 4 * * *")
    148 		[]
    149 		[Borg.KeepDays 30]
    150 		`requires` Ssh.userKeyAt (Just keyfile)
    151 			(User "root")
    152 			(Context "olduse.net")
    153 			(SshRsa, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD0F6L76SChMCIGmeyGhlFMUTgZ3BoTbATiOSs0A7KXQoI1LTE5ZtDzzUkrQRJVpJ640pfMR7cQZyBm8tv+kYIPp0238GrX43c1vgm0L78agDnBU7r2iNMyWIwhssK8O3ZAhp8Q4KCz1r8hP2nIiD0y1D1VWW8h4KWOS7I1XCEAjOTvFvEjTh6a9MyHrcIkv7teUUzTBRjNrsyijCFRk1+pEET54RueoOmEjQcWd/sK1tYRiMZjegRLBOus2wUWsUOvznJ2iniLONUTGAWRnEV+O7hLN6CD44osJ+wkZk8bPAumTS0zcSLckX1jpdHJicmAyeniWSd4FCqm1YE6/xDD")
    154 		`requires` Ssh.knownHost hosts "usw-s002.rsync.net" (User "root")
    155 	borgrepo = rsyncNetBorgRepo "olduse.net.borg" [Borg.UseSshKey keyfile]
    156 	keyfile = "/root/.ssh/olduse.net.key"
    157 
    158 oldUseNetShellBox :: Property DebianLike
    159 oldUseNetShellBox = propertyList "olduse.net shellbox" $ props
    160 	& oldUseNetInstalled "oldusenet"
    161 	& Service.running "shellinabox"
    162 
    163 oldUseNetInstalled :: Apt.Package -> Property DebianLike
    164 oldUseNetInstalled pkg = check (not <$> Apt.isInstalled pkg) $
    165 	propertyList ("olduse.net " ++ pkg) $ props
    166 		& Apt.installed (words "build-essential devscripts debhelper git libncursesw5-dev libpcre3-dev pkg-config bison libicu-dev libidn11-dev libcanlock2-dev libuu-dev ghc libghc-ifelse-dev libghc-hxt-dev libghc-utf8-string-dev libghc-missingh-dev libghc-sha-dev haskell-stack")
    167 			`describe` "olduse.net build deps"
    168 		& scriptProperty
    169 			[ "rm -rf /root/tmp/oldusenet" -- idenpotency
    170 			, "git clone git://olduse.net/ /root/tmp/oldusenet/source"
    171 			, "cd /root/tmp/oldusenet/source/"
    172 			, "HOME=/root dpkg-buildpackage -us -uc"
    173 			, "dpkg -i ../" ++ pkg ++ "_*.deb || true"
    174 			, "apt-get -fy install" -- dependencies
    175 			, "rm -rf /root/tmp/oldusenet"
    176 			]
    177 			`assume` MadeChange
    178 			`describe` "olduse.net built"
    179 
    180 kgbServer :: Property (HasInfo + DebianLike)
    181 kgbServer = propertyList desc $ props
    182 	& Apt.serviceInstalledRunning "kgb-bot"
    183 	& "/etc/default/kgb-bot" `File.containsLine` "BOT_ENABLED=1"
    184 		`describe` "kgb bot enabled"
    185 		`onChange` Service.running "kgb-bot"
    186 	& File.hasPrivContent "/etc/kgb-bot/kgb.conf" anyContext
    187 		`onChange` Service.restarted "kgb-bot"
    188   where
    189 	desc = "kgb.kitenet.net setup"
    190 
    191 -- git.kitenet.net and git.joeyh.name
    192 gitServer :: [Host] -> Property (HasInfo + DebianLike)
    193 gitServer hosts = propertyList "git.kitenet.net setup" $ props
    194 	& Borg.backup "/srv/git" borgrepo
    195 		(Cron.Times "33 3 * * *")
    196 		[]
    197 		[Borg.KeepDays 30]
    198 		`requires` Ssh.userKeyAt (Just sshkey)
    199 			(User "root")
    200 			(Context "git.kitenet.net")
    201 			(SshRsa, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLwUUkpkI9c2Wcnv/E4v9bJ7WcpiNkToltXfzRDd1F31AYrucfSMgzu3rtDpEL+wSnQLua/taJkWUWT/pyXOAh+90K6O/YeBZmY5CK01rYDz3kSTAtwHkMqednsRjdQS6NNJsuWc1reO8a4pKtsToJ3G9VAKufCkt2b8Nhqz0yLvLYwwU/mdI8DmfX6IgXhdy9njVEG/jsQnLFXY6QEfwKbIPs9O6qo4iFJg3defXX+zVMLsh3NE1P2i2VxMjxJEQdPdy9Z1sVpkiQM+mgJuylQQ5flPK8sxhO9r4uoK/JROkjPJNYoJMlsN+QlK04ABb7JV2JwhAL/Y8ypjQ13JdT")
    202 		`requires` Ssh.knownHost hosts "usw-s002.rsync.net" (User "root")
    203 	& Ssh.authorizedKeys (User "family") (Context "git.kitenet.net")
    204 	& User.accountFor (User "family")
    205 	& Apt.installed ["git", "rsync", "cgit"]
    206 	& Apt.installed ["git-annex"]
    207 	& Apt.installed ["kgb-client"]
    208 	& File.hasPrivContentExposed "/etc/kgb-bot/kgb-client.conf" anyContext
    209 		`requires` File.dirExists "/etc/kgb-bot/"
    210 	& Git.daemonRunning "/srv/git"
    211 	& "/etc/cgitrc" `File.hasContent`
    212 		[ "clone-url=https://git.joeyh.name/git/$CGIT_REPO_URL git://git.joeyh.name/$CGIT_REPO_URL"
    213 		, "css=/cgit-css/cgit.css"
    214 		, "logo=/cgit-css/cgit.png"
    215 		, "enable-http-clone=1"
    216 		, "root-title=Joey's git repositories"
    217 		, "root-desc="
    218 		, "enable-index-owner=0"
    219 		, "snapshots=tar.gz"
    220 		, "enable-git-config=1"
    221 		, "scan-path=/srv/git"
    222 		]
    223 		`describe` "cgit configured"
    224 	-- I keep the website used for git.kitenet.net/git.joeyh.name checked into git..
    225 	& Git.cloned (User "joey") "/srv/git/joey/git.kitenet.net.git" "/srv/web/git.kitenet.net" Nothing
    226 	-- Don't need global apache configuration for cgit.
    227 	! Apache.confEnabled "cgit"
    228 	& website "git.kitenet.net"
    229 	& website "git.joeyh.name"
    230 	& Apache.modEnabled "cgi"
    231   where
    232 	sshkey = "/root/.ssh/git.kitenet.net.key"
    233 	borgrepo = rsyncNetBorgRepo "git.kitenet.net.borg" [Borg.UseSshKey sshkey]
    234 	website hn = Apache.httpsVirtualHost' hn "/srv/web/git.kitenet.net/" letos
    235 		[ Apache.iconDir
    236 		, "  <Directory /srv/web/git.kitenet.net/>"
    237 		, "    Options Indexes ExecCGI FollowSymlinks"
    238 		, "    AllowOverride None"
    239 		, "    AddHandler cgi-script .cgi"
    240 		, "    DirectoryIndex index.cgi"
    241 		,      Apache.allowAll
    242 		, "  </Directory>"
    243 		, ""
    244 		, "  ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/"
    245 		, "  <Directory /usr/lib/cgi-bin>"
    246 		, "    SetHandler cgi-script"
    247 		, "    Options ExecCGI"
    248 		, "  </Directory>"
    249 		]
    250 
    251 type AnnexUUID = String
    252 
    253 -- | A website, with files coming from a git-annex repository.
    254 annexWebSite :: Git.RepoUrl -> HostName -> AnnexUUID -> [(String, Git.RepoUrl)] -> Property (HasInfo + DebianLike)
    255 annexWebSite origin hn uuid remotes = propertyList (hn ++" website using git-annex") $ props
    256 	& Git.cloned (User "joey") origin dir Nothing
    257 		`onChange` setup
    258 	& alias hn
    259 	& postupdatehook `File.hasContent`
    260 		[ "#!/bin/sh"
    261 		, "exec git update-server-info"
    262 		] `onChange`
    263 			(postupdatehook `File.mode` (combineModes (ownerWriteMode:readModes ++ executeModes)))
    264 	& setupapache
    265   where
    266 	dir = "/srv/web/" ++ hn
    267 	postupdatehook = dir </> ".git/hooks/post-update"
    268 	setup = userScriptProperty (User "joey") setupscript
    269 		`assume` MadeChange
    270 	setupscript =
    271 		[ "cd " ++ shellEscape dir
    272 		, "git annex reinit " ++ shellEscape uuid
    273 		] ++ map addremote remotes ++
    274 		[ "git annex get"
    275 		, "git update-server-info"
    276 		]
    277 	addremote (name, url) = "git remote add " ++ shellEscape name ++ " " ++ shellEscape url
    278 	setupapache = Apache.httpsVirtualHost' hn dir letos
    279 		[ "  ServerAlias www."++hn
    280 		,    Apache.iconDir
    281 		, "  <Directory "++dir++">"
    282 		, "    Options Indexes FollowSymLinks ExecCGI"
    283 		, "    AllowOverride None"
    284 		, "    AddHandler cgi-script .cgi"
    285 		, "    DirectoryIndex index.html index.cgi"
    286 		,      Apache.allowAll
    287 		, "  </Directory>"
    288 		]
    289 
    290 letos :: LetsEncrypt.AgreeTOS
    291 letos = LetsEncrypt.AgreeTOS (Just "id@joeyh.name")
    292 
    293 apacheSite :: HostName -> Apache.ConfigFile -> RevertableProperty DebianLike DebianLike
    294 apacheSite hn middle = Apache.siteEnabled hn $ apachecfg hn middle
    295 
    296 apachecfg :: HostName -> Apache.ConfigFile -> Apache.ConfigFile
    297 apachecfg hn middle =
    298 	[ "<VirtualHost *:" ++ val port ++ ">"
    299 	, "  ServerAdmin grue@joeyh.name"
    300 	, "  ServerName "++hn++":" ++ val port
    301 	]
    302 	++ middle ++
    303 	[ ""
    304 	, "  ErrorLog /var/log/apache2/error.log"
    305 	, "  LogLevel warn"
    306 	, "  CustomLog /var/log/apache2/access.log combined"
    307 	, "  ServerSignature On"
    308 	, "  "
    309 	, Apache.iconDir
    310 	, "</VirtualHost>"
    311 	]
    312 	  where
    313 		port = Port 80
    314 
    315 gitAnnexDistributor :: Property (HasInfo + DebianLike)
    316 gitAnnexDistributor = combineProperties "git-annex distributor, including rsync server and signer" $ props
    317 	& Apt.installed ["rsync"]
    318 	& File.hasPrivContent "/etc/rsyncd.conf" (Context "git-annex distributor")
    319 		`onChange` Service.restarted "rsync"
    320 	& File.hasPrivContent "/etc/rsyncd.secrets" (Context "git-annex distributor")
    321 		`onChange` Service.restarted "rsync"
    322 	& "/etc/default/rsync" `File.containsLine` "RSYNC_ENABLE=true"
    323 		`onChange` Service.running "rsync"
    324 	& Systemd.enabled "rsync"
    325 	& endpoint "/srv/web/downloads.kitenet.net/git-annex/autobuild"
    326 	& endpoint "/srv/web/downloads.kitenet.net/git-annex/autobuild/x86_64-apple-yosemite"
    327 	& endpoint "/srv/web/downloads.kitenet.net/git-annex/autobuild/windows"
    328 	-- git-annex distribution signing key
    329 	& Gpg.keyImported (Gpg.GpgKeyId "89C809CB") (User "joey")
    330   where
    331 	endpoint d = combineProperties ("endpoint " ++ d) $ props
    332 		& File.dirExists d
    333 		& File.ownerGroup d (User "joey") (Group "joey")
    334 
    335 downloads :: Property (HasInfo + DebianLike)
    336 downloads = annexWebSite "/srv/git/downloads.git"
    337 	"downloads.kitenet.net"
    338 	"840760dc-08f0-11e2-8c61-576b7e66acfd"
    339 	[]
    340 
    341 tmp :: Property (HasInfo + DebianLike)
    342 tmp = propertyList "tmp.joeyh.name" $ props
    343 	& annexWebSite "/srv/git/joey/tmp.git"
    344 		"tmp.joeyh.name"
    345 		"26fd6e38-1226-11e2-a75f-ff007033bdba"
    346 		[]
    347 	& Cron.jobDropped "pump rss" (Cron.Times "15 * * * *")
    348 
    349 ircBouncer :: Property (HasInfo + DebianLike)
    350 ircBouncer = propertyList "IRC bouncer" $ props
    351 	& Apt.installed ["znc"]
    352 	& User.accountFor (User "znc")
    353 	& File.dirExists (takeDirectory conf)
    354 	& File.hasPrivContent conf anyContext
    355 	& File.ownerGroup conf (User "znc") (Group "znc")
    356 	& Cron.job "znconboot" (Cron.Times "@reboot") (User "znc") "~" "znc"
    357 	-- ensure running if it was not already
    358 	& userScriptProperty (User "znc") ["znc || true"]
    359 		`assume` NoChange
    360 		`describe` "znc running"
    361   where
    362 	conf = "/home/znc/.znc/configs/znc.conf"
    363 
    364 githubBackup :: Property (HasInfo + DebianLike)
    365 githubBackup = propertyList "github-backup box" $ props
    366 	& Apt.installed ["github-backup", "moreutils"]
    367 	& githubKeys
    368 	& Cron.niceJob "github-backup run" (Cron.Times "30 4 * * *") (User "joey")
    369 		"/home/joey/lib/backup" backupcmd
    370   where
    371 	backupcmd = intercalate "&&" $
    372 		[ "mkdir -p github"
    373 		, "cd github"
    374 		, ". $HOME/.github-keys"
    375 		, "github-backup joeyh"
    376 		]
    377 
    378 githubKeys :: Property (HasInfo + UnixLike)
    379 githubKeys =
    380 	let f = "/home/joey/.github-keys"
    381 	in File.hasPrivContent f anyContext
    382 		`onChange` File.ownerGroup f (User "joey") (Group "joey")
    383 
    384 
    385 rsyncNetBackup :: [Host] -> Property DebianLike
    386 rsyncNetBackup hosts = Cron.niceJob "rsync.net copied in daily" (Cron.Times "30 5 * * *")
    387 	(User "joey") "/home/joey/lib/backup" "mkdir -p rsync.net && rsync --delete -az 2318@usw-s002.rsync.net: rsync.net"
    388 	`requires` Ssh.knownHost hosts "usw-s002.rsync.net" (User "joey")
    389 
    390 podcatcher :: Property DebianLike
    391 podcatcher = Cron.niceJob "podcatcher run hourly" (Cron.Times "55 * * * *")
    392 	(User "joey") "/home/joey/lib/sound/podcasts"
    393 	"xargs git-annex importfeed -c annex.genmetadata=true < feeds; mr --quiet update"
    394 	`requires` Apt.installed ["git-annex", "myrepos"]
    395 
    396 spamdEnabled :: Property DebianLike
    397 spamdEnabled = tightenTargets $ 
    398 	cmdProperty "update-rc.d" ["spamassassin", "enable"]
    399 		`assume` MadeChange
    400 
    401 spamassassinConfigured :: Property DebianLike
    402 spamassassinConfigured = propertyList "spamassassin configured" $ props
    403 	& Apt.serviceInstalledRunning "spamassassin"
    404 	& "/etc/default/spamassassin" `File.containsLines`
    405 		[ "# Propellor deployed"
    406 		, "OPTIONS=\"--create-prefs --max-children 5 --helper-home-dir\""
    407 		, "CRON=1"
    408 		, "NICE=\"--nicelevel 15\""
    409 		]
    410 		`describe` "spamd configured"
    411 		`onChange` spamdEnabled
    412 		`onChange` Service.restarted "spamassassin"
    413 		`requires` Apt.serviceInstalledRunning "cron"
    414 
    415 kiteMailServer :: Property (HasInfo + DebianLike)
    416 kiteMailServer = propertyList "kitenet.net mail server" $ props
    417 	& Postfix.installed
    418 	& Apt.installed ["postfix-pcre"]
    419 	& Apt.serviceInstalledRunning "postgrey"
    420 	& spamassassinConfigured
    421 	& Apt.serviceInstalledRunning "spamass-milter"
    422 	-- Add -m to prevent modifying messages Subject or body.
    423 	& "/etc/default/spamass-milter" `File.containsLine`
    424 		"OPTIONS=\"-m -u spamass-milter -i 127.0.0.1\""
    425 		`onChange` Service.restarted "spamass-milter"
    426 		`describe` "spamass-milter configured"
    427 
    428 	& Apt.serviceInstalledRunning "amavisd-milter"
    429 	& "/etc/default/amavisd-milter" `File.containsLines`
    430 		[ "# Propellor deployed"
    431 		, "MILTERSOCKET=/var/spool/postfix/amavis/amavis.sock"
    432 		, "MILTERSOCKETOWNER=\"postfix:postfix\""
    433 		, "MILTERSOCKETMODE=\"0660\""
    434 		]
    435 		`onChange` Service.restarted "amavisd-milter"
    436 		`describe` "amavisd-milter configured for postfix"
    437 	& Apt.serviceInstalledRunning "clamav-freshclam"
    438 	-- Workaround https://bugs.debian.org/569150
    439 	& Cron.niceJob "amavis-expire" Cron.Daily (User "root") "/"
    440 		"find /var/lib/amavis/virusmails/ -type f -ctime +7 -delete"
    441 
    442 	& dkimInstalled
    443 
    444 	& Postfix.saslAuthdInstalled
    445 	& Fail2Ban.installed
    446 	& Fail2Ban.jailEnabled "postfix-sasl"
    447 	& "/etc/default/saslauthd" `File.containsLine` "MECHANISMS=sasldb"
    448 	& Postfix.saslPasswdSet "kitenet.net" (User "errol")
    449 	& Postfix.saslPasswdSet "kitenet.net" (User "joey")
    450 
    451 	& Apt.installed ["maildrop"]
    452 	& "/etc/maildroprc" `File.hasContent`
    453 		[ "# Global maildrop filter file (deployed with propellor)"
    454 		, "DEFAULT=\"$HOME/Maildir\""
    455 		, "MAILBOX=\"$DEFAULT/.\""
    456 		, "# Filter spam to a spam folder, unless .keepspam exists"
    457 		, "if (/^X-Spam-Status: Yes/)"
    458 		, "{"
    459 		, "  `test -e \"$HOME/.keepspam\"`"
    460 		, "  if ( $RETURNCODE != 0 )"
    461 		, "  to ${MAILBOX}spam"
    462 		, "}"
    463 		]
    464 		`describe` "maildrop configured"
    465 
    466 	& "/etc/aliases" `File.hasPrivContentExposed` ctx
    467 		`onChange` Postfix.newaliases
    468 	& hasPostfixCert ctx
    469 
    470 	& "/etc/postfix/mydomain" `File.containsLines`
    471 		[ "/.*\\.kitenet\\.net/\tOK"
    472 		, "/ikiwiki\\.info/\tOK"
    473 		, "/joeyh\\.name/\tOK"
    474 		]
    475 		`onChange` Postfix.reloaded
    476 		`describe` "postfix mydomain file configured"
    477 	& "/etc/postfix/obscure_client_relay.pcre" `File.hasContent`
    478 		-- Remove received lines for mails relayed from trusted
    479 		-- clients. These can be a privacy violation, or trigger
    480 		-- spam filters.
    481 		[ "/^Received: from ([^.]+)\\.kitenet\\.net.*using TLS.*by kitenet\\.net \\(([^)]+)\\) with (E?SMTPS?A?) id ([A-F[:digit:]]+)(.*)/ IGNORE"
    482 		-- Munge local Received line for postfix running on a
    483 		-- trusted client that relays through. These can trigger
    484 		-- spam filters.
    485 		, "/^Received: by ([^.]+)\\.kitenet\\.net.*/ REPLACE X-Question: 42"
    486 		]
    487 		`onChange` Postfix.reloaded
    488 		`describe` "postfix obscure_client_relay file configured"
    489 	& Postfix.mappedFile "/etc/postfix/virtual"
    490 		(flip File.containsLines
    491 			[ "# *@joeyh.name to joey"
    492 			, "@joeyh.name\tjoey"
    493 			]
    494 		) `describe` "postfix virtual file configured"
    495 		`onChange` Postfix.reloaded
    496 	& Postfix.mappedFile "/etc/postfix/relay_clientcerts"
    497 		(flip File.hasPrivContentExposed ctx)
    498 	& Postfix.mainCfFile `File.containsLines`
    499 		[ "myhostname = kitenet.net"
    500 		, "mydomain = $myhostname"
    501 		, "append_dot_mydomain = no"
    502 		, "myorigin = kitenet.net"
    503 		, "mydestination = $myhostname, localhost.$mydomain, $mydomain, kite.$mydomain., localhost, regexp:$config_directory/mydomain"
    504 		, "mailbox_command = maildrop"
    505 		, "virtual_alias_maps = hash:/etc/postfix/virtual"
    506 
    507 		, "# Allow clients with trusted certs to relay mail through."
    508 		, "relay_clientcerts = hash:/etc/postfix/relay_clientcerts"
    509 		, "smtpd_relay_restrictions = permit_mynetworks,permit_tls_clientcerts,permit_sasl_authenticated,reject_unauth_destination"
    510 
    511 		, "# Filter out client relay lines from headers."
    512 		, "header_checks = pcre:$config_directory/obscure_client_relay.pcre"
    513 
    514 		, "# Password auth for relaying"
    515 		, "smtpd_sasl_auth_enable = yes"
    516 		, "smtpd_sasl_security_options = noanonymous"
    517 		, "smtpd_sasl_local_domain = kitenet.net"
    518 
    519 		, "# Enable postgrey and sasl auth and client certs."
    520 		, "smtpd_recipient_restrictions = permit_tls_clientcerts,permit_sasl_authenticated,,permit_mynetworks,reject_unauth_destination,check_policy_service inet:127.0.0.1:10023"
    521 
    522 		, "# Enable spamass-milter, amavis-milter (opendkim is not enabled because it causes mails forwarded from eg gmail to be rejected)"
    523 		, "smtpd_milters = unix:/spamass/spamass.sock unix:amavis/amavis.sock"
    524 		, "# opendkim is used for outgoing mail"
    525 		, "non_smtpd_milters = inet:localhost:8891"
    526 		, "milter_connect_macros = j {daemon_name} v {if_name} _"
    527 		, "# If a milter is broken, fall back to just accepting mail."
    528 		, "milter_default_action = accept"
    529 
    530 		, "# TLS setup -- server"
    531 		, "smtpd_tls_CAfile = /etc/ssl/certs/joeyca.pem"
    532 		, "smtpd_tls_cert_file = /etc/ssl/certs/postfix.pem"
    533 		, "smtpd_tls_key_file = /etc/ssl/private/postfix.pem"
    534 		, "smtpd_tls_loglevel = 1"
    535 		, "smtpd_tls_received_header = yes"
    536 		, "smtpd_use_tls = yes"
    537 		, "smtpd_tls_ask_ccert = yes"
    538 		, "smtpd_tls_session_cache_database = sdbm:/etc/postfix/smtpd_scache"
    539 
    540 		, "# TLS setup -- client"
    541 		, "smtp_tls_CAfile = /etc/ssl/certs/joeyca.pem"
    542 		, "smtp_tls_cert_file = /etc/ssl/certs/postfix.pem"
    543 		, "smtp_tls_key_file = /etc/ssl/private/postfix.pem"
    544 		, "smtp_tls_loglevel = 1"
    545 		, "smtp_use_tls = yes"
    546 		, "smtp_tls_session_cache_database = sdbm:/etc/postfix/smtp_scache"
    547 
    548 		, "# Allow larger attachments, up to 200 mb."
    549 		, "# (Avoid setting too high; the postfix queue must have"
    550 		, "# 1.5 times this much space free, or postfix will reject"
    551 		, "# ALL mail!)"
    552 		, "message_size_limit = 204800000"
    553 		, "virtual_mailbox_limit = 20480000"
    554 		]
    555 		`onChange` Postfix.dedupMainCf
    556 		`onChange` Postfix.reloaded
    557 		`describe` "postfix configured"
    558 
    559 	& Apt.serviceInstalledRunning "dovecot-imapd"
    560 	& Apt.serviceInstalledRunning "dovecot-pop3d"
    561 	& "/etc/dovecot/conf.d/10-mail.conf" `File.containsLine`
    562 		"mail_location = maildir:~/Maildir"
    563 		`onChange` Service.reloaded "dovecot"
    564 		`describe` "dovecot mail.conf"
    565 	& "/etc/dovecot/conf.d/10-auth.conf" `File.containsLine`
    566 		"!include auth-passwdfile.conf.ext"
    567 		`onChange` Service.restarted "dovecot"
    568 		`describe` "dovecot auth.conf"
    569 	& File.hasPrivContent dovecotusers ctx
    570 		`onChange` (dovecotusers `File.mode`
    571 			combineModes [ownerReadMode, groupReadMode])
    572 	& File.ownerGroup dovecotusers (User "root") (Group "dovecot")
    573 
    574 	& Apt.installed ["mutt", "bsd-mailx", "alpine"]
    575 
    576 	& pinescript `File.hasContent`
    577 		[ "#!/bin/sh"
    578 		, "# deployed with propellor"
    579 		, "set -e"
    580 		, "exec alpine \"$@\""
    581 		]
    582 		`onChange` (pinescript `File.mode`
    583 			combineModes (readModes ++ executeModes))
    584 		`describe` "pine wrapper script"
    585 	-- Make pine use dovecot pipe to read maildir.
    586 	& "/etc/pine.conf" `File.hasContent`
    587 		[ "# deployed with propellor"
    588 		, "inbox-path={localhost}inbox"
    589 		, "rsh-command=" ++ imapalpinescript
    590 		]
    591 		`describe` "pine configured to use local imap server"
    592 	& imapalpinescript `File.hasContent`
    593 		[ "#!/bin/sh"
    594 		, "# deployed with propellor"
    595 		, "set -e"
    596 		, "exec /usr/lib/dovecot/imap 2>/dev/null"
    597 		]
    598 		`onChange` (imapalpinescript `File.mode`
    599 			combineModes (readModes ++ executeModes))
    600 		`describe` "imap script for pine"
    601 	& Apt.serviceInstalledRunning "mailman"
    602 	-- Override the default http url. (Only affects new lists.)
    603 	& "/etc/mailman/mm_cfg.py" `File.containsLine`
    604 		"DEFAULT_URL_PATTERN = 'https://%s/cgi-bin/mailman/'"
    605 
    606 	& Postfix.service ssmtp
    607 
    608 	& Apt.installed ["fetchmail"]
    609   where
    610 	ctx = Context "kitenet.net"
    611 	pinescript = "/usr/local/bin/pine"
    612 	imapalpinescript = "/usr/local/bin/imap-for-alpine"
    613 	dovecotusers = "/etc/dovecot/users"
    614 
    615 	ssmtp = Postfix.Service
    616 		(Postfix.InetService Nothing "ssmtp")
    617 		"smtpd" Postfix.defServiceOpts
    618 
    619 -- Configures postfix to have the dkim milter, and no other milters.
    620 dkimMilter :: Property (HasInfo + DebianLike)
    621 dkimMilter = Postfix.mainCfFile `File.containsLines`
    622 	[ "smtpd_milters = inet:localhost:8891"
    623 	, "non_smtpd_milters = inet:localhost:8891"
    624 	, "milter_default_action = accept"
    625 	]
    626 	`describe` "postfix dkim milter"
    627 	`onChange` Postfix.dedupMainCf
    628 	`onChange` Postfix.reloaded
    629 	`requires` dkimInstalled
    630 	`requires` Postfix.installed
    631 
    632 -- This does not configure postfix to use the dkim milter,
    633 -- nor does it set up domainkey DNS.
    634 dkimInstalled :: Property (HasInfo + DebianLike)
    635 dkimInstalled = go `onChange` Service.restarted "opendkim"
    636   where
    637 	go = propertyList "opendkim installed" $ props
    638 		& Apt.serviceInstalledRunning "opendkim"
    639 		& File.dirExists "/etc/mail"
    640 		& File.hasPrivContent "/etc/mail/dkim.key" (Context "kitenet.net")
    641 		& File.ownerGroup "/etc/mail/dkim.key" (User "root") (Group "root")
    642 		& "/etc/default/opendkim" `File.containsLine`
    643 			"SOCKET=\"inet:8891@localhost\""
    644 			`onChange` 
    645 				(cmdProperty "/lib/opendkim/opendkim.service.generate" []
    646 				`assume` MadeChange)
    647 			`onChange` Service.restarted "opendkim"
    648 		& "/etc/opendkim.conf" `File.containsLines`
    649 			[ "KeyFile /etc/mail/dkim.key"
    650 			, "SubDomains yes"
    651 			, "Domain *"
    652 			, "Selector mail"
    653 			]
    654 
    655 -- This is the dkim public key, corresponding with /etc/mail/dkim.key
    656 -- This value can be included in a domain's additional records to make
    657 -- it use this domainkey.
    658 domainKey :: (BindDomain, Record)
    659 domainKey = (RelDomain "mail._domainkey", TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCc+/rfzNdt5DseBBmfB3C6sVM7FgVvf4h1FeCfyfwPpVcmPdW6M2I+NtJsbRkNbEICxiP6QY2UM0uoo9TmPqLgiCCG2vtuiG6XMsS0Y/gGwqKM7ntg/7vT1Go9vcquOFFuLa5PnzpVf8hB9+PMFdS4NPTvWL2c5xxshl/RJzICnQIDAQAB")
    660 
    661 postfixSaslPasswordClient :: Property (HasInfo + DebianLike)
    662 postfixSaslPasswordClient = combineProperties "postfix uses SASL password to authenticate with smarthost" $ props
    663 	& Postfix.mappedFile "/etc/postfix/sasl_passwd" 
    664 		(`File.hasPrivContent` (Context "kitenet.net"))
    665 	& Postfix.mainCfFile `File.containsLines`
    666 		[ "# TLS setup for SASL auth to kite"
    667 		, "smtp_sasl_auth_enable = yes"
    668 		, "smtp_tls_security_level = encrypt"
    669 		, "smtp_sasl_tls_security_options = noanonymous"
    670 		, "relayhost = [kitenet.net]"
    671 		, "smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd"
    672 		]
    673 		`onChange` Postfix.reloaded
    674 	-- Comes after so it does not set relayhost but uses the setting 
    675 	-- above.
    676 	& Postfix.satellite
    677 
    678 hasPostfixCert :: Context -> Property (HasInfo + UnixLike)
    679 hasPostfixCert ctx = combineProperties "postfix tls cert installed" $ props
    680 	& "/etc/ssl/certs/postfix.pem" `File.hasPrivContentExposed` ctx
    681 	& "/etc/ssl/private/postfix.pem" `File.hasPrivContent` ctx
    682 
    683 -- Legacy static web sites and redirections from kitenet.net to newer
    684 -- sites.
    685 legacyWebSites :: Property (HasInfo + DebianLike)
    686 legacyWebSites = propertyList "legacy web sites" $ props
    687 	& Apt.serviceInstalledRunning "apache2"
    688 	& Apache.modEnabled "rewrite"
    689 	& Apache.modEnabled "cgi"
    690 	& Apache.modEnabled "speling"
    691 	& userDirHtml
    692 	& Apache.httpsVirtualHost' "kitenet.net" "/var/www" letos kitenetcfg
    693 	& alias "anna.kitenet.net"
    694 	& apacheSite "anna.kitenet.net"
    695 		[ "DocumentRoot /home/anna/html"
    696 		, "<Directory /home/anna/html/>"
    697 		, "  Options Indexes ExecCGI"
    698 		, "  AllowOverride None"
    699 		, Apache.allowAll
    700 		, "</Directory>"
    701 		]
    702 	& alias "sows-ear.kitenet.net"
    703 	& alias "www.sows-ear.kitenet.net"
    704 	& apacheSite "sows-ear.kitenet.net"
    705 		[ "ServerAlias www.sows-ear.kitenet.net"
    706 		, "DocumentRoot /srv/web/sows-ear.kitenet.net"
    707 		, "<Directory /srv/web/sows-ear.kitenet.net>"
    708 		, "  Options FollowSymLinks"
    709 		, "  AllowOverride None"
    710 		, Apache.allowAll
    711 		, "</Directory>"
    712 		, "RewriteEngine On"
    713 		, "RewriteRule .* http://www.sowsearpoetry.org/ [L]"
    714 		]
    715 	& alias "wortroot.kitenet.net"
    716 	& alias "www.wortroot.kitenet.net"
    717 	& apacheSite "wortroot.kitenet.net"
    718 		[ "ServerAlias www.wortroot.kitenet.net"
    719 		, "DocumentRoot /srv/web/wortroot.kitenet.net"
    720 		, "<Directory /srv/web/wortroot.kitenet.net>"
    721 		, "  Options FollowSymLinks"
    722 		, "  AllowOverride None"
    723 		, Apache.allowAll
    724 		, "</Directory>"
    725 		]
    726 	& alias "creeksidepress.com"
    727 	& apacheSite "creeksidepress.com"
    728 		[ "ServerAlias www.creeksidepress.com"
    729 		, "DocumentRoot /srv/web/www.creeksidepress.com"
    730 		, "<Directory /srv/web/www.creeksidepress.com>"
    731 		, "  Options FollowSymLinks"
    732 		, "  AllowOverride None"
    733 		, Apache.allowAll
    734 		, "</Directory>"
    735 		]
    736 	& alias "joey.kitenet.net"
    737 	& apacheSite "joey.kitenet.net"
    738 		[ "DocumentRoot /var/www"
    739 		, "<Directory /var/www/>"
    740 		, "  Options Indexes ExecCGI"
    741 		, "  AllowOverride None"
    742 		, Apache.allowAll
    743 		, "</Directory>"
    744 
    745 		, "RewriteEngine On"
    746 
    747 		, "# Old ikiwiki filenames for joey's wiki."
    748 		, "rewritecond $1 !.*/index$"
    749 		, "rewriterule (.+).html$ http://joeyh.name/$1/ [l]"
    750 
    751 		, "rewritecond $1 !.*/index$"
    752 		, "rewriterule (.+).rss$ http://joeyh.name/$1/index.rss [l]"
    753 
    754 		, "# Redirect all to joeyh.name."
    755 		, "rewriterule (.*) http://joeyh.name$1 [r]"
    756 		]
    757 	& alias "house.joeyh.name"
    758 	& apacheSite "house.joeyh.name"
    759 		[ "DocumentRoot /srv/web/house.joeyh.name"
    760 		, "<Directory /srv/web/house.joeyh.name>"
    761 		, "  Options Indexes ExecCGI"
    762 		, "  AllowOverride None"
    763 		, Apache.allowAll
    764 		, "</Directory>"
    765 		]
    766   where
    767 	kitenetcfg =
    768 		-- /var/www is empty
    769 		[ "DocumentRoot /var/www"
    770 		, "<Directory /var/www>"
    771 		, "  Options Indexes FollowSymLinks MultiViews ExecCGI Includes"
    772 		, "  AllowOverride None"
    773 		, Apache.allowAll
    774 		, "</Directory>"
    775 		, "ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/"
    776 
    777 		-- for mailman cgi scripts
    778 		, "<Directory /usr/lib/cgi-bin>"
    779 		, "  AllowOverride None"
    780 		, "  Options ExecCGI"
    781 		, Apache.allowAll
    782 		, "</Directory>"
    783 		, "Alias /pipermail/ /var/lib/mailman/archives/public/"
    784 		, "<Directory /var/lib/mailman/archives/public/>"
    785 		, "  Options Indexes MultiViews FollowSymlinks"
    786 		, "  AllowOverride None"
    787 		, Apache.allowAll
    788 		, "</Directory>"
    789 		, "Alias /images/ /usr/share/images/"
    790 		, "<Directory /usr/share/images/>"
    791 		, "  Options Indexes MultiViews"
    792 		, "  AllowOverride None"
    793 		, Apache.allowAll
    794 		, "</Directory>"
    795 
    796 		, "RewriteEngine On"
    797 		, "# Force hostname to kitenet.net"
    798 		, "RewriteCond %{HTTP_HOST} !^kitenet\\.net [NC]"
    799 		, "RewriteCond %{HTTP_HOST} !^$"
    800 		, "RewriteRule ^/(.*) http://kitenet\\.net/$1 [L,R]"
    801 
    802 		, "# Moved pages"
    803 		, "RewriteRule /programs/debhelper http://joeyh.name/code/debhelper/ [L]"
    804 		, "RewriteRule /programs/satutils http://joeyh.name/code/satutils/ [L]"
    805 		, "RewriteRule /programs/filters http://joeyh.name/code/filters/ [L]"
    806 		, "RewriteRule /programs/ticker http://joeyh.name/code/ticker/ [L]"
    807 		, "RewriteRule /programs/pdmenu http://joeyh.name/code/pdmenu/ [L]"
    808 		, "RewriteRule /programs/sleepd http://joeyh.name/code/sleepd/ [L]"
    809 		, "RewriteRule /programs/Lingua::EN::Words2Nums http://joeyh.name/code/Words2Nums/ [L]"
    810 		, "RewriteRule /programs/wmbattery http://joeyh.name/code/wmbattery/ [L]"
    811 		, "RewriteRule /programs/dpkg-repack http://joeyh.name/code/dpkg-repack/ [L]"
    812 		, "RewriteRule /programs/debconf http://joeyh.name/code/debconf/ [L]"
    813 		, "RewriteRule /programs/perlmoo http://joeyh.name/code/perlmoo/ [L]"
    814 		, "RewriteRule /programs/alien http://joeyh.name/code/alien/ [L]"
    815 		, "RewriteRule /~joey/blog/entry/(.+)-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[0-9][0-9].html http://joeyh.name/blog/entry/$1/ [L]"
    816 		, "RewriteRule /~anna/.* http://waldeneffect\\.org/ [R]"
    817 		, "RewriteRule /~anna/.* http://waldeneffect\\.org/ [R]"
    818 		, "RewriteRule /~anna http://waldeneffect\\.org/ [R]"
    819 		, "RewriteRule /simpleid/ http://openid.kitenet.net:8086/simpleid/"
    820 		, "# Even the kite home page is not here any more!"
    821 		, "RewriteRule ^/$ http://www.kitenet.net/ [R]"
    822 		, "RewriteRule ^/index.html http://www.kitenet.net/ [R]"
    823 		, "RewriteRule ^/joey http://www.kitenet.net/joey/ [R]"
    824 		, "RewriteRule ^/joey/index.html http://www.kitenet.net/joey/ [R]"
    825 		, "RewriteRule ^/wifi http://www.kitenet.net/wifi/ [R]"
    826 		, "RewriteRule ^/wifi/index.html http://www.kitenet.net/wifi/ [R]"
    827 
    828 		, "# Old ikiwiki filenames for kitenet.net wiki."
    829 		, "rewritecond $1 !^/~"
    830 		, "rewritecond $1 !^/doc/"
    831 		, "rewritecond $1 !^/pipermail/"
    832 		, "rewritecond $1 !^/cgi-bin/"
    833 		, "rewritecond $1 !.*/index$"
    834 		, "rewriterule (.+).html$ $1/ [r]"
    835 
    836 		, "# Old ikiwiki filenames for joey's wiki."
    837 		, "rewritecond $1 ^/~joey/"
    838 		, "rewritecond $1 !.*/index$"
    839 		, "rewriterule (.+).html$ http://kitenet.net/$1/ [L,R]"
    840 
    841 		, "# ~joey to joeyh.name"
    842 		, "rewriterule /~joey/(.*) http://joeyh.name/$1 [L]"
    843 
    844 		, "# Old familywiki location."
    845 		, "rewriterule /~family/(.*).html http://family.kitenet.net/$1 [L]"
    846 		, "rewriterule /~family/(.*).rss http://family.kitenet.net/$1/index.rss [L]"
    847 		, "rewriterule /~family(.*) http://family.kitenet.net$1 [L]"
    848 
    849 		, "rewriterule /~kyle/bywayofscience(.*) http://bywayofscience.branchable.com$1 [L]"
    850 		, "rewriterule /~kyle/family/wiki/(.*).html http://macleawiki.branchable.com/$1 [L]"
    851 		, "rewriterule /~kyle/family/wiki/(.*).rss http://macleawiki.branchable.com/$1/index.rss [L]"
    852 		, "rewriterule /~kyle/family/wiki(.*) http://macleawiki.branchable.com$1 [L]"
    853 		]
    854 
    855 userDirHtml :: Property DebianLike
    856 userDirHtml = File.fileProperty "apache userdir is html" (map munge) conf
    857 	`onChange` Apache.reloaded
    858 	`requires` Apache.modEnabled "userdir"
    859   where
    860 	munge = replace "public_html" "html"
    861 	conf = "/etc/apache2/mods-available/userdir.conf"
    862 
    863 -- Alarm clock: see
    864 -- <http://joeyh.name/blog/entry/a_programmable_alarm_clock_using_systemd/>
    865 --
    866 -- oncalendar example value: "*-*-* 7:30"
    867 alarmClock :: String -> User -> String -> Property Linux
    868 alarmClock oncalendar (User user) command = combineProperties "goodmorning timer installed" $ props
    869 	& "/etc/systemd/system/goodmorning.timer" `File.hasContent`
    870 		[ "[Unit]"
    871 		, "Description=good morning"
    872 		, ""
    873 		, "[Timer]"
    874 		, "Unit=goodmorning.service"
    875 		, "OnCalendar=" ++ oncalendar
    876 		, "WakeSystem=true"
    877 		, "Persistent=false"
    878 		, ""
    879 		, "[Install]"
    880 		, "WantedBy=multi-user.target"
    881 		]
    882 		`onChange` (Systemd.daemonReloaded
    883 			`before` Systemd.restarted "goodmorning.timer")
    884 	& "/etc/systemd/system/goodmorning.service" `File.hasContent`
    885 		[ "[Unit]"
    886 		, "Description=good morning"
    887 		, "RefuseManualStart=true"
    888 		, "RefuseManualStop=true"
    889 		, "ConditionACPower=true"
    890 		, "StopWhenUnneeded=yes"
    891 		, ""
    892 		, "[Service]"
    893 		, "Type=oneshot"
    894 		, "ExecStart=/bin/systemd-inhibit --what=handle-lid-switch --why=goodmorning /bin/su " ++ user ++ " -c \"" ++ command ++ "\""
    895 		]
    896 		`onChange` Systemd.daemonReloaded
    897 	& Systemd.enabled "goodmorning.timer"
    898 	& Systemd.started "goodmorning.timer"
    899 	& "/etc/systemd/logind.conf" `ConfFile.containsIniSetting`
    900 		("Login", "LidSwitchIgnoreInhibited", "no")
    901 
    902 house :: IsContext c => User -> [Host] -> c -> (SshKeyType, Ssh.PubKeyText) -> Property (HasInfo + DebianLike)
    903 house user hosts ctx sshkey = propertyList "home automation" $ props
    904 	& Apache.installed
    905 	& Apt.installed ["python", "python-pymodbus", "rrdtool", "rsync"]
    906 	& Git.cloned user "https://git.joeyh.name/git/joey/house.git" d Nothing
    907 	& Git.cloned user "https://git.joeyh.name/git/reactive-banana-automation.git" (d </> "reactive-banana-automation") Nothing
    908 	& websitesymlink
    909 	& build
    910 	& Systemd.enabled setupservicename
    911 		`requires` setupserviceinstalled
    912 		`onChange` Systemd.started setupservicename
    913 	& Systemd.enabled pollerservicename
    914 		`requires` pollerserviceinstalled
    915 		`onChange` Systemd.started pollerservicename
    916 	& Systemd.enabled controllerservicename
    917 		`requires` controllerserviceinstalled
    918 		`onChange` Systemd.started controllerservicename
    919 	& Systemd.enabled watchdogservicename
    920 		`requires` watchdogserviceinstalled
    921 		`onChange` Systemd.started watchdogservicename
    922 	& Apt.serviceInstalledRunning "watchdog"
    923 	& User.hasGroup user (Group "dialout")
    924 	& Group.exists (Group "gpio") Nothing
    925 	& User.hasGroup user (Group "gpio")
    926 	& Apt.installed ["i2c-tools"]
    927 	& User.hasGroup user (Group "i2c")
    928 	& "/etc/modules-load.d/house.conf" `File.hasContent` ["i2c-dev"]
    929 	& Cron.niceJob "house upload"
    930 		(Cron.Times "1 * * * *") user d rsynccommand
    931 		`requires` Ssh.userKeyAt (Just sshkeyfile) user ctx sshkey
    932 		`requires` File.ownerGroup (takeDirectory sshkeyfile)
    933 			user (userGroup user)
    934 		`requires` File.dirExists (takeDirectory sshkeyfile)
    935 		`requires` Ssh.knownHost hosts "kitenet.net" user
    936 	&  File.hasPrivContentExposed "/etc/darksky-forecast-url" anyContext
    937   where
    938 	d = "/home/joey/house"
    939 	sshkeyfile = d </> ".ssh/key"
    940 	build = check (not <$> doesFileExist (d </> "controller")) $
    941 		userScriptProperty (User "joey")
    942 			[ "cd " ++ d </> "reactive-banana-automation"
    943 			, "cabal install"
    944 			, "cd " ++ d
    945 			, "make"
    946 			]
    947 		`assume` MadeChange
    948 		`requires` Apt.installed
    949 			[ "ghc", "cabal-install", "make"
    950 			, "libghc-http-types-dev"
    951 			, "libghc-aeson-dev"
    952 			, "libghc-wai-dev"
    953 			, "libghc-warp-dev"
    954 			, "libghc-http-client-dev"
    955 			, "libghc-http-client-tls-dev"
    956 			, "libghc-reactive-banana-dev"
    957 			, "libghc-hinotify-dev"
    958 			]
    959 	pollerservicename = "house-poller"
    960 	pollerservicefile = "/etc/systemd/system/" ++ pollerservicename ++ ".service"
    961 	pollerserviceinstalled = pollerservicefile `File.hasContent`
    962 		[ "[Unit]"
    963 		, "Description=house poller"
    964 		, ""
    965 		, "[Service]"
    966 		, "ExecStart=" ++ d ++ "/poller"
    967 		, "WorkingDirectory=" ++ d
    968 		, "User=joey"
    969 		, "Group=joey"
    970 		, "Restart=always"
    971 		, ""
    972 		, "[Install]"
    973 		, "WantedBy=multi-user.target"
    974 		, "WantedBy=house-controller.target"
    975 		]
    976 	controllerservicename = "house-controller"
    977 	controllerservicefile = "/etc/systemd/system/" ++ controllerservicename ++ ".service"
    978 	controllerserviceinstalled = controllerservicefile `File.hasContent`
    979 		[ "[Unit]"
    980 		, "Description=house controller"
    981 		, ""
    982 		, "[Service]"
    983 		, "ExecStart=" ++ d ++ "/controller"
    984 		, "WorkingDirectory=" ++ d
    985 		, "User=joey"
    986 		, "Group=joey"
    987 		, "Restart=always"
    988 		, ""
    989 		, "[Install]"
    990 		, "WantedBy=multi-user.target"
    991 		]
    992 	watchdogservicename = "house-watchdog"
    993 	watchdogservicefile = "/etc/systemd/system/" ++ watchdogservicename ++ ".service"
    994 	watchdogserviceinstalled = watchdogservicefile `File.hasContent`
    995 		[ "[Unit]"
    996 		, "Description=house watchdog"
    997 		, ""
    998 		, "[Service]"
    999 		, "ExecStart=" ++ d ++ "/watchdog"
   1000 		, "WorkingDirectory=" ++ d
   1001 		, "User=root"
   1002 		, "Group=root"
   1003 		, "Restart=always"
   1004 		, ""
   1005 		, "[Install]"
   1006 		, "WantedBy=multi-user.target"
   1007 		]
   1008 	setupservicename = "house-setup"
   1009 	setupservicefile = "/etc/systemd/system/" ++ setupservicename ++ ".service"
   1010 	setupserviceinstalled = setupservicefile `File.hasContent`
   1011 		[ "[Unit]"
   1012 		, "Description=house setup"
   1013 		, ""
   1014 		, "[Service]"
   1015 		, "ExecStart=" ++ d ++ "/setup"
   1016 		, "WorkingDirectory=" ++ d
   1017 		, "User=root"
   1018 		, "Group=root"
   1019 		, "Type=oneshot"
   1020 		, ""
   1021 		, "[Install]"
   1022 		, "WantedBy=multi-user.target"
   1023 		, "WantedBy=house-poller.target"
   1024 		, "WantedBy=house-controller.target"
   1025 		, "WantedBy=house-watchdog.target"
   1026 		]
   1027 	-- Any changes to the rsync command will need my .authorized_keys
   1028 	-- rsync server command to be updated too.
   1029 	rsynccommand = "rsync -e 'ssh -i" ++ sshkeyfile ++ "' -avz rrds/ joey@kitenet.net:/srv/web/house.joeyh.name/rrds/ >/dev/null 2>&1"
   1030 
   1031 	websitesymlink :: Property UnixLike
   1032 	websitesymlink = check (not . isSymbolicLink <$> getSymbolicLinkStatus "/var/www/html")
   1033 		(property "website symlink" $ makeChange $ do
   1034 			removeDirectoryRecursive "/var/www/html"
   1035 			createSymbolicLink d "/var/www/html"
   1036 		)
   1037 
   1038 homerouterWifiInterfaceOld :: String
   1039 homerouterWifiInterfaceOld = "wlx00c0ca82eb78" -- thinkpenguin wifi adapter
   1040 
   1041 homerouterWifiInterface :: String
   1042 homerouterWifiInterface = "wlx7cdd90400448" -- small wifi dongle
   1043 
   1044 -- My home router, running hostapd and dnsmasq,
   1045 -- with eth0 connected to a satellite modem, and a fallback ppp connection.
   1046 homeRouter :: Property (HasInfo + DebianLike)
   1047 homeRouter = propertyList "home router" $ props
   1048 	& File.notPresent (Network.interfaceDFile homerouterWifiInterfaceOld)
   1049 	& Network.static homerouterWifiInterface (IPv4 "10.1.1.1") Nothing
   1050 		`requires` Network.cleanInterfacesFile
   1051 	& Apt.installed ["hostapd"]
   1052 	& File.hasContent "/etc/hostapd/hostapd.conf"
   1053 			[ "interface=" ++ homerouterWifiInterface
   1054 			, "ssid=house"
   1055 			, "hw_mode=g"
   1056 			, "channel=8"
   1057 			]
   1058 		`requires` File.dirExists "/etc/hostapd"
   1059 		`requires` File.hasContent "/etc/default/hostapd"
   1060 			[ "DAEMON_CONF=/etc/hostapd/hostapd.conf" ]
   1061 		`onChange` Service.running "hostapd"
   1062 	& Systemd.enabled "hostapd"
   1063 	& File.hasContent "/etc/resolv.conf"
   1064 		[ "domain kitenet.net"
   1065 		, "search kitenet.net"
   1066 		, "nameserver 8.8.8.8"
   1067 		, "nameserver 8.8.4.4"
   1068 		]
   1069 	& Apt.installed ["dnsmasq"]
   1070 	& File.hasContent "/etc/dnsmasq.conf"
   1071 		[ "domain-needed"
   1072 		, "bogus-priv"
   1073 		, "interface=" ++ homerouterWifiInterface
   1074 		, "interface=eth0"
   1075 		, "domain=kitenet.net"
   1076 		-- lease time is short because the house
   1077 		-- controller wants to know when clients disconnect
   1078 		, "dhcp-range=10.1.1.100,10.1.1.150,10m"
   1079 		, "no-hosts"
   1080 		, "address=/honeybee.kitenet.net/10.1.1.1"
   1081 		, "address=/house.kitenet.net/10.1.1.1"
   1082 		, "dhcp-host=0c:98:38:80:6a:f9,10.1.1.134,android-kodama"
   1083 		]
   1084 		`onChange` Service.restarted "dnsmasq"
   1085 	& ipmasq homerouterWifiInterface
   1086 	-- Used to bring down eth0 when satellite is off, which causes ppp
   1087 	-- to start, but I am not using this currently.
   1088 	& Apt.removed ["netplug"]
   1089 	& Network.static' "eth0" (IPv4 "192.168.1.100")
   1090 		(Just (Network.Gateway (IPv4 "192.168.1.1")))
   1091 		-- When satellite is down, fall back to dialup
   1092 		[ ("pre-up", "poff -a || true")
   1093 		, ("post-down", "pon")
   1094 		]
   1095 		`requires` Network.cleanInterfacesFile
   1096 	& Apt.installed ["ppp"]
   1097 		`before` File.hasContent "/etc/ppp/peers/provider"
   1098 			[ "user \"joeyh@arczip.com\""
   1099 			, "connect \"/usr/sbin/chat -v -f /etc/chatscripts/pap -T 3825441\""
   1100 			, "/dev/ttyACM0"
   1101 			, "115200"
   1102 			, "noipdefault"
   1103 			, "defaultroute"
   1104 			, "persist"
   1105 			, "noauth"
   1106 			]
   1107 		`before` File.hasPrivContent "/etc/ppp/pap-secrets" (Context "joeyh@arczip.com")
   1108 
   1109 -- | Enable IP masqerading, on whatever other interfaces come up, besides the
   1110 -- provided intif.
   1111 ipmasq :: String -> Property DebianLike
   1112 ipmasq intif = File.hasContent ifupscript
   1113 	[ "#!/bin/sh"
   1114 	, "INTIF=" ++ intif
   1115 	, "if [ \"$IFACE\" = $INTIF ] || [ \"$IFACE\" = lo ]; then"
   1116 	, "exit 0"
   1117 	, "fi"
   1118 	, "iptables -F"
   1119 	, "iptables -A FORWARD -i $IFACE -o $INTIF -m state --state ESTABLISHED,RELATED -j ACCEPT"
   1120 	, "iptables -A FORWARD -i $INTIF -o $IFACE -j ACCEPT"
   1121 	, "iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE"
   1122 	, "echo 1 > /proc/sys/net/ipv4/ip_forward"
   1123 	]
   1124 	`before` scriptmode ifupscript
   1125 	`before` File.dirExists (takeDirectory pppupscript)
   1126 	`before` File.hasContent pppupscript
   1127 		[ "#!/bin/sh"
   1128 		, "IFACE=$PPP_IFACE " ++ ifupscript
   1129 		]
   1130 	`before` scriptmode pppupscript
   1131 	`requires` Apt.installed ["iptables"]
   1132   where
   1133 	ifupscript = "/etc/network/if-up.d/ipmasq"
   1134 	pppupscript = "/etc/ppp/ip-up.d/ipmasq"
   1135 	scriptmode f = f `File.mode` combineModes (readModes ++ executeModes)
   1136 
   1137 laptopSoftware :: Property DebianLike
   1138 laptopSoftware = Apt.installed
   1139 	[ "intel-microcode", "acpi"
   1140 	, "procmeter3", "xfce4", "procmeter3", "unclutter"
   1141 	, "mplayer", "fbreader", "firefox", "chromium"
   1142 	, "libdatetime-event-sunrise-perl", "libtime-duration-perl"
   1143 	, "network-manager", "network-manager-openvpn-gnome", "openvpn"
   1144 	, "gtk-redshift", "powertop"
   1145 	, "gimp", "gthumb", "inkscape", "sozi", "xzgv", "hugin"
   1146 	, "mpc", "mpd", "ncmpc", "sonata", "mpdtoys"
   1147 	, "bsdgames", "nethack-console"
   1148 	, "xmonad", "libghc-xmonad-dev", "libghc-xmonad-contrib-dev"
   1149 	, "ttf-bitstream-vera", "fonts-symbola", "fonts-noto-color-emoji"
   1150 	, "mairix", "offlineimap", "mutt", "slrn"
   1151 	, "mtr", "nmap", "whois", "wireshark", "tcpdump", "iftop"
   1152 	, "pmount", "tree", "pv"
   1153 	, "arbtt", "hledger", "bc"
   1154 	, "apache2", "ikiwiki", "libhighlight-perl"
   1155 	, "avahi-daemon", "avahi-discover"
   1156 	, "pal"
   1157 	, "yeahconsole", "xkbset", "xinput"
   1158 	, "assword", "pumpa"
   1159 	, "vorbis-tools", "audacity"
   1160 	, "ekiga"
   1161 	, "bluez-firmware", "blueman", "pulseaudio-module-bluetooth"
   1162 	, "xul-ext-ublock-origin", "xul-ext-pdf.js", "xul-ext-status4evar"
   1163 	, "vim-syntastic", "vim-fugitive"
   1164 	, "adb", "gthumb"
   1165 	, "w3m", "sm", "weechat"
   1166 	, "borgbackup", "wipe", "smartmontools", "libgfshare-bin"
   1167 	, "units"
   1168 	, "virtualbox", "virtualbox-guest-additions-iso", "qemu-kvm"
   1169 	]
   1170 	`requires` baseSoftware
   1171 	`requires` devSoftware
   1172 
   1173 baseSoftware :: Property DebianLike
   1174 baseSoftware = Apt.installed
   1175 	[ "bash", "bash-completion", "vim", "screen", "less", "moreutils"
   1176 	, "git", "mr", "etckeeper", "git-annex", "ssh", "vim-vimoutliner"
   1177 	]
   1178 
   1179 devSoftware :: Property DebianLike
   1180 devSoftware = Apt.installed
   1181 	[ "build-essential", "debhelper", "devscripts"
   1182 	, "ghc", "cabal-install", "haskell-stack"
   1183 	, "hothasktags", "hdevtools", "hlint"
   1184 	, "gdb", "time"
   1185 	, "dpkg-repack", "lintian"
   1186 	, "pristine-tar", "github-backup"
   1187 	]
   1188 
   1189 cubieTruckOneWire :: Property DebianLike
   1190 cubieTruckOneWire = utilitysetup
   1191 	`requires` dtsinstalled
   1192 	`requires` utilityinstalled
   1193   where
   1194 	dtsinstalled = File.hasContent "/etc/easy-peasy-devicetree-squeezy/my.dts" mydts
   1195 		`requires` File.dirExists "/etc/easy-peasy-devicetree-squeezy"
   1196 	utilityinstalled = Git.cloned (User "root") "https://git.joeyh.name/git/easy-peasy-devicetree-squeezy.git" "/usr/local/easy-peasy-devicetree-squeezy" Nothing
   1197 		`onChange` File.isSymlinkedTo "/usr/local/bin/easy-peasy-devicetree-squeezy" (File.LinkTarget "/usr/local/easy-peasy-devicetree-squeezy/easy-peasy-devicetree-squeezy")
   1198 		`requires` Apt.installed ["pv", "device-tree-compiler", "cpp", "linux-source"]
   1199 	utilitysetup = check (not <$> doesFileExist dtb) $ 
   1200 		cmdProperty "easy-peasy-devicetree-squeezy"
   1201 			["--debian", "sun7i-a20-cubietruck"]
   1202 			`assume` MadeChange
   1203 	dtb = "/etc/flash-kernel/dtbs/sun7i-a20-cubietruck.dtb"
   1204 	mydts =
   1205 		[ "/* Device tree addition enabling onewire sensors on CubieTruck GPIO pin PC21 */"
   1206 		, "#include <dt-bindings/gpio/gpio.h>"
   1207 		, ""
   1208 		, "/ {"
   1209 		, "\tonewire_device {"
   1210 		, "\t\tcompatible = \"w1-gpio\";"
   1211 		, "\t\tgpios = <&pio 2 21 GPIO_ACTIVE_HIGH>; /* PC21 */"
   1212 		, "\t\tpinctrl-names = \"default\";"
   1213 		, "\t\tpinctrl-0 = <&my_w1_pin>;"
   1214 		, "\t};"
   1215 		, "};"
   1216 		, ""
   1217 		, "&pio {"
   1218 		, "\tmy_w1_pin: my_w1_pin@0 {"
   1219 		, "\t\tallwinner,pins = \"PC21\";"
   1220 		, "\t\tallwinner,function = \"gpio_in\";"
   1221 		, "\t};"
   1222 		, "};"
   1223 		]
   1224 
   1225 -- My home networked attached storage server.
   1226 homeNAS :: Property DebianLike
   1227 homeNAS = propertyList "home NAS" $ props
   1228 	& Apt.installed ["uhubctl"]
   1229 	& "/etc/udev/rules.d/52-startech-hub.rules" `File.hasContent`
   1230 		[ "# let users power control startech hub with uhubctl"
   1231 		, "ATTR{idVendor}==\"" ++ hubvendor ++ "\", ATTR{idProduct}==\"005a\", MODE=\"0666\""
   1232 		]
   1233 	& autoMountDrive "archive-10" (USBHubPort hubvendor 1) (Just "archive-older")
   1234 	& autoMountDrive "archive-11" (USBHubPort hubvendor 2) (Just "archive-old")
   1235 	& autoMountDrive "archive-12" (USBHubPort hubvendor 3) (Just "archive")
   1236 	& autoMountDrive "passport" (USBHubPort hubvendor 4) Nothing
   1237 	& Apt.installed ["git-annex", "borgbackup"]
   1238   where
   1239 	hubvendor = "0409"
   1240 
   1241 data USBHubPort = USBHubPort String Int
   1242 
   1243 -- Makes a USB drive with the given label automount, and unmount after idle
   1244 -- for a while.
   1245 --
   1246 -- The hub port is turned on and off automatically as needed, using
   1247 -- uhubctl.
   1248 autoMountDrive :: Mount.Label -> USBHubPort -> Maybe FilePath -> Property DebianLike
   1249 autoMountDrive label (USBHubPort hubvendor port) malias = propertyList desc $ props
   1250 	& File.ownerGroup mountpoint (User "joey") (Group "joey")
   1251 		`requires` File.dirExists mountpoint
   1252 	& case malias of
   1253 		Just t -> ("/media/joey/" ++ t) `File.isSymlinkedTo`
   1254 			File.LinkTarget mountpoint
   1255 		Nothing -> doNothing <!> doNothing
   1256 	& File.hasContent ("/etc/systemd/system/" ++ mount)
   1257 		[ "[Unit]"
   1258 		, "Description=" ++ label
   1259 		, "Requires=" ++ hub
   1260 		, "After=" ++ hub
   1261 		, "[Mount]"
   1262 		-- avoid mounting whenever the block device is available,
   1263 		-- only want to automount on demand
   1264 		, "Options=noauto"
   1265 		, "What=" ++ devfile
   1266 		, "Where=" ++ mountpoint
   1267 		, "[Install]"
   1268 		, "WantedBy="
   1269 		]
   1270 		`onChange` Systemd.daemonReloaded
   1271 	& File.hasContent ("/etc/systemd/system/" ++ hub)
   1272 		[ "[Unit]"
   1273 		, "Description=Startech usb hub port " ++ show port
   1274 		, "PartOf=" ++ mount
   1275 		, "[Service]"
   1276 		, "Type=oneshot"
   1277 		, "RemainAfterExit=true"
   1278 		, "ExecStart=/usr/sbin/uhubctl -a on -p " ++ show port ++ " --vendor " ++ hubvendor 
   1279 		, "ExecStop=/bin/sh -c 'uhubctl -a off -p " ++ show port ++ " --vendor " ++ hubvendor
   1280 			-- Powering off the port does not remove device
   1281 			-- files, so ask udev to remove the devfile; it will
   1282 			-- be added back after the drive next spins up
   1283 			-- and so avoid mount happening before the drive is
   1284 			-- spun up.
   1285 			-- (This only works when the devfile is in
   1286 			-- by-label.)
   1287 			++ "; udevadm trigger --action=remove " ++ devfile ++ " || true'"
   1288 		, "[Install]"
   1289 		, "WantedBy="
   1290 		]
   1291 		`onChange` Systemd.daemonReloaded
   1292 	& File.hasContent ("/etc/systemd/system/" ++ automount)
   1293 		[ "[Unit]"
   1294 		, "Description=Automount " ++ label
   1295 		, "[Automount]"
   1296 		, "Where=" ++ mountpoint
   1297 		, "TimeoutIdleSec=300"
   1298 		, "[Install]"
   1299 		, "WantedBy=multi-user.target"
   1300 		]
   1301 		`onChange` Systemd.daemonReloaded
   1302 	& Systemd.enabled automount
   1303 	& Systemd.started automount
   1304 	& Sudo.sudoersDFile ("automount-" ++ label)
   1305 		[ "joey ALL= NOPASSWD: " ++ sudocommands
   1306 		]
   1307   where
   1308 	devfile = "/dev/disk/by-label/" ++ label
   1309 	mountpoint = "/media/joey/" ++ label
   1310 	desc = "auto mount " ++ mountpoint
   1311 	hub = "startech-hub-port-" ++ show port ++ ".service"
   1312 	automount = svcbase ++ ".automount"
   1313 	mount = svcbase ++ ".mount"
   1314 	svcbase = Systemd.escapePath mountpoint
   1315 	sudocommands = intercalate " , " $ map (\c -> "/bin/systemctl " ++ c)
   1316 		[ "stop " ++ mountpoint
   1317 		, "start " ++ mountpoint
   1318 		]
   1319 
   1320 rsyncNetBorgRepo :: String -> [Borg.BorgRepoOpt] -> Borg.BorgRepo
   1321 rsyncNetBorgRepo d os = Borg.BorgRepoUsing os' ("2318@usw-s002.rsync.net:" ++ d)
   1322   where
   1323 	-- rsync.net has a newer borg here
   1324 	os' = Borg.UsesEnvVar ("BORG_REMOTE_PATH", "borg1") : os
   1325 
   1326 noExim :: Property DebianLike
   1327 noExim = Apt.removed ["exim4", "exim4-base", "exim4-daemon-light"]
   1328 	`onChange` Apt.autoRemove