├── .gitignore ├── README.md ├── defaults └── main.yml ├── handlers └── main.yml ├── library ├── ensure_pi2_oc.py ├── expand_fs.py └── pi_boot_config.py ├── meta └── main.yml ├── tasks ├── camera.yml ├── main.yml ├── security_check.yml └── setup_replace_user.yml └── vars └── main.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | 61 | ### JetBrains template 62 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 63 | 64 | *.iml 65 | 66 | ## Directory-based project format: 67 | .idea/ 68 | # if you remove the above rule, at least ignore the following: 69 | 70 | # User-specific stuff: 71 | # .idea/workspace.xml 72 | # .idea/tasks.xml 73 | # .idea/dictionaries 74 | 75 | # Sensitive or high-churn files: 76 | # .idea/dataSources.ids 77 | # .idea/dataSources.xml 78 | # .idea/sqlDataSources.xml 79 | # .idea/dynamic.xml 80 | # .idea/uiDesigner.xml 81 | 82 | # Gradle: 83 | # .idea/gradle.xml 84 | # .idea/libraries 85 | 86 | # Mongo Explorer plugin: 87 | # .idea/mongoSettings.xml 88 | 89 | ## File-based project format: 90 | *.ipr 91 | *.iws 92 | 93 | ## Plugin-specific files: 94 | 95 | # IntelliJ 96 | out/ 97 | 98 | # mpeltonen/sbt-idea plugin 99 | .idea_modules/ 100 | 101 | # JIRA plugin 102 | atlassian-ide-plugin.xml 103 | 104 | # Crashlytics plugin (for Android Studio and IntelliJ) 105 | com_crashlytics_export_strings.xml 106 | crashlytics.properties 107 | crashlytics-build.properties 108 | 109 | 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | raspi_config 2 | ========= 3 | 4 | [![Ansible Role](https://img.shields.io/ansible/role/30050.svg?style=plastic)](https://galaxy.ansible.com/mikolak-net/raspi-config/) [![Ansible Role](https://img.shields.io/ansible/role/d/30050.svg?style=plastic)](https://galaxy.ansible.com/mikolak-net/raspi-config/) 5 | 6 | A configuration role for Raspbian-based Raspberry Pi machines. Provides the following features: 7 | - exposes and/or emulates those `raspi-config` options that are most relevant to headless servers (see _Rule Variables_), 8 | - allows to add user-specified settings to `/boot/config.txt` via the `raspi_config_other_options` variable, 9 | - warns about leaving the default credentials accessible. 10 | 11 | Requirements 12 | ------------ 13 | 14 | None, other than installing the role itself. To do that, create a `requirements.yml` file next to your playbook with 15 | the following contents: 16 | 17 | ```yaml 18 | - name: mikolak-net.raspi_config 19 | ``` 20 | 21 | **(note the `_` underscore instead of the `-` hyphen in `raspi_config`)** 22 | 23 | and then run: 24 | 25 | ansible-galaxy install -r requirements.yml 26 | 27 | The role and its dependencies should be now installed and ready to reference in your playbook via the name given 28 | in the requirements file. 29 | 30 | _Note:_ you can also install the role directly: 31 | 32 | ansible-galaxy install mikolak-net.raspi_config 33 | 34 | but creating a requirements file is just good practice. 35 | 36 | Role Variables 37 | -------------- 38 | 39 | All user variables reside in `defaults/main.yml`. Currently the following are available: 40 | 41 | ```yaml 42 | # perform full update+upgrade 43 | raspi_config_update_packages: yes 44 | # have the FS fill the SD card 45 | raspi_config_expanded_filesystem: yes 46 | # how much memory should be owned by the GPU (vs RAM) 47 | raspi_config_memory_split_gpu: 16 48 | #currently sets Pi2 OC setting if applicable 49 | raspi_config_ensure_optimal_cpu_params: yes 50 | # set global locale 51 | raspi_config_locale: en_US.UTF-8 52 | # set timezone 53 | raspi_config_timezone: UTC 54 | # list of services that should be restarted if the timezone is changed 55 | raspi_config_timezone_dependent_services: [] 56 | # set hostname 57 | raspi_config_hostname: pi 58 | # ensure camera support is on - CURRENTLY UNVERIFIED 59 | raspi_config_enable_camera: no 60 | # specify whether to fail deployment when user/password is default 61 | # ignored if "raspi_config_replace_user" is set (warning will still display) 62 | raspi_config_fail_on_auth_test: yes 63 | # user to replace the default "pi" user with 64 | # NOTE: if you use this for the first time as "pi", any post_tasks will fail! 65 | raspi_config_replace_user: 66 | name: 67 | path_to_ssh_key: #LOCAL path to your public key file 68 | # wait this many seconds before forcing connection retry after reboot 69 | raspi_config_reboot_max_wait_time: 300 70 | # use this to add any additional options to the config in raw form 71 | raspi_config_other_options: {} 72 | ``` 73 | 74 | 75 | Dependencies 76 | ------------ 77 | See `dependencies` in `meta/main.yml`. 78 | 79 | Example Playbook 80 | ---------------- 81 | 82 | ```yaml 83 | - hosts: pi* 84 | remote_user: pi 85 | become: true 86 | roles: 87 | - role: mikolak-net.raspi_config 88 | raspi_config_replace_user: 89 | name: mainuser 90 | path_to_ssh_key: "~/.ssh/my_pub_key_id_rsa.pub" 91 | ``` 92 | 93 | License 94 | ------- 95 | 96 | BSD 97 | 98 | Author Information 99 | ------------------ 100 | 101 | Issues should be reported on the [project page](https://github.com/mikolak-net/ansible-raspi-config). 102 | 103 | Thanks to: 104 | - [Colin Nolan](https://github.com/colin-nolan) for various contributions, including the reboot handler fix and general support. 105 | - [Erik Berkun-Drevnig](https://github.com/eberkund) for the locale dependency fix. 106 | - [Thomas Redmer](https://github.com/Skorfulose) for the `become` modernization. 107 | -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # perform full update+upgrade 4 | raspi_config_update_packages: true 5 | # have the FS fill the SD card 6 | raspi_config_expanded_filesystem: true 7 | # how much memory should be owned by the GPU (vs RAM) 8 | raspi_config_memory_split_gpu: 16 9 | # currently sets Pi2 OC setting if applicable 10 | raspi_config_ensure_optimal_cpu_params: true 11 | # set global locale 12 | raspi_config_locale: en_US.UTF-8 13 | # set timezone 14 | raspi_config_timezone: UTC 15 | # list of services that should be restarted if the timezone is changed 16 | raspi_config_timezone_dependent_services: [] 17 | # set hostname 18 | raspi_config_hostname: pi 19 | # ensure camera support is on - CURRENTLY UNVERIFIED 20 | raspi_config_enable_camera: false 21 | # specify whether to fail deployment when user/password is default 22 | # ignored if "raspi_config_replace_user" is set (warning will still display) 23 | raspi_config_fail_on_auth_test: true 24 | # user to replace the default "pi" user with 25 | # NOTE: if you use this for the first time as "pi", any post_tasks will fail! 26 | raspi_config_replace_user: 27 | name: 28 | # LOCAL path to your public key file 29 | path_to_ssh_key: 30 | # wait this many seconds before forcing connection retry after reboot 31 | raspi_config_reboot_max_wait_time: 300 32 | # use this to add any additional options to the config in raw form 33 | raspi_config_other_options: {} 34 | -------------------------------------------------------------------------------- /handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: apply raspi-config 4 | command: raspi-config --apply-os-config 5 | 6 | - name: reboot 7 | shell: "sleep 1 && shutdown -r now +1" 8 | async: 1 9 | poll: 0 10 | notify: 11 | - wait for reboot 12 | 13 | - name: wait for reboot 14 | wait_for_connection: 15 | delay: "{{ raspi_config_reboot_min_time }}" 16 | timeout: "{{ raspi_config_reboot_max_wait_time }}" 17 | 18 | - name: remove default user 19 | when: "raspi_config_replace_user['name'] != raspi_config_auth_test_username" 20 | user: name={{ raspi_config_auth_test_username }} state=absent force=yes 21 | async: 0 22 | poll: 0 23 | ignore_errors: true 24 | 25 | - name: restart timezone dependent services 26 | service: 27 | name: "{{ item }}" 28 | state: restarted 29 | with_items: "{{ raspi_config_timezone_dependent_services }}" 30 | -------------------------------------------------------------------------------- /library/ensure_pi2_oc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # Ensures Pi2 have the correct "overclocking" setting 4 | 5 | from ansible.module_utils.basic import * 6 | import re 7 | 8 | # START - common module code - yay for copy-paste 9 | BOOT_CONFIG_PATH = "/boot/config.txt" 10 | 11 | RASPI_CONFIG_BIN = "/usr/bin/raspi-config" 12 | 13 | 14 | class ConfigFile: 15 | 16 | @staticmethod 17 | def __param_string(param, value): 18 | return param+"="+value 19 | 20 | def __init__(self, file_name=BOOT_CONFIG_PATH): 21 | self.is_changed = False 22 | self.file_name = file_name 23 | 24 | with open(self.file_name) as fp: 25 | self.lines = fp.readlines() 26 | 27 | def __find_starting_with(self, searched): 28 | try: 29 | return [x.find(self.__param_string(searched, "")) == 0 for x in self.lines].index(True) 30 | except ValueError: 31 | return -1 32 | 33 | def set(self, param, value): 34 | # search for an uncommented line, and a commented one if that fails 35 | line_num = self.__find_starting_with(param) 36 | if line_num == -1: 37 | line_num = self.__find_starting_with('#'+param) 38 | 39 | # ...and finally just create an empty line 40 | if line_num == -1: 41 | line_num == len(self.lines) 42 | self.lines.append("") 43 | 44 | target_value = self.__param_string(param, value)+"\n" 45 | if self.lines[line_num] != target_value: 46 | self.lines[line_num] = target_value 47 | self.is_changed = True 48 | with open(self.file_name, 'w') as fp: 49 | fp.writelines(self.lines) 50 | return True 51 | else: 52 | return False 53 | # END - common module code - yay for copy-paste 54 | 55 | CPU_INFO_PATH = "/proc/cpuinfo" 56 | 57 | CPU_PI2 = "Pi2" 58 | 59 | CPU_TYPES = "cpu_types" 60 | 61 | CONFIG_OC_REGEXP = re.compile("set_overclock " + CPU_PI2 + " (?P\d+) (?P\d+) (?P\d+) (?P\d+)") 62 | 63 | 64 | def read_oc_params(): 65 | with open(RASPI_CONFIG_BIN) as fp: 66 | oc_config = CONFIG_OC_REGEXP.search(fp.read()).groupdict() 67 | return oc_config 68 | 69 | 70 | def main(): 71 | module = AnsibleModule(argument_spec={ 72 | CPU_TYPES: {"required": True, "type": "dict"} 73 | } 74 | ) 75 | 76 | pi2_cpu = module.params.get(CPU_TYPES)[CPU_PI2] 77 | 78 | is_pi2 = False 79 | 80 | with open(CPU_INFO_PATH) as fp: 81 | is_pi2 = any(x.find(pi2_cpu) > -1 for x in fp.readlines()) 82 | 83 | if is_pi2: 84 | oc_config = read_oc_params() 85 | config_file = ConfigFile() 86 | for (param, value) in list(oc_config.items()): 87 | config_file.set(param, value) 88 | module.exit_json(changed=config_file.is_changed, msg="Is Pi2, ensured optimum CPU params.") 89 | else: 90 | module.exit_json(changed=False, msg="CPU chipset does not appear to be a Pi2 (but you can still custom-OC it by setting 'raspi_config_other_options!).") 91 | 92 | main() -------------------------------------------------------------------------------- /library/expand_fs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # Made because matching the condition in Jinja2 was a royal pain the a** 4 | 5 | from ansible.module_utils.basic import * 6 | 7 | SD_CARD_PARTITION_NAME = "/dev/mmcblk0p2" 8 | 9 | def main(): 10 | module = AnsibleModule(argument_spec=dict()) 11 | 12 | (rc, out, err) = module.run_command(["df", "/"], check_rc=True) 13 | main_partition_name = out.split("\n")[1].split()[0] #first line is header 14 | 15 | if main_partition_name != SD_CARD_PARTITION_NAME: 16 | module.exit_json(changed=False, stderr="WARN: The root partition {} does not appear to be on an SD card, resize via raspi-config will not work. Doing nothing.".format(main_partition_name)) 17 | else: 18 | (rc, out, err) = module.run_command(["sudo", "raspi-config", "--expand-rootfs"], check_rc=True) 19 | 20 | fs_expanded = (lambda x, y: x-y)(*[int(l.split()[2]) for l in out.split("\n") if l.startswith(main_partition_name)]) 21 | module.exit_json(changed=fs_expanded, stdout=out, stderr=err) 22 | 23 | 24 | main() -------------------------------------------------------------------------------- /library/pi_boot_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # Ensures the given config value is set. 4 | 5 | from ansible.module_utils.basic import * 6 | 7 | 8 | # START - common module code - yay for copy-paste 9 | BOOT_CONFIG_PATH = "/boot/config.txt" 10 | 11 | RASPI_CONFIG_BIN = "/usr/bin/raspi-config" 12 | 13 | 14 | class ConfigFile: 15 | 16 | @staticmethod 17 | def __param_string(param, value): 18 | return param+"="+value 19 | 20 | def __init__(self, file_name=BOOT_CONFIG_PATH): 21 | self.is_changed = False 22 | self.file_name = file_name 23 | 24 | with open(self.file_name) as fp: 25 | self.lines = fp.readlines() 26 | 27 | def __find_starting_with(self, searched): 28 | try: 29 | return [x.find(self.__param_string(searched, "")) == 0 for x in self.lines].index(True) 30 | except ValueError: 31 | return -1 32 | 33 | def set(self, param, value): 34 | # search for an uncommented line, and a commented one if that fails 35 | line_num = self.__find_starting_with(param) 36 | if line_num == -1: 37 | line_num = self.__find_starting_with('#'+param) 38 | 39 | # ...and finally just create an empty line 40 | if line_num == -1: 41 | line_num == len(self.lines) 42 | self.lines.append("") 43 | 44 | target_value = self.__param_string(param, value)+"\n" 45 | if self.lines[line_num] != target_value: 46 | self.lines[line_num] = target_value 47 | self.is_changed = True 48 | with open(self.file_name, 'w') as fp: 49 | fp.writelines(self.lines) 50 | return True 51 | else: 52 | return False 53 | # END - common module code - yay for copy-paste 54 | 55 | CONFIG_VALS = "config_vals" 56 | 57 | 58 | def main(): 59 | module = AnsibleModule(argument_spec={ 60 | CONFIG_VALS: {"required": True, "type": "dict"} 61 | } 62 | ) 63 | 64 | config_vals = module.params.get(CONFIG_VALS) 65 | 66 | config = ConfigFile() 67 | 68 | out = "" 69 | err = "" 70 | 71 | # sanitize from auto-typing in YAML 72 | config_vals = dict((str(key), str(val)) for (key, val) in list(config_vals.items())) 73 | 74 | for (key, val) in list(config_vals.items()): 75 | try: 76 | modified = config.set(key, val) 77 | if modified: 78 | out += "\nModified "+key+" to "+val 79 | else: 80 | out += "\n"+key+" was already set to "+val 81 | except Exception as e: 82 | err = "Error when writing config: "+str(e) 83 | module.fail_json(changed=config.is_changed, msg=err, stdout=out, stderr=err) 84 | 85 | module.exit_json(changed=config.is_changed, stdout=out, stderr=err) 86 | 87 | 88 | main() -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | galaxy_info: 4 | author: Mikołaj Koziarkiewicz 5 | description: Sets up basic configuration for a headless Raspberry Pi Raspbian server. 6 | company: mikołak 7 | license: BSD 8 | min_ansible_version: 2.4 9 | platforms: 10 | - name: Raspbian 11 | galaxy_tags: 12 | - development 13 | - system 14 | 15 | dependencies: 16 | - name: "oefenweb.locales" 17 | version: "v1.0.40" 18 | locales_present: 19 | - "{{ raspi_config_locale }}" 20 | locales_default: 21 | lang: 22 | - "{{ raspi_config_locale }}" 23 | 24 | - role: "Stouts.hostname" 25 | version: "1.1.0" 26 | hostname_hostname: "{{ raspi_config_hostname }}" 27 | -------------------------------------------------------------------------------- /tasks/camera.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: camera - ensure memory 4 | pi_boot_config: 5 | config_vals: "gpu_mem=128" 6 | when: raspi_config_enable_camera and raspi_config_memory_split_gpu < raspi_config_min_camera_mem 7 | 8 | - name: camera - set state 9 | pi_boot_config: 10 | config_vals: "start_x={{ raspi_config_enable_camera | int }}" 11 | notify: 12 | - apply raspi-config 13 | - reboot 14 | -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: update all packages 4 | apt: 5 | update_cache: true 6 | upgrade: dist 7 | when: raspi_config_update_packages 8 | 9 | - name: sets the timezone 10 | timezone: 11 | name: "{{ raspi_config_timezone }}" 12 | notify: restart timezone dependent services 13 | 14 | - import_tasks: setup_replace_user.yml 15 | when: raspi_config_replace_user['name'] != None 16 | 17 | - import_tasks: security_check.yml 18 | 19 | - name: ensure filesystem is resized 20 | expand_fs: 21 | when: raspi_config_expanded_filesystem 22 | register: expand_fs_output 23 | 24 | - name: filesystem expand non-action check 25 | debug: 26 | msg: "{{ expand_fs_output.stderr }}" 27 | when: "raspi_config_expanded_filesystem and expand_fs_output.stderr.startswith('WARN')" 28 | changed_when: "True" # for highlighting purposes 29 | 30 | - name: ensure mem split 31 | pi_boot_config: 32 | config_vals: "gpu_mem={{ raspi_config_memory_split_gpu }}" 33 | notify: 34 | - apply raspi-config 35 | - reboot 36 | 37 | - name: ensure correct CPU parameters for Pi2 38 | ensure_pi2_oc: 39 | args: 40 | cpu_types: "{{ raspi_config_pi_cpu }}" 41 | when: raspi_config_ensure_optimal_cpu_params 42 | notify: 43 | - apply raspi-config 44 | - reboot 45 | 46 | - name: set camera state 47 | import_tasks: camera.yml 48 | - name: set additional config vars 49 | pi_boot_config: 50 | args: 51 | config_vals: "{{ raspi_config_other_options }}" 52 | notify: 53 | - apply raspi-config 54 | - reboot 55 | -------------------------------------------------------------------------------- /tasks/security_check.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: ensure utility present 4 | apt: 5 | name: sshpass 6 | state: present 7 | 8 | - name: check for login 9 | command: sshpass -p {{ raspi_config_auth_test_password }} ssh {{ raspi_config_auth_test_username }}@localhost -o NoHostAuthenticationForLocalhost=yes "echo {{ raspi_config_auth_test_string }}" 10 | register: auth_test 11 | changed_when: false 12 | failed_when: false 13 | 14 | - name: optional warning 15 | debug: 16 | msg: "{{ raspi_config_auth_test_fail_msg }}" 17 | when: "raspi_config_auth_test_string == auth_test.stdout" 18 | changed_when: "raspi_config_auth_test_string == auth_test.stdout" # for highlighting purposes 19 | failed_when: "raspi_config_fail_on_auth_test and raspi_config_replace_user['name'] != None" 20 | 21 | - name: additional info 22 | debug: 23 | msg: "{{ raspi_config_auth_test_replace_info }}" 24 | when: "raspi_config_auth_test_string == auth_test.stdout and raspi_config_replace_user['name'] != None" 25 | -------------------------------------------------------------------------------- /tasks/setup_replace_user.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Create user {{ raspi_config_replace_user['name'] }} 4 | user: 5 | name: "{{ raspi_config_replace_user['name'] }}" 6 | changed_when: true # to force handler call 7 | notify: 8 | - remove default user 9 | 10 | - name: Add your login key to {{ raspi_config_replace_user['name'] }} 11 | authorized_key: 12 | user: "{{ raspi_config_replace_user['name'] }}" 13 | key: "{{ lookup('file', raspi_config_replace_user['path_to_ssh_key']) }}" 14 | 15 | - name: Add {{ raspi_config_replace_user['name'] }} to sudoers 16 | lineinfile: 17 | args: 18 | dest: /etc/sudoers 19 | line: "{{ raspi_config_replace_user['name'] }} ALL=(ALL) NOPASSWD: ALL" 20 | -------------------------------------------------------------------------------- /vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | raspi_config_pi_cpu: 4 | Pi1: BCM2708 5 | Pi2: BCM2709 6 | Pi3: BCM2710 7 | Pi4: BCM2835 8 | raspi_config_min_camera_mem: 128 9 | raspi_config_auth_test_string: VULN 10 | raspi_config_auth_test_fail_msg: ABLE TO SSH IN WITH FACTORY CREDENTIALS - ASSUME PWNED IF SSH OPEN TO THE INTERNET 11 | raspi_config_auth_test_replace_info: User "pi" will be replaced by {{ raspi_config_replace_user['name'] }} at the end of role execution 12 | raspi_config_auth_test_username: pi 13 | raspi_config_auth_test_password: raspberry 14 | raspi_config_reboot_min_time: 5 15 | --------------------------------------------------------------------------------