├── .gitignore ├── LICENSE ├── README.md └── tailgraft.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Tailscale Community 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailgraft 2 | 3 | Graft Tailscale onto your Raspberry Pi's SD card and join it to your tailnet automatically on its very first boot. More background and details in the [blog post announcing this project][blog]. 4 | 5 | ## Usage 6 | 7 | This script is designed to be run after you've flashed Ubuntu onto an SD card but before you've booted it in a Raspberry Pi for the first time. It assumes you are using Linux or macOS and have `python3` installed but otherwise has no external dependencies. 8 | 9 | 1. Clone this repository or download the `tailgraft.py` script. 10 | 1. Once the operating system is flashed, run the script with `sudo python3 tailgraft.py`. 11 | 1. Answer the prompts to configure Tailscale on your Raspberry Pi. One of the prompts will request an auth key, which you can generate from your [Tailscale admin console](https://login.tailscale.com/admin/settings/keys). 12 | 13 | When your Rasbperry Pi boots up, you should see it in your Admin console's [**Machines**](https://login.tailscale.com/admin/machines) page and you should be able to use to [Tailscale SSH](https://tailscale.com/tailscale-ssh/) to connect to it. 14 | 15 | ``` 16 | tailscale ssh ubuntu@ 17 | ``` 18 | 19 | Depending on your ACL configuration, you may be prompted to authenticate with Tailscale. 20 | 21 | ## How it works 22 | 23 | More details about how this script uses `cloud-init` can be found in the [companion blog post][blog]. 24 | 25 | [blog]: https://tailscale.dev/blog/tailgraft 26 | 27 | ## Contributing 28 | 29 | Issues and pull requests welcome! 30 | -------------------------------------------------------------------------------- /tailgraft.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import os 5 | import sys 6 | 7 | 8 | flags = [ 9 | "tailscale", 10 | "up", 11 | "--ssh", 12 | ] 13 | hostname = "" 14 | 15 | 16 | def lsblk_linux(): 17 | return json.loads(os.popen("lsblk --json").read()) 18 | 19 | 20 | def find_user_data(): 21 | if sys.platform == 'darwin': 22 | for root, dirs, files in os.walk('/Volumes'): 23 | for dir in dirs: 24 | if os.path.isfile(os.path.join(root, dir, 'user-data')): 25 | return os.path.join(root, dir, 'user-data') 26 | elif sys.platform == 'linux': 27 | devices = lsblk_linux()['blockdevices'] 28 | 29 | for dev in devices: 30 | if dev["mountpoint"] is not None and os.path.isfile(os.path.join(dev["mountpoint"], 'user-data')): 31 | return os.path.join(dev["mountpoint"], 'user-data') 32 | 33 | if dev["children"] is not None: 34 | for child in dev["children"]: 35 | if child["mountpoint"] is not None and os.path.isfile(os.path.join(child["mountpoint"], 'user-data')): 36 | return os.path.join(child["mountpoint"], 'user-data') 37 | 38 | for root, dirs, files in os.walk('/Volumes'): 39 | for dir in dirs: 40 | if os.path.isfile(os.path.join(root, dir, 'user-data')): 41 | return os.path.join(root, dir, 'user-data') 42 | return None 43 | 44 | 45 | def prompt_user(prompt, allowed_replies = []): 46 | while True: 47 | reply = input(prompt) 48 | if allowed_replies != [] and reply in allowed_replies: 49 | return reply 50 | else: 51 | print("Invalid reply. Please try again.") 52 | 53 | 54 | def check_root(): 55 | if os.geteuid() != 0: 56 | print("This script must be run as root. Re-executing with sudo...") 57 | os.execvp('sudo', ['sudo', 'python3'] + sys.argv) 58 | 59 | 60 | def main(): 61 | check_root() 62 | user_data_fname = find_user_data() 63 | if user_data_fname is None: 64 | print("Could not find user-data file. Please try removing your SD card and re-inserting it.") 65 | sys.exit(1) 66 | 67 | print("Found user-data file at {}".format(user_data_fname)) 68 | 69 | be_exit_node = prompt_user("Would you like this device to be an exit node? (y/n): ", ['y', 'n']) == 'y' 70 | if be_exit_node: 71 | flags.append("--advertise-exit-node") 72 | print("This device will be an exit node.") 73 | 74 | authkey = input("Please enter your Tailscale authkey: ") 75 | flags.append("--authkey={}".format(authkey)) 76 | 77 | hostname = input("Please enter a hostname for this device: ") 78 | if hostname != "": 79 | flags.append("--hostname={}".format(hostname)) 80 | 81 | print("Adding Tailscale to user-data file...") 82 | 83 | with open(user_data_fname, 'a') as f: 84 | f.write("runcmd:\n") 85 | f.write(""" - [ "sh", "-c", "curl -fsSL https://tailscale.com/install.sh | sh" ]""") 86 | f.write("\n") 87 | f.write(""" - [ "sh", "-c", "echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf && echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf && sudo sysctl -p /etc/sysctl.d/99-tailscale.conf" ]""") 88 | f.write("\n") 89 | f.write(" - {}\n".format(json.dumps(flags))) 90 | if hostname != "": 91 | f.write(""" - [ "sh", "-c", "sudo hostnamectl hostname {}" ]""".format(hostname)) 92 | f.write("\n") 93 | 94 | print("Tailscale will be installed on boot. Please eject your SD card and boot your raspi.") 95 | print("Good luck!") 96 | 97 | 98 | if __name__ == "__main__": 99 | main() --------------------------------------------------------------------------------