| 1 | #!/usr/bin/python |
| 2 | import glob |
| 3 | import json |
| 4 | import os |
| 5 | import plistlib |
| 6 | import pwd |
| 7 | import re |
| 8 | import shutil |
| 9 | import struct |
| 10 | import subprocess |
| 11 | import sys |
| 12 | import time |
| 13 | |
| 14 | |
| 15 | SCRIPT = "/Library/Scripts/snowglobe-provision.py" |
| 16 | STATE = "/var/db/snowglobe" |
| 17 | |
| 18 | |
| 19 | def write(path, data, mode=0o600): |
| 20 | with open(path, "wb") as outgoing: |
| 21 | outgoing.write(data) |
| 22 | os.chmod(path, mode) |
| 23 | |
| 24 | |
| 25 | def 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 | |
| 36 | def 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 | |
| 226 | if __name__ == "__main__": |
| 227 | main() |