diff --git a/plinth/modules/i2p/helpers.py b/plinth/modules/i2p/helpers.py
new file mode 100644
index 000000000..76b1301cd
--- /dev/null
+++ b/plinth/modules/i2p/helpers.py
@@ -0,0 +1,119 @@
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program 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. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+import os
+import re
+
+import augeas
+
+I2P_CONF_DIR = '/var/lib/i2p/i2p-config'
+FILE_TUNNEL_CONF = os.path.join(I2P_CONF_DIR, 'i2ptunnel.config')
+TUNNEL_IDX_REGEX = re.compile(r'tunnel.(\d+).name$')
+
+
+class TunnelEditor(object):
+ """
+
+ :type aug: augeas.Augeas
+ """
+
+ def __init__(self, conf_filename=None, idx=None):
+ self.conf_filename = conf_filename or FILE_TUNNEL_CONF
+ self.idx = idx
+ self.aug = None
+
+ @property
+ def lines(self):
+ if self.aug:
+ return self.aug.match('/files{}/*'.format(self.conf_filename))
+ else:
+ return []
+
+ def read_conf(self):
+ """Return an instance of Augeaus for processing APT configuration."""
+ self.aug = augeas.Augeas(flags=augeas.Augeas.NO_LOAD +
+ augeas.Augeas.NO_MODL_AUTOLOAD)
+ self.aug.set('/augeas/load/Properties/lens', 'Properties.lns')
+ self.aug.set('/augeas/load/Properties/incl[last() + 1]', self.conf_filename)
+ self.aug.load()
+
+ return self
+
+ def write_conf(self):
+ self.aug.save()
+ return self
+
+ def set_tunnel_idx(self, name):
+
+ """
+ Finds the index of the tunnel with the given name.
+
+ Chainable method.
+
+ :type name: basestring
+ """
+
+ for prop in self.aug.match('/files{}/*'.format(self.conf_filename)):
+ match = TUNNEL_IDX_REGEX.search(prop)
+
+ if match and self.aug.get(prop) == name:
+ self.idx = int(match.group(1))
+ return self
+ raise ValueError('No tunnel with that name')
+
+ def calc_prop_path(self, tunnel_prop):
+ """
+ Calculates the property name as found in the properties files
+ :type tunnel_prop: str
+ :rtype: basestring
+ """
+ calced_prop_path = '/files{filepath}/tunnel.{idx}.{tunnel_prop}'.format(
+ idx=self.idx, tunnel_prop=tunnel_prop,
+ filepath=self.conf_filename
+ )
+ return calced_prop_path
+
+ def set_tunnel_prop(self, tunnel_prop, value):
+ """
+ Updates a tunnel's property.
+
+ The idx has to be set and the property has to exist in the config file and belong to the tunnel's properties.
+
+ see calc_prop_path
+
+ Chainable method.
+
+ :param tunnel_prop:
+ :type tunnel_prop: str
+ :param value:
+ :type value: basestring | int
+ :return:
+ :rtype:
+ """
+ if self.idx is None:
+ raise ValueError('Please init the tunnel index before calling this method')
+
+ calc_prop_path = self.calc_prop_path(tunnel_prop)
+ self.aug.set(calc_prop_path, value)
+ return self
+
+ def __getitem__(self, tunnel_prop):
+ ret = self.aug.get(self.calc_prop_path(tunnel_prop))
+ if ret is None:
+ raise KeyError('Unknown property {}'.format(tunnel_prop))
+ return ret
+
+ def __setitem__(self, tunnel_prop, value):
+ self.aug.set(self.calc_prop_path(tunnel_prop), value)
diff --git a/plinth/modules/i2p/tests/__init__.py b/plinth/modules/i2p/tests/__init__.py
new file mode 100644
index 000000000..019e3bfd7
--- /dev/null
+++ b/plinth/modules/i2p/tests/__init__.py
@@ -0,0 +1,16 @@
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program 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. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
diff --git a/plinth/modules/i2p/tests/data/i2ptunnel.config b/plinth/modules/i2p/tests/data/i2ptunnel.config
new file mode 100644
index 000000000..467980059
--- /dev/null
+++ b/plinth/modules/i2p/tests/data/i2ptunnel.config
@@ -0,0 +1,192 @@
+# NOTE: This I2P config file must use UTF-8 encoding
+tunnel.0.description=HTTP proxy for browsing eepsites and the web
+tunnel.0.interface=127.0.0.1
+tunnel.0.listenPort=4444
+tunnel.0.name=I2P HTTP Proxy
+tunnel.0.option.i2cp.closeIdleTime=1800000
+tunnel.0.option.i2cp.closeOnIdle=false
+tunnel.0.option.i2cp.delayOpen=false
+tunnel.0.option.i2cp.destination.sigType=EdDSA_SHA512_Ed25519
+tunnel.0.option.i2cp.newDestOnResume=false
+tunnel.0.option.i2cp.reduceIdleTime=900000
+tunnel.0.option.i2cp.reduceOnIdle=true
+tunnel.0.option.i2cp.reduceQuantity=1
+tunnel.0.option.i2p.streaming.connectDelay=1000
+tunnel.0.option.i2ptunnel.httpclient.SSLOutproxies=false.i2p
+tunnel.0.option.i2ptunnel.httpclient.allowInternalSSL=true
+tunnel.0.option.i2ptunnel.httpclient.jumpServers=http://stats.i2p/cgi-bin/jump.cgi?a=,http://no.i2p/jump/,http://i2pjump.i2p/jump/
+tunnel.0.option.i2ptunnel.httpclient.sendAccept=false
+tunnel.0.option.i2ptunnel.httpclient.sendReferer=false
+tunnel.0.option.i2ptunnel.httpclient.sendUserAgent=false
+tunnel.0.option.i2ptunnel.useLocalOutproxy=false
+tunnel.0.option.inbound.backupQuantity=0
+tunnel.0.option.inbound.length=3
+tunnel.0.option.inbound.lengthVariance=0
+tunnel.0.option.inbound.nickname=shared clients
+tunnel.0.option.inbound.quantity=2
+tunnel.0.option.outbound.backupQuantity=0
+tunnel.0.option.outbound.length=3
+tunnel.0.option.outbound.lengthVariance=0
+tunnel.0.option.outbound.nickname=shared clients
+tunnel.0.option.outbound.priority=10
+tunnel.0.option.outbound.quantity=2
+tunnel.0.option.outproxyAuth=false
+tunnel.0.option.persistentClientKey=false
+tunnel.0.option.sslManuallySet=true
+tunnel.0.option.useSSL=false
+tunnel.0.proxyList=false.i2p
+tunnel.0.sharedClient=true
+tunnel.0.startOnLoad=true
+tunnel.0.type=httpclient
+tunnel.1.description=IRC tunnel to access the Irc2P network
+tunnel.1.i2cpHost=127.0.0.1
+tunnel.1.i2cpPort=7654
+tunnel.1.interface=127.0.0.1
+tunnel.1.listenPort=6668
+tunnel.1.name=Irc2P
+tunnel.1.option.crypto.lowTagThreshold=14
+tunnel.1.option.crypto.tagsToSend=20
+tunnel.1.option.i2cp.closeIdleTime=1200000
+tunnel.1.option.i2cp.closeOnIdle=true
+tunnel.1.option.i2cp.delayOpen=true
+tunnel.1.option.i2cp.destination.sigType=ECDSA_SHA256_P256
+tunnel.1.option.i2cp.newDestOnResume=false
+tunnel.1.option.i2cp.reduceIdleTime=600000
+tunnel.1.option.i2cp.reduceOnIdle=true
+tunnel.1.option.i2cp.reduceQuantity=1
+tunnel.1.option.i2p.streaming.connectDelay=1000
+tunnel.1.option.i2p.streaming.maxWindowSize=16
+tunnel.1.option.inbound.length=3
+tunnel.1.option.inbound.lengthVariance=0
+tunnel.1.option.inbound.nickname=Irc2P
+tunnel.1.option.outbound.length=3
+tunnel.1.option.outbound.lengthVariance=0
+tunnel.1.option.outbound.nickname=Irc2P
+tunnel.1.option.outbound.priority=15
+tunnel.1.sharedClient=false
+tunnel.1.startOnLoad=true
+tunnel.1.targetDestination=irc.00.i2p:6667,irc.postman.i2p:6667,irc.echelon.i2p:6667
+tunnel.1.type=ircclient
+tunnel.2.description=I2P Monotone Server
+tunnel.2.i2cpHost=127.0.0.1
+tunnel.2.i2cpPort=7654
+tunnel.2.interface=127.0.0.1
+tunnel.2.listenPort=8998
+tunnel.2.name=mtn.i2p-projekt.i2p
+tunnel.2.option.i2cp.destination.sigType=EdDSA_SHA512_Ed25519
+tunnel.2.option.i2cp.reduceIdleTime=900000
+tunnel.2.option.i2cp.reduceOnIdle=true
+tunnel.2.option.i2cp.reduceQuantity=1
+tunnel.2.option.inbound.backupQuantity=0
+tunnel.2.option.inbound.length=3
+tunnel.2.option.inbound.lengthVariance=0
+tunnel.2.option.inbound.nickname=shared clients
+tunnel.2.option.inbound.quantity=2
+tunnel.2.option.outbound.backupQuantity=0
+tunnel.2.option.outbound.length=3
+tunnel.2.option.outbound.lengthVariance=0
+tunnel.2.option.outbound.nickname=shared clients
+tunnel.2.option.outbound.quantity=2
+tunnel.2.sharedClient=true
+tunnel.2.startOnLoad=false
+tunnel.2.targetDestination=mtn.i2p-projekt.i2p:4691
+tunnel.2.type=client
+tunnel.3.description=My eepsite
+tunnel.3.i2cpHost=127.0.0.1
+tunnel.3.i2cpPort=7654
+tunnel.3.name=I2P webserver
+tunnel.3.option.i2cp.destination.sigType=7
+tunnel.3.option.i2p.streaming.limitAction=http
+tunnel.3.option.i2p.streaming.maxConcurrentStreams=20
+tunnel.3.option.i2p.streaming.maxConnsPerDay=100
+tunnel.3.option.i2p.streaming.maxConnsPerHour=40
+tunnel.3.option.i2p.streaming.maxConnsPerMinute=10
+tunnel.3.option.i2p.streaming.maxTotalConnsPerMinute=25
+tunnel.3.option.inbound.length=3
+tunnel.3.option.inbound.lengthVariance=0
+tunnel.3.option.inbound.nickname=eepsite
+tunnel.3.option.maxPosts=3
+tunnel.3.option.maxTotalPosts=10
+tunnel.3.option.outbound.length=3
+tunnel.3.option.outbound.lengthVariance=0
+tunnel.3.option.outbound.nickname=eepsite
+tunnel.3.option.shouldBundleReplyInfo=false
+tunnel.3.privKeyFile=eepsite/eepPriv.dat
+tunnel.3.spoofedHost=mysite.i2p
+tunnel.3.startOnLoad=false
+tunnel.3.targetHost=127.0.0.1
+tunnel.3.targetPort=7658
+tunnel.3.type=httpserver
+tunnel.4.description=smtp server
+tunnel.4.i2cpHost=127.0.0.1
+tunnel.4.i2cpPort=7654
+tunnel.4.interface=127.0.0.1
+tunnel.4.listenPort=7659
+tunnel.4.name=smtp.postman.i2p
+tunnel.4.option.i2cp.destination.sigType=EdDSA_SHA512_Ed25519
+tunnel.4.option.i2cp.reduceIdleTime=900000
+tunnel.4.option.i2cp.reduceOnIdle=true
+tunnel.4.option.i2cp.reduceQuantity=1
+tunnel.4.option.inbound.backupQuantity=0
+tunnel.4.option.inbound.length=3
+tunnel.4.option.inbound.lengthVariance=0
+tunnel.4.option.inbound.nickname=shared clients
+tunnel.4.option.inbound.quantity=2
+tunnel.4.option.outbound.backupQuantity=0
+tunnel.4.option.outbound.length=3
+tunnel.4.option.outbound.lengthVariance=0
+tunnel.4.option.outbound.nickname=shared clients
+tunnel.4.option.outbound.quantity=2
+tunnel.4.sharedClient=true
+tunnel.4.startOnLoad=true
+tunnel.4.targetDestination=smtp.postman.i2p:25
+tunnel.4.type=client
+tunnel.5.description=pop3 server
+tunnel.5.i2cpHost=127.0.0.1
+tunnel.5.i2cpPort=7654
+tunnel.5.interface=127.0.0.1
+tunnel.5.listenPort=7660
+tunnel.5.name=pop3.postman.i2p
+tunnel.5.option.i2cp.destination.sigType=EdDSA_SHA512_Ed25519
+tunnel.5.option.i2cp.reduceIdleTime=900000
+tunnel.5.option.i2cp.reduceOnIdle=true
+tunnel.5.option.i2cp.reduceQuantity=1
+tunnel.5.option.i2p.streaming.connectDelay=1000
+tunnel.5.option.inbound.backupQuantity=0
+tunnel.5.option.inbound.length=3
+tunnel.5.option.inbound.lengthVariance=0
+tunnel.5.option.inbound.nickname=shared clients
+tunnel.5.option.inbound.quantity=2
+tunnel.5.option.outbound.backupQuantity=0
+tunnel.5.option.outbound.length=3
+tunnel.5.option.outbound.lengthVariance=0
+tunnel.5.option.outbound.nickname=shared clients
+tunnel.5.option.outbound.quantity=2
+tunnel.5.sharedClient=true
+tunnel.5.startOnLoad=true
+tunnel.5.targetDestination=pop.postman.i2p:110
+tunnel.5.type=client
+tunnel.6.description=HTTPS proxy for browsing eepsites and the web
+tunnel.6.i2cpHost=127.0.0.1
+tunnel.6.i2cpPort=7654
+tunnel.6.interface=127.0.0.1
+tunnel.6.listenPort=4445
+tunnel.6.name=I2P HTTPS Proxy
+tunnel.6.option.i2cp.reduceIdleTime=900000
+tunnel.6.option.i2cp.reduceOnIdle=true
+tunnel.6.option.i2cp.reduceQuantity=1
+tunnel.6.option.i2p.streaming.connectDelay=1000
+tunnel.6.option.inbound.backupQuantity=0
+tunnel.6.option.inbound.length=3
+tunnel.6.option.inbound.lengthVariance=0
+tunnel.6.option.inbound.nickname=shared clients
+tunnel.6.option.inbound.quantity=2
+tunnel.6.option.outbound.backupQuantity=0
+tunnel.6.option.outbound.length=3
+tunnel.6.option.outbound.lengthVariance=0
+tunnel.6.option.outbound.nickname=shared clients
+tunnel.6.option.outbound.quantity=2
+tunnel.6.proxyList=outproxy-tor.meeh.i2p
+tunnel.6.sharedClient=true
+tunnel.6.startOnLoad=true
+tunnel.6.type=connectclient
diff --git a/plinth/modules/i2p/tests/test_helpers.py b/plinth/modules/i2p/tests/test_helpers.py
new file mode 100644
index 000000000..dc817c396
--- /dev/null
+++ b/plinth/modules/i2p/tests/test_helpers.py
@@ -0,0 +1,64 @@
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program 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. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+import unittest
+
+from pathlib import Path
+
+from plinth.modules.i2p.helpers import TunnelEditor
+
+DATA_DIR = Path(__file__).parent / 'data'
+TUNNEL_CONF_PATH = DATA_DIR / 'i2ptunnel.config'
+TUNNEL_HTTP_NAME = 'I2P HTTP Proxy'
+
+
+class TunnelEditorTests(unittest.TestCase):
+
+ def setUp(self):
+ self.editor = TunnelEditor(str(TUNNEL_CONF_PATH))
+
+ def test_reading_conf(self):
+ self.editor.read_conf()
+ self.assertGreater(len(self.editor.lines), 1)
+
+ def test_setting_idx(self):
+ self.editor.read_conf()
+ self.assertIsNone(self.editor.idx)
+ self.editor.set_tunnel_idx(TUNNEL_HTTP_NAME)
+ self.assertEqual(self.editor.idx, 0)
+
+ def test_setting_tunnel_props(self):
+ self.editor.read_conf()
+ self.editor.set_tunnel_idx('I2P HTTP Proxy')
+ interface = '0.0.0.0'
+ self.editor.set_tunnel_prop('interface', interface)
+ self.assertEqual(self.editor['interface'], interface)
+
+ def test_getting_inexistent_props(self):
+ self.editor.read_conf()
+ self.editor.idx = 0
+ self.assertRaises(KeyError, self.editor.__getitem__, 'blabla')
+
+ def test_setting_new_props(self):
+ self.editor.read_conf()
+ self.editor.idx = 0
+ value = 'lol'
+ prop = 'blablabla'
+ self.editor[prop] = value
+ self.assertEqual(self.editor[prop], value)
+
+
+if __name__ == '__main__':
+ unittest.main()