1#!/usr/bin/python
2import glob
3import json
4import os
5import plistlib
6import pwd
7import re
8import shutil
9import struct
10import subprocess
11import sys
12import time
13
14
15SCRIPT = "/Library/Scripts/snowglobe-provision.py"
16STATE = "/var/db/snowglobe"
17
18
19def write(path, data, mode=0o600):
20 with open(path, "wb") as outgoing:
21 outgoing.write(data)
22 os.chmod(path, mode)
23
24
25def autologin(username, password):
26 # loginwindow requires a NUL terminator and 12-byte blocks.
27 key = [125, 137, 82, 35, 210, 188, 221, 234, 163, 185, 31]
28 data = bytearray(password.encode("ascii"))
29 data.extend([0] * (12 - len(data) % 12))
30 encoded = [value ^ key[index % len(key)] for index, value in enumerate(data)]
31 write("/etc/kcpassword", struct.pack("%dB" % len(encoded), *encoded))
32 subprocess.check_call(["/usr/bin/defaults", "write", "/Library/Preferences/com.apple.loginwindow", "autoLoginUser", username])
33 os.chmod("/Library/Preferences/com.apple.loginwindow.plist", 0o644)
34
35
36def main():
37 os.umask(0o077)
38 if sys.argv[1:] == ["welcome"]:
39 notice = os.path.expanduser("~/VM Login.txt")
40 if os.path.isfile(notice):
41 subprocess.check_call(["/usr/bin/open", "-a", "TextEdit", notice])
42 return
43 if sys.argv[1:] == ["keychain"]:
44 password = sys.stdin.read()
45 keychain = os.path.expanduser("~/Library/Keychains/login.keychain")
46 security = ["/usr/bin/security"]
47 with open("/dev/null", "wb") as quiet:
48 if os.path.isfile(keychain):
49 subprocess.check_call(security + ["lock-keychain", keychain], stdout=quiet, stderr=quiet)
50 if subprocess.call(security + ["unlock-keychain", "-p", password, keychain], stdout=quiet, stderr=quiet):
51 if subprocess.call(security + ["set-keychain-password", "-o", "password", "-p", password, keychain], stdout=quiet, stderr=quiet):
52 raise ValueError("The guest login keychain password could not be updated.")
53 elif subprocess.call(security + ["create-keychain", "-p", password, keychain], stdout=quiet, stderr=quiet):
54 raise ValueError("The guest login keychain could not be created.")
55 if subprocess.call(security + ["unlock-keychain", "-p", password, keychain], stdout=quiet, stderr=quiet):
56 raise ValueError("The guest login keychain could not be unlocked.")
57 subprocess.check_call(security + ["default-keychain", "-s", keychain])
58 return
59 if os.geteuid() != 0:
60 raise ValueError("Run guest provisioning as root.")
61 version = subprocess.Popen([
62 "/usr/bin/sw_vers", "-productVersion",
63 ], stdout=subprocess.PIPE).communicate()[0].strip().split(".")[:2]
64 if sys.argv[1:] == ["install"]:
65 shutil.copyfile(__file__, SCRIPT)
66 os.chmod(SCRIPT, 0o755)
67 if not os.path.isdir(STATE):
68 os.mkdir(STATE, 0o700)
69 plistlib.writePlist({
70 "Label": "net.paperclover.snowglobe.provision",
71 "ProgramArguments": ["/usr/bin/python", SCRIPT],
72 "RunAtLoad": True,
73 "KeepAlive": {"SuccessfulExit": False},
74 "ThrottleInterval": 10,
75 "StandardErrorPath": STATE + "/provision.log",
76 }, "/Library/LaunchDaemons/net.paperclover.snowglobe.provision.plist")
77 os.chmod("/Library/LaunchDaemons/net.paperclover.snowglobe.provision.plist", 0o644)
78 if version == ["10", "6"]:
79 path = "/System/Library/Extensions/IOHIDFamily.kext/Contents/PlugIns/IOHIDEventDriverSafeBoot.kext/Contents/Info.plist"
80 driver = plistlib.readPlist(path)
81 personalities = driver["IOKitPersonalities"]
82 tablet = dict(next(value for value in personalities.values() if value["IOClass"] == "IOHIDEventDriver" and "VendorID" not in value))
83 # QEMU tablet coordinates cover the full display without Snow Leopard's 15% border.
84 tablet.update(VendorID=1575, ProductID=1, IOProbeScore=1000, AbsoluteAxisBoundsRemovalPercentage=0)
85 personalities["QEMU USB Tablet"] = tablet
86 plistlib.writePlist(driver, path)
87 subprocess.check_call(["/usr/bin/touch", "/System/Library/Extensions"])
88 subprocess.check_call(["/usr/sbin/kextcache", "-u", "/"])
89 autologin("sandbox", "password")
90 return
91 if sys.argv[1:]:
92 raise ValueError("Use install or welcome, or omit the argument to provision.")
93 if os.path.isfile(STATE + "/prepared"):
94 return
95 seed = "/Volumes/SNOWGLOBE"
96 while not os.path.isfile(seed + "/access.json"):
97 if not os.path.isdir(seed):
98 os.mkdir(seed, 0o700)
99 # Snow Leopard's diskutil plist discovery crashes on the emulated optical drive.
100 with open("/dev/null", "wb") as quiet:
101 for device in glob.glob("/dev/disk[0-9]*"):
102 if subprocess.call(["/sbin/mount", "-t", "cd9660", "-o", "rdonly", device, seed], stdout=quiet, stderr=quiet):
103 continue
104 if os.path.isfile(seed + "/access.json"):
105 break
106 subprocess.check_call(["/sbin/umount", seed])
107 time.sleep(2)
108 with open(seed + "/access.json") as incoming:
109 access = json.load(incoming)
110 username = access["username"]
111 password = access["password"]
112 hostname = access["hostname"]
113 if (not re.match(r"^[a-z_][a-z0-9_-]{0,31}\Z", username) or username == "root"
114 or not re.match(r"^[A-Za-z0-9_-]{16,64}\Z", password)
115 or not re.match(r"^snowglobe-vm-[a-f0-9]{8}\Z", hostname)):
116 raise ValueError("The guest provisioning account is malformed.")
117 for field in ["ComputerName", "LocalHostName", "HostName"]:
118 subprocess.check_call(["/usr/sbin/scutil", "--set", field, hostname])
119 subprocess.check_call(["/usr/bin/pmset", "-a", "sleep", "0", "displaysleep", "0", "disksleep", "0"])
120 # The login keychain needs the base account's GUI session.
121 while not re.search(r"(?m)^\s*501\s+.*/Dock\s*$", subprocess.Popen([
122 "/bin/ps", "-axo", "uid,comm",
123 ], stdout=subprocess.PIPE).communicate()[0]):
124 time.sleep(2)
125 account = pwd.getpwuid(501)
126 if account.pw_name not in ("sandbox", username):
127 raise ValueError("This preset needs a sandbox account with UID 501.")
128 home = "/Users/" + username
129 if account.pw_name != username:
130 if os.path.exists(home):
131 raise ValueError("The guest account's home already exists.")
132 subprocess.check_call(["/usr/bin/dscl", ".", "-change", "/Users/sandbox", "RecordName", "sandbox", username])
133 if account.pw_dir != home and os.path.isdir(account.pw_dir):
134 os.rename(account.pw_dir, home)
135 record = "/Users/" + username
136 for field, value in [("NFSHomeDirectory", home), ("RealName", username)]:
137 subprocess.check_call(["/usr/bin/dscl", ".", "-create", record, field, value])
138 if subprocess.call(["/usr/bin/dscl", ".", "-passwd", record, password]):
139 raise ValueError("The guest account password could not be set.")
140 if username != "sandbox":
141 groups = plistlib.readPlistFromString(subprocess.Popen([
142 "/usr/bin/dscl", "-plist", ".", "-readall", "/Groups", "RecordName", "GroupMembership",
143 ], stdout=subprocess.PIPE).communicate()[0])
144 for group in groups:
145 if "sandbox" in group.get("dsAttrTypeStandard:GroupMembership", []):
146 subprocess.check_call([
147 "/usr/bin/dscl", ".", "-change", "/Groups/" + group["dsAttrTypeStandard:RecordName"][0],
148 "GroupMembership", "sandbox", username,
149 ])
150 subprocess.check_call(["/usr/sbin/dseditgroup", "-o", "edit", "-a", username, "-t", "user", "admin"])
151 subprocess.check_call(["/usr/bin/dscacheutil", "-flushcache"])
152 keychains = home + "/Library/Keychains"
153 if not os.path.isdir(keychains):
154 os.makedirs(keychains)
155 os.chmod(keychains, 0o700)
156 os.chown(keychains, 501, account.pw_gid)
157 # Keep keychain passwords out of sudo's command log.
158 keychain = subprocess.Popen([
159 "/usr/bin/sudo", "-H", "-u", username, "/usr/bin/python", SCRIPT, "keychain",
160 ], stdin=subprocess.PIPE)
161 keychain.communicate(password.encode("ascii"))
162 if keychain.returncode:
163 raise ValueError("The guest login keychain could not be prepared.")
164 ssh = home + "/.ssh"
165 if not os.path.isdir(ssh):
166 os.mkdir(ssh, 0o700)
167 shutil.copyfile(seed + "/authorized_keys", ssh + "/authorized_keys")
168 os.chmod(ssh + "/authorized_keys", 0o600)
169 os.chown(ssh, 501, account.pw_gid)
170 os.chown(ssh + "/authorized_keys", 501, account.pw_gid)
171 ssh_dir = "/etc/ssh" if os.path.isdir("/etc/ssh") else "/etc"
172 for suffix in ["", ".pub"]:
173 path = ssh_dir + "/ssh_host_rsa_key" + suffix
174 shutil.copyfile(seed + "/ssh_host_rsa_key" + suffix, path)
175 os.chmod(path, 0o644 if suffix else 0o600)
176 config = ("Protocol 2\nHostKey " + ssh_dir + "/ssh_host_rsa_key\n"
177 "PubkeyAuthentication yes\nPasswordAuthentication no\n"
178 "ChallengeResponseAuthentication no\nPermitRootLogin no\n"
179 "AllowUsers " + username + "\nAllowTcpForwarding no\n"
180 "X11Forwarding no\nPermitTunnel no\nUseDNS no\nUsePAM yes\n")
181 write(ssh_dir + "/sshd_config", config.encode("ascii"))
182 subprocess.check_call(["/usr/sbin/sshd", "-t"])
183 subprocess.check_call(["/usr/sbin/systemsetup", "-setremotelogin", "on"])
184 volume = plistlib.readPlistFromString(subprocess.Popen([
185 "/usr/sbin/diskutil", "info", "-plist", "/",
186 ], stdout=subprocess.PIPE).communicate()[0])
187 disk = plistlib.readPlistFromString(subprocess.Popen([
188 "/usr/sbin/diskutil", "info", "-plist", volume["ParentWholeDisk"],
189 ], stdout=subprocess.PIPE).communicate()[0])
190 if disk["TotalSize"] - volume["TotalSize"] > 1024 ** 3:
191 size = "R"
192 if version == ["10", "6"]:
193 limits = subprocess.Popen([
194 "/usr/sbin/diskutil", "resizeVolume", "/", "limits",
195 ], stdout=subprocess.PIPE).communicate()[0]
196 maximum = re.search(r"Maximum size:.*\((\d+) Bytes\)", limits)
197 if not maximum:
198 raise ValueError("The guest filesystem's maximum size could not be read.")
199 size = maximum.group(1) + "B"
200 subprocess.check_call(["/usr/sbin/diskutil", "resizeVolume", "/", size])
201 subprocess.check_call([
202 "/usr/sbin/bless", "--folder", "/System/Library/CoreServices", "--bootefi",
203 ])
204 notice = home + "/VM Login.txt"
205 write(notice, ("VM Login\n\nUsername: " + username + "\nPassword: " + password + "\n").encode("ascii"))
206 os.chown(notice, 501, account.pw_gid)
207 agents = home + "/Library/LaunchAgents"
208 if not os.path.isdir(agents):
209 os.makedirs(agents)
210 agent = agents + "/net.paperclover.snowglobe.welcome.plist"
211 plistlib.writePlist({
212 "Label": "net.paperclover.snowglobe.welcome",
213 "ProgramArguments": ["/usr/bin/python", SCRIPT, "welcome"],
214 "RunAtLoad": True,
215 "KeepAlive": {"SuccessfulExit": False},
216 "ThrottleInterval": 10,
217 }, agent)
218 os.chmod(agent, 0o644)
219 os.chown(agents, 501, account.pw_gid)
220 os.chown(agent, 501, account.pw_gid)
221 autologin(username, password)
222 write(STATE + "/prepared", b"")
223 subprocess.check_call(["/sbin/shutdown", "-r", "now"])
224
225
226if __name__ == "__main__":
227 main()