diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | README.md | 65 | ||||
-rw-r--r-- | action_plugins/syncthing_init.py | 79 | ||||
-rw-r--r-- | action_plugins/syncthing_validate.py | 98 | ||||
-rw-r--r-- | files/syncthing.fact | 23 | ||||
-rw-r--r-- | handlers/main.yaml | 4 | ||||
-rw-r--r-- | tasks/main.yaml | 58 | ||||
-rw-r--r-- | templates/config.xml | 120 |
8 files changed, 448 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..40166b2 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# syncthing + +This ansible role handles the installation and configuration of [syncthing](https://syncthing.net/). + +## Introduction + +I wanted a role to install and configure syncthing for me and did not find an existing one that satisfied me. I had a few mandatory features in mind: +- the ability to configure a servers parameters in only one place to avoid repetition +- having a fact that retrieves the ID of a device +- the validation of host_vars which virtually no role in the wild ever does +- the ability to manage an additional inventory file for devices which ansible cannot manage (like my phone) + +## Role variables + +There is a single variable to specify in the `host_vars` of your hosts: `syncthing`. This is a dict that can contain the following keys: +- address: optional string to specify how to connect to the server, must match the format `tcp://<hostname>` or `tcp://<ip>`. Default value is *dynamic* which means a passive host. +- shared: a mandatory dict describing the directories this host shares, which can contain the following keys: + - name: a mandatory string to name the share in the configuration. It must match on all devices that share this folder. + - path: the path of the folder on the device. This can difer on each device sharing this data. + - peers: a list a strings. Each item should be either the ansible_hostname of another device, or a hostname from the `syncthing_data.yaml` file + +Configuring a host through its `host_vars` looks like this: +```yaml +syncthing: + address: tcp://lore.adyxax.org + shared: + - name: org-mode + path: /var/syncthing/org-mode + peers: + - hero + - light + - lumapps + - Pixel 3a +``` + +## The optional syncthing_data.yaml file + +To be found by the `action_plugins`, this file should be in the same folder as your playbook. It shares the same format as the `host_vars` but with additional keys for the hostname and its ID. + +The data file for non ansible devices looks like this: +```yaml +- name: Pixel 3a + id: ABCDEFG-HIJKLMN-OPQRSTU-VWXYZ01-2345678-90ABCDE-FGHIJKL-MNOPQRS + shared: + - name: Music + path: /storage/emulated/0/Music + peers: + - phoenix + - name: Photos + path: /storage/emulated/0/DCIM/Camera + peers: + - phoenix + - name: org-mode + path: /storage/emulated/0/Org + peers: + - lore.adyxax.org +``` + +## Example playbook + +```yaml +- hosts: all + roles: + - { role: syncthing, tags: [ 'syncthing' ], when: "syncthing is defined" } +``` diff --git a/action_plugins/syncthing_init.py b/action_plugins/syncthing_init.py new file mode 100644 index 0000000..eb6cf9f --- /dev/null +++ b/action_plugins/syncthing_init.py @@ -0,0 +1,79 @@ +from ansible.plugins.action import ActionBase +import re +import yaml +from yaml.loader import SafeLoader + +class ActionModule(ActionBase): + def run(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = dict() + result = super(ActionModule, self).run(tmp, task_vars) + result['changed'] = False + result['failed'] = False + + error_msgs = [] + + ### Syncthing variables for non ansible hosts ######################### + peers = {} + with open('syncthing_data.yaml') as f: + data = yaml.load(f, Loader=SafeLoader) + for peer in data: + peers[peer['name']] = peer + if not 'address' in peer.keys(): + peer['address'] = 'dynamic' + + ### Syncthing host vars ############################################### + for hostname, hostvars in task_vars['hostvars'].items() : + if 'syncthing' in hostvars.keys(): + syncthing = hostvars['syncthing'] + peer = { + 'address': 'dynamic', + 'id': '0000000-0000000-0000000-0000000-0000000-0000000-0000000-0000000', + 'shared': [], + } + if 'address' in syncthing.keys(): + peer['address'] = syncthing['address'] + for shared in syncthing['shared']: + peer['shared'].append({ 'name': shared['name'], 'path': shared['path'], 'peers': shared['peers']}) + if 'syncthing' in hostvars['ansible_local']: + peer['id'] = hostvars['ansible_local']['syncthing']['id'] + peers[hostname] = peer + + ### Compiling host configuration ###################################### + config = {} + if task_vars['ansible_host'] in peers.keys(): + myself = peers[task_vars['ansible_host']] + config = { + 'config_path': "", + 'folders_to_create': [], + 'packages': [], + 'peers': {}, + 'service': "syncthing", + 'shared': myself['shared'], + 'user_group': "syncthing", + } + if task_vars['ansible_distribution'] == 'FreeBSD': + config['config_path'] = "/usr/local/etc/syncthing/config.xml" + config['folders_to_create'] = ["/usr/local/etc/syncthing/", "/var/syncthing"] + config['packages'] = ["p5-libwww", "syncthing"] + elif task_vars['ansible_distribution'] == 'Gentoo': + config['config_path'] = "/var/lib/syncthing/.config/syncthing/config.xml" + config['folders_to_create'] = ["/var/lib/syncthing/.config/syncthing"] + config['packages'] = ["net-p2p/syncthing"] + else: + error_msgs.append(f"syncthing role does not support {task_vars['ansible_distribution']} hosts yet") + for shared in myself['shared']: + for peer in shared['peers']: + if not peer in config['peers'].keys(): + config['peers'][peer] = { 'id': peers[peer]['id'], 'address': peers[peer]['address'] } + + ### Results compilation ############################################## + if error_msgs != []: + result['msg'] = ' ; '.join(error_msgs) + result['failed'] = True + + result['ansible_facts'] = { + 'syncthing_config': config, + } + + return result diff --git a/action_plugins/syncthing_validate.py b/action_plugins/syncthing_validate.py new file mode 100644 index 0000000..df2c274 --- /dev/null +++ b/action_plugins/syncthing_validate.py @@ -0,0 +1,98 @@ +from ansible.plugins.action import ActionBase +import re +import yaml +from yaml.loader import SafeLoader + +class ActionModule(ActionBase): + def run(self, tmp=None, task_vars=None): + if task_vars is None: + task_vars = dict() + result = super(ActionModule, self).run(tmp, task_vars) + result['changed'] = False + result['failed'] = False + + error_msgs = [] + + ### syncthing_data.yaml file validation ############################### + peers = {} + with open('syncthing_data.yaml') as f: + data = yaml.load(f, Loader=SafeLoader) + if not isinstance(data, list): + error_msgs.append(f"The syncthing_data.yaml file must contain a list") + else: + for peer in data: + if not isinstance(peer, dict): + error_msgs.append(f"syncthing_data.yaml must contain a list of dicts") + else: + for key in peer.keys(): + if not key in ['address', 'id', 'name', 'shared']: + error_msgs.append(f"Invalid key {key} in dict for syncthing_data.yaml entry") + if 'address' in peer.keys(): + if not isinstance(peer['address'], str): + error_msgs.append(f"Invalid address in syncthing_data.yaml, must be of type string") + elif re.match('^tcp://', peer['address']) == None: + error_msgs.append(f"Invalid address in syncthing_data.yaml, must be of format tcp://<hostname or ip address>/") + if 'id' in peer.keys(): + if not isinstance(peer['id'], str): + error_msgs.append(f"Invalid id in syncthing_data.yaml, must be of type string") + elif re.match('^(?:[A-Z0-9]{7}-){7}[A-Z0-9]{7}$', peer['id']) == None: + error_msgs.append(f"Invalid id in syncthing_data.yaml, must be of valid") + if 'name' in peer.keys(): + if not isinstance(peer['name'], str): + error_msgs.append(f"Invalid name in syncthing_data.yaml, must be of type string") + elif not re.match('^[A-Za-z0-9\.]+$', peer['name']) == None: + error_msgs.append(f"Invalid name in syncthing_data.yaml, must be name a valid hostname") + if 'shared' in peer.keys(): + # TODO validate shared and populate peers + pass + + ### host_vars validations ############################################# + for hostname, hostvars in task_vars['hostvars'].items() : + if 'syncthing' in hostvars.keys(): + if not isinstance(task_vars['syncthing'], dict): + error_msgs.append(f"The syncthing variable must be of type dict for host {hostname}") + else: + syncthing = hostvars['syncthing'] + for key in syncthing.keys(): + if not key in ['address', 'shared']: + error_msgs.append(f"Invalid key {key} in the syncthing dict for host {hostname}") + peer = { + 'address': 'dynamic', + 'shared': [], + } + if 'address' in syncthing.keys(): + if not isinstance(syncthing['address'], str): + error_msgs.append(f"Invalid address for host {hostname}: must be of type string") + elif re.match('^tcp://', syncthing['address']) == None: + error_msgs.append(f"Invalid address for host {hostname}: must be of format tcp://<hostname or ip address>/") + else: + peer['address'] = syncthing['address'] + if 'shared' not in syncthing.keys(): + error_msgs.append(f"Invalid syncthing entry for host {hostname}: no shared key in dict") + elif not isinstance(syncthing['shared'], list): + error_msgs.append(f"Invalid shared syncthing entry for host {hostname}: must be of type list") + elif len(syncthing['shared']) == 0: + error_msgs.append(f"Invalid shared syncthing entry for host {hostname}: must be a non empty list") + else: + for shared in syncthing['shared']: + if not isinstance(shared, dict): + error_msgs.append(f"Invalid shared syncthing entry for host {hostname}: shared needs to be a dict") + else: + for key in shared.keys(): + if not key in ['name', 'path', 'peers']: + error_msgs.append(f"Invalid key {key} in the shared syncthing array for host {hostname}") + if 'name' not in shared.keys(): + error_msgs.append(f"Invalid shared syncthing entry for host {hostname}: no name key in dict") + elif not isinstance(shared['name'], str): + error_msgs.append(f"Invalid shared name for host {hostname}: must be of type string") + #elif not shared['name'] in task_vars['hostvars']: + # error_msgs.append(f"Invalid shared name for host {hostname}: must be an ansible host, or defined in syncthing_data.yaml") + # TODO keep validating each key + + ### Results compilation ############################################## + if error_msgs != []: + result['msg'] = ' ; '.join(error_msgs) + result['failed'] = True + + return result + diff --git a/files/syncthing.fact b/files/syncthing.fact new file mode 100644 index 0000000..79ea632 --- /dev/null +++ b/files/syncthing.fact @@ -0,0 +1,23 @@ +#!/usr/bin/env perl +############################################################################### +# \_o< WARNING : This file is being managed by ansible! >o_/ # +# ~~~~ ~~~~ # +############################################################################### + +use strict; +use warnings; + +use JSON::PP; +use LWP::UserAgent; + +my $id = '0000000-0000000-0000000-0000000-0000000-0000000-0000000-0000000'; + +my $resp = LWP::UserAgent->new()->head('http://localhost:8384/'); +if ($resp->code == 200) { + $id = $resp->header('X-Syncthing-Id'); +} + +my %output = ( + 'id' => $id, +); +print encode_json \%output; diff --git a/handlers/main.yaml b/handlers/main.yaml new file mode 100644 index 0000000..521381f --- /dev/null +++ b/handlers/main.yaml @@ -0,0 +1,4 @@ +- name: restart syncthing + service: + name: syncthing + state: restarted diff --git a/tasks/main.yaml b/tasks/main.yaml new file mode 100644 index 0000000..984a0cd --- /dev/null +++ b/tasks/main.yaml @@ -0,0 +1,58 @@ +- name: Push syncthing gathering fact on clients + copy: + src: syncthing.fact + dest: /etc/ansible/facts.d/ + mode: 0500 + owner: root + register: syncthing_gathering_fact + +- name: reload ansible_local + setup: filter=ansible_local + when: syncthing_gathering_fact.changed + +#- debug: +# msg: "{{ syncthing_config }}" +# changed_when: true + +- action: syncthing_validate + +- action: syncthing_init + +- name: Install syncthing dependencies + package: + name: "{{ syncthing_config.packages }}" + +- name: enforces service directories + file: + state: directory + path: "{{ item }}" + owner: syncthing + mode: 0700 + loop: "{{ syncthing_config.folders_to_create }}" + +- name: enforces config file + template: + src: config.xml + dest: "{{ syncthing_config.config_path }}" + owner: "{{ syncthing_config.user_group }}" + mode: 0640 + notify: restart syncthing + +- name: start and enables service + service: + name: syncthing + enabled: yes + state: started + +- name: reload ansible_local + setup: filter=ansible_local + when: syncthing_gathering_fact.changed + +- name: enforces config file + template: + src: config.xml + dest: "{{ syncthing_config.config_path }}" + owner: "{{ syncthing_config.user_group }}" + mode: 0640 + when: syncthing_gathering_fact.changed + notify: restart syncthing diff --git a/templates/config.xml b/templates/config.xml new file mode 100644 index 0000000..788dff5 --- /dev/null +++ b/templates/config.xml @@ -0,0 +1,120 @@ +<!--############################################################################### + # \_o< WARNING : This file is being managed by ansible! >o_/ # + # ~~~~ ~~~~ # + ###############################################################################--> +<configuration version="37"> + {% for shared in syncthing_config.shared -%} + <folder id="{{ shared.name }}" label="{{ shared.name }}" path="{{ shared.path }}" type="sendreceive" rescanIntervalS="3600" fsWatcherEnabled="true" fsWatcherDelayS="10" ignorePerms="false" autoNormalize="true"> + <filesystemType>basic</filesystemType> + {% for peer in shared.peers -%} + <device id="{{ syncthing_config.peers[peer].id }}" introducedBy=""> + <encryptionPassword></encryptionPassword> + </device> + {% endfor -%} + <minDiskFree unit="%">1</minDiskFree> + <versioning> + <cleanupIntervalS>3600</cleanupIntervalS> + <fsPath></fsPath> + <fsType>basic</fsType> + </versioning> + <copiers>0</copiers> + <pullerMaxPendingKiB>0</pullerMaxPendingKiB> + <hashers>0</hashers> + <order>newestFirst</order> + <ignoreDelete>false</ignoreDelete> + <scanProgressIntervalS>0</scanProgressIntervalS> + <pullerPauseS>0</pullerPauseS> + <maxConflicts>10</maxConflicts> + <disableSparseFiles>false</disableSparseFiles> + <disableTempIndexes>false</disableTempIndexes> + <paused>false</paused> + <weakHashThresholdPct>25</weakHashThresholdPct> + <markerName>.stfolder</markerName> + <copyOwnershipFromParent>false</copyOwnershipFromParent> + <modTimeWindowS>0</modTimeWindowS> + <maxConcurrentWrites>2</maxConcurrentWrites> + <disableFsync>false</disableFsync> + <blockPullOrder>standard</blockPullOrder> + <copyRangeMethod>standard</copyRangeMethod> + <caseSensitiveFS>false</caseSensitiveFS> + <junctionsAsDirs>false</junctionsAsDirs> + <syncOwnership>false</syncOwnership> + <sendOwnership>false</sendOwnership> + <syncXattrs>false</syncXattrs> + <sendXattrs>false</sendXattrs> + <xattrFilter> + <maxSingleEntrySize>1024</maxSingleEntrySize> + <maxTotalSize>4096</maxTotalSize> + </xattrFilter> + </folder> + {% endfor -%} + {% for name, peer in syncthing_config.peers.items() -%} + <device id="{{ peer.id }}" name="{{ name }}" compression="metadata" introducer="false" skipIntroductionRemovals="false" introducedBy=""> + <address>{{ peer.address }}</address> + <paused>false</paused> + <autoAcceptFolders>false</autoAcceptFolders> + <maxSendKbps>0</maxSendKbps> + <maxRecvKbps>0</maxRecvKbps> + <maxRequestKiB>0</maxRequestKiB> + <untrusted>false</untrusted> + <remoteGUIPort>0</remoteGUIPort> + </device> + {% endfor -%} + <gui enabled="true" tls="false" debugging="false"> + <address>127.0.0.1:8384</address> + <apikey>QQ6NPEZAf9FEnCt3dWQ4x2Z3jFmyUTKN</apikey> + <theme>dark</theme> + </gui> + <ldap></ldap> + <options> + <listenAddress>default</listenAddress> + <globalAnnounceServer>default</globalAnnounceServer> + <globalAnnounceEnabled>false</globalAnnounceEnabled> + <localAnnounceEnabled>false</localAnnounceEnabled> + <localAnnouncePort>21027</localAnnouncePort> + <localAnnounceMCAddr>[ff12::8384]:21027</localAnnounceMCAddr> + <maxSendKbps>0</maxSendKbps> + <maxRecvKbps>0</maxRecvKbps> + <reconnectionIntervalS>60</reconnectionIntervalS> + <relaysEnabled>false</relaysEnabled> + <relayReconnectIntervalM>10</relayReconnectIntervalM> + <startBrowser>false</startBrowser> + <natEnabled>false</natEnabled> + <natLeaseMinutes>60</natLeaseMinutes> + <natRenewalMinutes>30</natRenewalMinutes> + <natTimeoutSeconds>10</natTimeoutSeconds> + <urAccepted>-1</urAccepted> + <urSeen>3</urSeen> + <urUniqueID></urUniqueID> + <urURL>https://data.syncthing.net/newdata</urURL> + <urPostInsecurely>false</urPostInsecurely> + <urInitialDelayS>1800</urInitialDelayS> + <autoUpgradeIntervalH>0</autoUpgradeIntervalH> + <upgradeToPreReleases>false</upgradeToPreReleases> + <keepTemporariesH>24</keepTemporariesH> + <cacheIgnoredFiles>false</cacheIgnoredFiles> + <progressUpdateIntervalS>5</progressUpdateIntervalS> + <limitBandwidthInLan>false</limitBandwidthInLan> + <minHomeDiskFree unit="%">10</minHomeDiskFree> + <releasesURL>https://upgrades.syncthing.net/meta.json</releasesURL> + <overwriteRemoteDeviceNamesOnConnect>false</overwriteRemoteDeviceNamesOnConnect> + <tempIndexMinBlocks>10</tempIndexMinBlocks> + <trafficClass>0</trafficClass> + <setLowPriority>true</setLowPriority> + <maxFolderConcurrency>0</maxFolderConcurrency> + <crashReportingURL>https://crash.syncthing.net/newcrash</crashReportingURL> + <crashReportingEnabled>true</crashReportingEnabled> + <stunKeepaliveStartS>180</stunKeepaliveStartS> + <stunKeepaliveMinS>20</stunKeepaliveMinS> + <stunServer>default</stunServer> <!-- TODO --> + <databaseTuning>auto</databaseTuning> + <maxConcurrentIncomingRequestKiB>0</maxConcurrentIncomingRequestKiB> + <announceLANAddresses>true</announceLANAddresses> + <sendFullIndexOnUpgrade>false</sendFullIndexOnUpgrade> + <connectionLimitEnough>0</connectionLimitEnough> + <connectionLimitMax>0</connectionLimitMax> + <insecureAllowOldTLSVersions>false</insecureAllowOldTLSVersions> + </options> + <defaults> + </defaults> +</configuration> |