Merge ~ines-almeida/txpkgupload:fix-passive-ftp-through-haproxy into txpkgupload:master

Proposed by Ines Almeida
Status: Merged
Approved by: Ines Almeida
Approved revision: 193298bc0ae2f8c9cd32384f24fbe7b9463cc5ed
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~ines-almeida/txpkgupload:fix-passive-ftp-through-haproxy
Merge into: txpkgupload:master
Prerequisite: ~ines-almeida/txpkgupload:allow-setting-passive-port-range
Diff against target: 223 lines (+82/-21)
6 files modified
charm/txpkgupload/config.yaml (+6/-0)
charm/txpkgupload/reactive/txpkgupload.py (+10/-1)
charm/txpkgupload/templates/txpkgupload_config.yaml.j2 (+2/-0)
src/txpkgupload/plugin.py (+5/-0)
src/txpkgupload/tests/test_plugin.py (+1/-0)
src/txpkgupload/twistedftp.py (+58/-20)
Reviewer Review Type Date Requested Status
Colin Watson Approve
Review via email: mp+453543@code.launchpad.net

Commit message

Update txpkgupload + its charm to allow FTP through haproxy

The proxy's address is needed for passive twisted FTP requests in our txpkgupload so that the host address we send to the client is the proxy address (which will redirect the upload to the txpkgupload unit) instead of the txpkgupload unit address itself (which the client won't have access to)

Description of the change

Tested locally and in qastaging

To post a comment you must log in.
Revision history for this message
Ines Almeida (ines-almeida) wrote :
Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Comments addressed

Revision history for this message
Ines Almeida (ines-almeida) wrote :

After this gets merged, I will create a MP to the mojo specs updating the txpkgupload build_label, re-deploy and later another one to update the charm version, and re-deploy again.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Thanks for the review!

Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/charm/txpkgupload/config.yaml b/charm/txpkgupload/config.yaml
2index b53064e..c4fb6bf 100644
3--- a/charm/txpkgupload/config.yaml
4+++ b/charm/txpkgupload/config.yaml
5@@ -3,6 +3,12 @@ options:
6 type: int
7 default: 2221
8 description: The port to run the FTP server on.
9+ haproxy_ipv4_address:
10+ type: string
11+ description: >
12+ The IPv4 address of the haproxy unit related to this charm. Only
13+ one haproxy unit is expected.
14+ default:
15 haproxy_server_options:
16 type: string
17 description: >
18diff --git a/charm/txpkgupload/reactive/txpkgupload.py b/charm/txpkgupload/reactive/txpkgupload.py
19index 962ff92..d219464 100644
20--- a/charm/txpkgupload/reactive/txpkgupload.py
21+++ b/charm/txpkgupload/reactive/txpkgupload.py
22@@ -192,6 +192,16 @@ def configure_loadbalancer():
23 hookenv.log("Configuring loadbalancer for txpkgupload")
24 config = hookenv.config()
25
26+ if config.get("haproxy_ipv4_address"):
27+ hookenv.log(
28+ "Waiting for 'haproxy_ipv4_address' value to be configured"
29+ )
30+ hookenv.status_set(
31+ "blocked",
32+ "Blocked until 'haproxy_ipv4_address' value is configured",
33+ )
34+ return
35+
36 try:
37 service_options_ftp = yaml.safe_load(
38 config["haproxy_service_options_ftp"]
39@@ -266,7 +276,6 @@ def configure_loadbalancer():
40 },
41 ]
42 services_yaml = yaml.dump(services)
43-
44 for rel in hookenv.relations_of_type("loadbalancer"):
45 hookenv.relation_set(rel["__relid__"], services=services_yaml)
46
47diff --git a/charm/txpkgupload/templates/txpkgupload_config.yaml.j2 b/charm/txpkgupload/templates/txpkgupload_config.yaml.j2
48index eb5b224..4b02352 100644
49--- a/charm/txpkgupload/templates/txpkgupload_config.yaml.j2
50+++ b/charm/txpkgupload/templates/txpkgupload_config.yaml.j2
51@@ -19,3 +19,5 @@ temp_dir: "{{ base_dir }}/tmp-incoming"
52
53 min_passive_port: {{ min_passive_port }}
54 max_passive_port: {{ max_passive_port }}
55+
56+proxy_ipv4_address: {{ haproxy_ipv4_address }}
57diff --git a/src/txpkgupload/plugin.py b/src/txpkgupload/plugin.py
58index dbacac5..a1df540 100644
59--- a/src/txpkgupload/plugin.py
60+++ b/src/txpkgupload/plugin.py
61@@ -136,6 +136,10 @@ class Config(Schema):
62 min_passive_port = Int(if_missing=None)
63 max_passive_port = Int(if_missing=None)
64
65+ # IPv4 address of the proxy between the service and the client, if it
66+ # exists.
67+ proxy_ipv4_address = String(if_missing=None)
68+
69 @classmethod
70 def parse(cls, stream):
71 """Load a YAML configuration from `stream` and validate."""
72@@ -251,6 +255,7 @@ class PkgUploadServiceMaker:
73 idle_timeout=config["idle_timeout"],
74 min_passive_port=config.get("min_passive_port"),
75 max_passive_port=config.get("max_passive_port"),
76+ proxy_ipv4_address=config.get("proxy_ipv4_address"),
77 )
78 ftp_service.name = "ftp"
79 ftp_service.setServiceParent(services)
80diff --git a/src/txpkgupload/tests/test_plugin.py b/src/txpkgupload/tests/test_plugin.py
81index ac07566..20740d6 100644
82--- a/src/txpkgupload/tests/test_plugin.py
83+++ b/src/txpkgupload/tests/test_plugin.py
84@@ -113,6 +113,7 @@ class TestConfig(TestCase):
85 "idle_timeout": 3600,
86 "max_passive_port": None,
87 "min_passive_port": None,
88+ "proxy_ipv4_address": None,
89 "oops": {
90 "directory": "",
91 "reporter": "PKGUPLOAD",
92diff --git a/src/txpkgupload/twistedftp.py b/src/txpkgupload/twistedftp.py
93index 4c019b6..bfc195b 100644
94--- a/src/txpkgupload/twistedftp.py
95+++ b/src/txpkgupload/twistedftp.py
96@@ -199,6 +199,41 @@ class FTPWithEPSV(ftp.FTP):
97 "No port available in range %s" %
98 (self.passivePortRange,))
99
100+ def getHostAddress(self):
101+ """
102+ Returns IPv4 host address to be sent back the client during a Passive
103+ FTP request.
104+
105+ If there is a proxy between service and the client, return the proxy
106+ address. Else return the address of the unit txpkgupload is running on.
107+
108+ XXX ines-almeida 2023-10-13 ideally, we wouldn't need this method.
109+ If we decide to use the `haproxy` wrapper (see
110+ twisted.protocols.haproxy) then `self.transport.getHost().host`
111+ should automatically return the proxy address.
112+ """
113+ if self.factory.proxy_ipv4_address:
114+ host = self.factory.proxy_ipv4_address
115+ else:
116+ host = self.transport.getHost().host
117+
118+ # Returns either an IPv4Address or an IPv6Address
119+ address = ipaddress.ip_address(six.ensure_text(host))
120+
121+ if isinstance(address, ipaddress.IPv4Address):
122+ return str(address)
123+
124+ if not address.ipv4_mapped:
125+ # There's no standard defining the behaviour of PASV with
126+ # IPv6, so just claim it as unimplemented. (Some servers
127+ # return something like '0,0,0,0' in the host part of the
128+ # response in order that at least clients that ignore the
129+ # host part can work, and if it becomes necessary then we
130+ # could do that too.)
131+ return defer.fail(ftp.CmdNotImplementedError('PASV'))
132+
133+ return str(address.ipv4_mapped)
134+
135 def ftp_PASV(self):
136 """
137 Request for a passive connection
138@@ -215,24 +250,7 @@ class FTPWithEPSV(ftp.FTP):
139 return defer.fail(ftp.BadCmdSequenceError(
140 'may not send PASV after EPSV ALL'))
141
142- host = self.transport.getHost().host
143- try:
144- address = ipaddress.IPv6Address(six.ensure_text(host))
145- except ipaddress.AddressValueError:
146- pass
147- else:
148- if address.ipv4_mapped is not None:
149- # IPv4-mapped addresses are usable, but we need to make sure
150- # they're encoded as IPv4 in the response.
151- host = str(address.ipv4_mapped)
152- else:
153- # There's no standard defining the behaviour of PASV with
154- # IPv6, so just claim it as unimplemented. (Some servers
155- # return something like '0,0,0,0' in the host part of the
156- # response in order that at least clients that ignore the
157- # host part can work, and if it becomes necessary then we
158- # could do that too.)
159- return defer.fail(ftp.CmdNotImplementedError('PASV'))
160+ host = self.getHostAddress()
161
162 # if we have a DTP port set up, lose it.
163 if self.dtpFactory is not None:
164@@ -357,6 +375,16 @@ class FTPWithEPSV(ftp.FTP):
165 return self.dtpFactory.deferred.addCallbacks(connected, connFailed)
166
167
168+class FTPFactoryWithProxy(ftp.FTPFactory):
169+ # XXX ines-almeida 2023-10-13 ideally, we wouldn't need this class.
170+ # If we decide to use the `haproxy` wrapper (see twisted.protocols.haproxy)
171+ # we wouldn't need to pass the `proxy_ipv4_address` address.
172+ # For future reference, inserting "haproxy:" to the start of `strport`
173+ # FTPServiceFactory.makeFTPService() wraps the endpoint automatically.
174+
175+ proxy_ipv4_address = None
176+
177+
178 class FTPServiceFactory(service.Service):
179 """A factory that makes an `FTPService`"""
180
181@@ -368,11 +396,12 @@ class FTPServiceFactory(service.Service):
182 idle_timeout,
183 min_passive_port,
184 max_passive_port,
185+ proxy_ipv4_address=None,
186 ):
187 realm = FTPRealm(root, temp_dir)
188 portal = Portal(realm)
189 portal.registerChecker(AccessCheck())
190- factory = ftp.FTPFactory(portal)
191+ factory = FTPFactoryWithProxy(portal)
192
193 factory.tld = root
194 factory.protocol = FTPWithEPSV
195@@ -384,12 +413,20 @@ class FTPServiceFactory(service.Service):
196 min_passive_port, max_passive_port
197 )
198
199+ factory.proxy_ipv4_address = proxy_ipv4_address
200+
201 self.ftpfactory = factory
202 self.portno = port
203
204 @staticmethod
205 def makeFTPService(
206- port, root, temp_dir, idle_timeout, min_passive_port, max_passive_port
207+ port,
208+ root,
209+ temp_dir,
210+ idle_timeout,
211+ min_passive_port,
212+ max_passive_port,
213+ proxy_ipv4_address=None,
214 ):
215 if port.isdigit():
216 strport = "tcp6:%s" % port
217@@ -402,5 +439,6 @@ class FTPServiceFactory(service.Service):
218 idle_timeout,
219 min_passive_port,
220 max_passive_port,
221+ proxy_ipv4_address,
222 )
223 return strports.service(strport, factory.ftpfactory)

Subscribers

People subscribed via source and target branches

to all changes: