aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpacien2020-01-20 17:07:12 +0100
committerpacien2020-01-20 17:07:12 +0100
commit883b5abc7b2a770146683e7e27bf275bd4064511 (patch)
tree81dd200fc2cea8e2030b5b5b68c39abe3c32ab46
parent3fc8a2ed3bfbcbd29bc22c2c73416e2708cd7615 (diff)
downloadtincapp-883b5abc7b2a770146683e7e27bf275bd4064511.tar.gz
pass network device fd via unix socket instead of inheritance
Workaround for new shared memory restrictions added in Android 10 preventing file descriptor leakage to sub-processes. This change set BREAKS ENCRYPTED PRIVATE KEYS SUPPORT. GitHub: https://github.com/pacien/tincapp/issues/92
-rw-r--r--app/CMakeLists.txt9
-rw-r--r--app/build.gradle4
-rw-r--r--app/src/main/c/exec.c60
-rw-r--r--app/src/main/c/main.c1
-rw-r--r--app/src/main/java/org/pacien/tincapp/commands/Executor.kt34
-rw-r--r--app/src/main/java/org/pacien/tincapp/commands/Tincd.kt15
-rw-r--r--app/src/main/java/org/pacien/tincapp/context/App.kt10
-rw-r--r--app/src/main/java/org/pacien/tincapp/service/TincVpnService.kt34
-rw-r--r--app/src/main/play/listings/en-US/full-description.txt2
-rw-r--r--readme.md2
10 files changed, 55 insertions, 116 deletions
diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
index 073f7d7..6449aeb 100644
--- a/app/CMakeLists.txt
+++ b/app/CMakeLists.txt
@@ -54,9 +54,8 @@ ExternalProject_Add(libressl
54 54
55ExternalProject_Add(tinc 55ExternalProject_Add(tinc
56 DEPENDS lzo libressl 56 DEPENDS lzo libressl
57 URL https://github.com/gsliepen/tinc/archive/017a7fb57655d9b1d706ee78f7e3d0000411b883.tar.gz 57 URL https://github.com/gsliepen/tinc/archive/6682a0d29cbb70b216a3fe02f2812963dee607d0.tar.gz
58 URL_HASH SHA256=27f361706d09f81fbbef7021f37adf5375f01857b23272a490df066ca290a530 58 URL_HASH SHA256=ffb0e6c02b0112c095366b3baa89f5ed56cc5f644be40268696ce7741e76452a
59 PATCH_COMMAND sed -i -e "s/test(void)/test(void *x)/" <SOURCE_DIR>/m4/attribute.m4
60 CONFIGURE_COMMAND autoreconf -fsi <SOURCE_DIR> && 59 CONFIGURE_COMMAND autoreconf -fsi <SOURCE_DIR> &&
61 <SOURCE_DIR>/configure ${xCONFIG} 60 <SOURCE_DIR>/configure ${xCONFIG}
62 --with-openssl=${CMAKE_CURRENT_BINARY_DIR}/usr/local 61 --with-openssl=${CMAKE_CURRENT_BINARY_DIR}/usr/local
@@ -72,5 +71,5 @@ ExternalProject_Add(tinc
72 rm -r <BINARY_DIR> 71 rm -r <BINARY_DIR>
73) 72)
74 73
75add_library(exec SHARED src/main/c/exec.c) 74add_library(main SHARED src/main/c/main.c)
76add_dependencies(exec tinc) 75add_dependencies(main tinc)
diff --git a/app/build.gradle b/app/build.gradle
index 5ef4f4a..25e449e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,6 +1,6 @@
1/* 1/*
2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon 2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon
3 * Copyright (C) 2017-2019 Pacien TRAN-GIRARD 3 * Copyright (C) 2017-2020 Pacien TRAN-GIRARD
4 * 4 *
5 * This program is free software: you can redistribute it and/or modify 5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by 6 * it under the terms of the GNU General Public License as published by
@@ -35,7 +35,7 @@ android {
35 defaultConfig { 35 defaultConfig {
36 applicationId 'org.pacien.tincapp' 36 applicationId 'org.pacien.tincapp'
37 minSdkVersion 21 37 minSdkVersion 21
38 targetSdkVersion 28 // FIXME: bad file descriptor in daemon when targeting 29 38 targetSdkVersion 29
39 multiDexEnabled true 39 multiDexEnabled true
40 versionCode 28 40 versionCode 28
41 versionName '0.28' 41 versionName '0.28'
diff --git a/app/src/main/c/exec.c b/app/src/main/c/exec.c
deleted file mode 100644
index c335b20..0000000
--- a/app/src/main/c/exec.c
+++ /dev/null
@@ -1,60 +0,0 @@
1/*
2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon
3 * Copyright (C) 2017-2018 Pacien TRAN-GIRARD
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19#include <jni.h>
20#include <unistd.h>
21#include <stdlib.h>
22#include <sys/wait.h>
23
24static inline const char **to_string_array(JNIEnv *env, jobjectArray ja) {
25 const int len = (*env)->GetArrayLength(env, ja);
26 const char **ca = calloc((size_t) len + 1, sizeof(char *));
27
28 for (int i = 0; i < len; ++i) {
29 jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, ja, i);
30 ca[i] = (*env)->GetStringUTFChars(env, jstr, NULL);
31 }
32
33 ca[len] = NULL;
34 return ca;
35}
36
37static inline void exec(const char **argcv) {
38 execv(argcv[0], (char *const *) argcv);
39 exit(1);
40}
41
42JNIEXPORT jint JNICALL
43Java_org_pacien_tincapp_commands_Executor_forkExec(JNIEnv *env, __attribute__((unused)) jclass class, jobjectArray args) {
44 pid_t pid = fork();
45 switch (pid) {
46 case 0:
47 exec(to_string_array(env, args));
48 return 0;
49
50 default:
51 return pid;
52 }
53}
54
55JNIEXPORT jint JNICALL
56Java_org_pacien_tincapp_commands_Executor_wait(__attribute__((unused))JNIEnv *env, __attribute__((unused)) jclass class, jint pid) {
57 int status;
58 waitpid(pid, &status, 0);
59 return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
60}
diff --git a/app/src/main/c/main.c b/app/src/main/c/main.c
new file mode 100644
index 0000000..68007d8
--- /dev/null
+++ b/app/src/main/c/main.c
@@ -0,0 +1 @@
// This file intentionally left blank.
diff --git a/app/src/main/java/org/pacien/tincapp/commands/Executor.kt b/app/src/main/java/org/pacien/tincapp/commands/Executor.kt
index 29e011f..0a8a774 100644
--- a/app/src/main/java/org/pacien/tincapp/commands/Executor.kt
+++ b/app/src/main/java/org/pacien/tincapp/commands/Executor.kt
@@ -1,6 +1,6 @@
1/* 1/*
2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon 2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon
3 * Copyright (C) 2017-2018 Pacien TRAN-GIRARD 3 * Copyright (C) 2017-2020 Pacien TRAN-GIRARD
4 * 4 *
5 * This program is free software: you can redistribute it and/or modify 5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by 6 * it under the terms of the GNU General Public License as published by
@@ -30,42 +30,10 @@ import java.io.InputStreamReader
30 * @author pacien 30 * @author pacien
31 */ 31 */
32internal object Executor { 32internal object Executor {
33 private const val FAILED = -1
34 private const val SUCCESS = 0
35
36 class CommandExecutionException(msg: String) : Exception(msg) 33 class CommandExecutionException(msg: String) : Exception(msg)
37 34
38 init {
39 System.loadLibrary("exec")
40 }
41
42 /**
43 * @return FAILED (-1) on error, forked child PID otherwise
44 */
45 private external fun forkExec(args: Array<String>): Int
46
47 /**
48 * @return FAILED (-1) on error, the exit status of the process otherwise
49 */
50 private external fun wait(pid: Int): Int
51
52 private fun read(stream: InputStream) = BufferedReader(InputStreamReader(stream)).readLines() 35 private fun read(stream: InputStream) = BufferedReader(InputStreamReader(stream)).readLines()
53 36
54 fun forkExec(cmd: Command): CompletableFuture<Unit> {
55 val pid = forkExec(cmd.asArray()).also {
56 if (it == FAILED) throw CommandExecutionException("Could not fork child process.")
57 }
58
59 return runAsyncTask {
60 val exitCode = wait(pid)
61 when (exitCode) {
62 SUCCESS -> Unit
63 FAILED -> throw CommandExecutionException("Process terminated abnormally.")
64 else -> throw CommandExecutionException("Non-zero exit status code ($exitCode).")
65 }
66 }
67 }
68
69 fun run(cmd: Command): Process = try { 37 fun run(cmd: Command): Process = try {
70 ProcessBuilder(cmd.asList()).start() 38 ProcessBuilder(cmd.asList()).start()
71 } catch (e: IOException) { 39 } catch (e: IOException) {
diff --git a/app/src/main/java/org/pacien/tincapp/commands/Tincd.kt b/app/src/main/java/org/pacien/tincapp/commands/Tincd.kt
index 92be0f5..c0b0048 100644
--- a/app/src/main/java/org/pacien/tincapp/commands/Tincd.kt
+++ b/app/src/main/java/org/pacien/tincapp/commands/Tincd.kt
@@ -1,6 +1,6 @@
1/* 1/*
2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon 2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon
3 * Copyright (C) 2017-2018 Pacien TRAN-GIRARD 3 * Copyright (C) 2017-2020 Pacien TRAN-GIRARD
4 * 4 *
5 * This program is free software: you can redistribute it and/or modify 5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by 6 * it under the terms of the GNU General Public License as published by
@@ -18,20 +18,23 @@
18 18
19package org.pacien.tincapp.commands 19package org.pacien.tincapp.commands
20 20
21import java8.util.concurrent.CompletableFuture
21import org.pacien.tincapp.context.AppPaths 22import org.pacien.tincapp.context.AppPaths
23import java.io.File
22 24
23/** 25/**
24 * @author pacien 26 * @author pacien
25 */ 27 */
26object Tincd { 28object Tincd {
27 fun start(netName: String, deviceFd: Int, ed25519PrivateKeyFd: Int? = null, rsaPrivateKeyFd: Int? = null) = 29 fun start(netName: String, device: String, ed25519PrivateKey: File? = null, rsaPrivateKey: File? = null): CompletableFuture<Unit> =
28 Executor.forkExec(Command(AppPaths.tincd().absolutePath) 30 Executor.call(Command(AppPaths.tincd().absolutePath)
29 .withOption("no-detach") 31 .withOption("no-detach")
30 .withOption("config", AppPaths.confDir(netName).absolutePath) 32 .withOption("config", AppPaths.confDir(netName).absolutePath)
31 .withOption("pidfile", AppPaths.pidFile(netName).absolutePath) 33 .withOption("pidfile", AppPaths.pidFile(netName).absolutePath)
32 .withOption("logfile", AppPaths.logFile(netName).absolutePath) 34 .withOption("logfile", AppPaths.logFile(netName).absolutePath)
33 .withOption("option", "DeviceType=fd") 35 .withOption("option", "DeviceType=fd")
34 .withOption("option", "Device=$deviceFd") 36 .withOption("option", "Device=@$device")
35 .apply { if (ed25519PrivateKeyFd != null) withOption("option", "Ed25519PrivateKeyFile=/proc/self/fd/$ed25519PrivateKeyFd") } 37 .apply { if (ed25519PrivateKey != null) withOption("option", "Ed25519PrivateKeyFile=${ed25519PrivateKey.absolutePath}") }
36 .apply { if (rsaPrivateKeyFd != null) withOption("option", "PrivateKeyFile=/proc/self/fd/$rsaPrivateKeyFd") }) 38 .apply { if (rsaPrivateKey != null) withOption("option", "PrivateKeyFile=${rsaPrivateKey.absolutePath}") }
39 ).thenApply { }
37} 40}
diff --git a/app/src/main/java/org/pacien/tincapp/context/App.kt b/app/src/main/java/org/pacien/tincapp/context/App.kt
index a877929..4d8d5d0 100644
--- a/app/src/main/java/org/pacien/tincapp/context/App.kt
+++ b/app/src/main/java/org/pacien/tincapp/context/App.kt
@@ -1,6 +1,6 @@
1/* 1/*
2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon 2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon
3 * Copyright (C) 2017-2019 Pacien TRAN-GIRARD 3 * Copyright (C) 2017-2020 Pacien TRAN-GIRARD
4 * 4 *
5 * This program is free software: you can redistribute it and/or modify 5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by 6 * it under the terms of the GNU General Public License as published by
@@ -21,6 +21,7 @@ package org.pacien.tincapp.context
21import android.app.Application 21import android.app.Application
22import android.content.Context 22import android.content.Context
23import android.content.Intent 23import android.content.Intent
24import android.content.pm.ApplicationInfo
24import android.net.Uri 25import android.net.Uri
25import android.os.Build 26import android.os.Build
26import android.os.Handler 27import android.os.Handler
@@ -47,7 +48,7 @@ class App : Application() {
47 48
48 private fun setupCrashHandler() { 49 private fun setupCrashHandler() {
49 val logger = LoggerFactory.getLogger(this.javaClass) 50 val logger = LoggerFactory.getLogger(this.javaClass)
50 val systemCrashHandler = Thread.getDefaultUncaughtExceptionHandler() 51 val systemCrashHandler = Thread.getDefaultUncaughtExceptionHandler()!!
51 val crashRecorder = CrashRecorder(logger, systemCrashHandler) 52 val crashRecorder = CrashRecorder(logger, systemCrashHandler)
52 Thread.setDefaultUncaughtExceptionHandler(crashRecorder) 53 Thread.setDefaultUncaughtExceptionHandler(crashRecorder)
53 } 54 }
@@ -61,6 +62,11 @@ class App : Application() {
61 fun getContext() = appContext!! 62 fun getContext() = appContext!!
62 fun getResources() = getContext().resources!! 63 fun getResources() = getContext().resources!!
63 64
65 fun getApplicationInfo(): ApplicationInfo =
66 getContext()
67 .packageManager
68 .getApplicationInfo(BuildConfig.APPLICATION_ID, 0)
69
64 fun alert(@StringRes title: Int, msg: String, manualLink: String? = null) = 70 fun alert(@StringRes title: Int, msg: String, manualLink: String? = null) =
65 notificationManager.notifyError(appContext!!.getString(title), msg, manualLink) 71 notificationManager.notifyError(appContext!!.getString(title), msg, manualLink)
66 72
diff --git a/app/src/main/java/org/pacien/tincapp/service/TincVpnService.kt b/app/src/main/java/org/pacien/tincapp/service/TincVpnService.kt
index 40e9004..48cb1df 100644
--- a/app/src/main/java/org/pacien/tincapp/service/TincVpnService.kt
+++ b/app/src/main/java/org/pacien/tincapp/service/TincVpnService.kt
@@ -1,6 +1,6 @@
1/* 1/*
2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon 2 * Tinc App, an Android binding and user interface for the tinc mesh VPN daemon
3 * Copyright (C) 2017-2019 Pacien TRAN-GIRARD 3 * Copyright (C) 2017-2020 Pacien TRAN-GIRARD
4 * 4 *
5 * This program is free software: you can redistribute it and/or modify 5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by 6 * it under the terms of the GNU General Public License as published by
@@ -21,6 +21,7 @@ package org.pacien.tincapp.service
21import android.app.Service 21import android.app.Service
22import android.content.Context 22import android.content.Context
23import android.content.Intent 23import android.content.Intent
24import android.net.LocalServerSocket
24import android.net.VpnService 25import android.net.VpnService
25import android.os.ParcelFileDescriptor 26import android.os.ParcelFileDescriptor
26import androidx.localbroadcastmanager.content.LocalBroadcastManager 27import androidx.localbroadcastmanager.content.LocalBroadcastManager
@@ -43,6 +44,7 @@ import org.pacien.tincapp.intent.Actions
43import org.pacien.tincapp.utils.TincKeyring 44import org.pacien.tincapp.utils.TincKeyring
44import org.slf4j.LoggerFactory 45import org.slf4j.LoggerFactory
45import java.io.FileNotFoundException 46import java.io.FileNotFoundException
47import java.security.AccessControlException
46 48
47/** 49/**
48 * @author pacien 50 * @author pacien
@@ -100,6 +102,7 @@ class TincVpnService : VpnService() {
100 log.info("Starting tinc daemon for network \"$netName\".") 102 log.info("Starting tinc daemon for network \"$netName\".")
101 if (isConnected() || getCurrentNetName() != null) stopVpn().join() 103 if (isConnected() || getCurrentNetName() != null) stopVpn().join()
102 104
105 // FIXME: pass decrypted private keys via temp file
103 val privateKeys = try { 106 val privateKeys = try {
104 TincConfiguration.fromTincConfiguration(AppPaths.existing(AppPaths.tincConfFile(netName))).let { tincCfg -> 107 TincConfiguration.fromTincConfiguration(AppPaths.existing(AppPaths.tincConfFile(netName))).let { tincCfg ->
105 Pair( 108 Pair(
@@ -125,13 +128,10 @@ class TincVpnService : VpnService() {
125 } 128 }
126 129
127 val deviceFd = try { 130 val deviceFd = try {
128 val appContextFd = Builder().setSession(netName) 131 Builder().setSession(netName)
129 .applyCfg(interfaceCfg) 132 .applyCfg(interfaceCfg)
130 .also { applyIgnoringException(it::addDisallowedApplication, BuildConfig.APPLICATION_ID) } 133 .also { applyIgnoringException(it::addDisallowedApplication, BuildConfig.APPLICATION_ID) }
131 .establish()!! 134 .establish()!!
132 val daemonContextFd = appContextFd.dup() // necessary since Android 10
133 appContextFd.close()
134 daemonContextFd
135 } catch (e: IllegalArgumentException) { 135 } catch (e: IllegalArgumentException) {
136 return reportError(resources.getString(R.string.notification_error_message_network_config_invalid_format, e.defaultMessage()), e, "network-interface") 136 return reportError(resources.getString(R.string.notification_error_message_network_config_invalid_format, e.defaultMessage()), e, "network-interface")
137 } catch (e: NullPointerException) { 137 } catch (e: NullPointerException) {
@@ -140,10 +140,15 @@ class TincVpnService : VpnService() {
140 return reportError(resources.getString(R.string.notification_error_message_could_not_configure_iface, e.defaultMessage()), e) 140 return reportError(resources.getString(R.string.notification_error_message_could_not_configure_iface, e.defaultMessage()), e)
141 } 141 }
142 142
143 val daemon = Tincd.start(netName, deviceFd.fd, privateKeys.first?.fd, privateKeys.second?.fd) 143 val serverSocket = LocalServerSocket(DEVICE_FD_ABSTRACT_SOCKET)
144 Executor.runAsyncTask { serveDeviceFd(serverSocket, deviceFd) }
145
146 // FIXME: pass decrypted private keys via temp file
147 val daemon = Tincd.start(netName, DEVICE_FD_ABSTRACT_SOCKET, null, null)
144 setState(netName, passphrase, interfaceCfg, deviceFd, daemon) 148 setState(netName, passphrase, interfaceCfg, deviceFd, daemon)
145 149
146 waitForDaemonStartup().whenComplete { _, exception -> 150 waitForDaemonStartup().whenComplete { _, exception ->
151 serverSocket.close()
147 deviceFd.close() 152 deviceFd.close()
148 privateKeys.first?.close() 153 privateKeys.first?.close()
149 privateKeys.second?.close() 154 privateKeys.second?.close()
@@ -189,6 +194,22 @@ class TincVpnService : VpnService() {
189 LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(event)) 194 LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(event))
190 } 195 }
191 196
197 private fun serveDeviceFd(serverSocket: LocalServerSocket, deviceFd: ParcelFileDescriptor) =
198 serverSocket.accept().let { socket ->
199 try {
200 if (socket.peerCredentials.uid != App.getApplicationInfo().uid)
201 throw AccessControlException("Peer UID mismatch.")
202
203 socket.setFileDescriptorsForSend(arrayOf(deviceFd.fileDescriptor))
204 socket.outputStream.write(0) // dummy write
205 socket.outputStream.flush()
206 } catch (e: Exception) {
207 log.error("Error while serving device fd", e)
208 } finally {
209 socket.close()
210 }
211 }
212
192 private fun waitForDaemonStartup() = 213 private fun waitForDaemonStartup() =
193 Executor 214 Executor
194 .runAsyncTask { Thread.sleep(SETUP_DELAY) } 215 .runAsyncTask { Thread.sleep(SETUP_DELAY) }
@@ -196,6 +217,7 @@ class TincVpnService : VpnService() {
196 217
197 companion object { 218 companion object {
198 private const val SETUP_DELAY = 500L // ms 219 private const val SETUP_DELAY = 500L // ms
220 private const val DEVICE_FD_ABSTRACT_SOCKET = "${BuildConfig.APPLICATION_ID}.daemon.socket"
199 221
200 private val STORE_NAME = this::class.java.`package`!!.name 222 private val STORE_NAME = this::class.java.`package`!!.name
201 private const val STORE_KEY_NETNAME = "netname" 223 private const val STORE_KEY_NETNAME = "netname"
diff --git a/app/src/main/play/listings/en-US/full-description.txt b/app/src/main/play/listings/en-US/full-description.txt
index 45fee19..b25756d 100644
--- a/app/src/main/play/listings/en-US/full-description.txt
+++ b/app/src/main/play/listings/en-US/full-description.txt
@@ -22,7 +22,7 @@ Please see the project's website for more information (setup guide, documentatio
22 22
23--- 23---
24 24
25Copyright © 2017-2019 Pacien TRAN-GIRARD and contributors. 25Copyright © 2017-2020 Pacien TRAN-GIRARD and contributors.
26This program is licensed under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License or any later version. 26This program is licensed under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License or any later version.
27It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 27It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
28See the GNU General Public License for more details: https://www.gnu.org/licenses/ 28See the GNU General Public License for more details: https://www.gnu.org/licenses/
diff --git a/readme.md b/readme.md
index d78fffb..f70c6e3 100644
--- a/readme.md
+++ b/readme.md
@@ -41,7 +41,7 @@ Requirements:
41License 41License
42------- 42-------
43 43
44Copyright (C) 2017-2019 Pacien TRAN-GIRARD and contributors (listed in `contributors.md`). 44Copyright (C) 2017-2020 Pacien TRAN-GIRARD and contributors (listed in `contributors.md`).
45 45
46_Tinc App_ is distributed under the terms of GNU General Public License v3.0, 46_Tinc App_ is distributed under the terms of GNU General Public License v3.0,
47as detailed in the provided `license.md` file. 47as detailed in the provided `license.md` file.