Browse code

Introduce same-uid-as and same-gid-as.

Xavier G authored on 14/04/2020 20:30:16
Showing 1 changed files
... ...
@@ -67,6 +67,8 @@ class CombinedFSConfiguration(object):
67 67
 		self.files = conf.get('files', {})
68 68
 		self.uid = int(conf.get('uid', DEFAULT_UID))
69 69
 		self.gid = int(conf.get('gid', DEFAULT_GID))
70
+		self.same_uid_as = conf.get('same-uid-as', None)
71
+		self.same_gid_as = conf.get('same-gid-as', None)
70 72
 		self.dir_mode = read_mode_setting(conf, 'dir_mode', DEFAULT_DIR_MODE)
71 73
 		self.reg_mode = read_mode_setting(conf, 'reg_mode', DEFAULT_REG_MODE)
72 74
 		self.key_mode = read_mode_setting(conf, 'key_mode', DEFAULT_KEY_MODE)
... ...
@@ -171,6 +173,79 @@ class CombinedFS(Operations):
171 173
 	def get_conf(self):
172 174
 		return self.configuration
173 175
 
176
+	# uid/gid-related helpers; in the end, the xid (uid/gid) precedence is:
177
+	#  - filespec/same-xid-as
178
+	#  - filespec/xid
179
+	#  - conf/same-xid-as
180
+	#  - conf/xid
181
+	#  - DEFAULT_XID
182
+	def get_uid_gid(self, conf, filespec=None):
183
+		"""
184
+		Just-get-it-done wrapper around get_{uid,gid}_{global,for_filespec}.
185
+		"""
186
+		stats = {}
187
+		if filespec is None:
188
+			return self.get_uid_global(conf, stats), self.get_gid_global(conf, stats)
189
+		return self.get_uid_for_filespec(conf, filespec, stats), self.get_gid_for_filespec(conf, filespec, stats)
190
+
191
+	def get_uid_for_filespec(self, conf, filespec, stats):
192
+		"""
193
+		File-specific uid selection mechanism: attempt to use file-specific same-uid-as, falling back on
194
+		file-specific uid, falling back on global uid selection mechanism.
195
+		"""
196
+		uid = self.get_stat_attr(filespec.get('same-uid-as', None), 'st_uid', filespec.get('uid', None), stats)
197
+		if uid is None:
198
+			uid = self.get_uid_global(conf, stats)
199
+		return uid
200
+
201
+	def get_gid_for_filespec(self, conf, filespec, stats):
202
+		"""
203
+		File-specific gid selection mechanism: attempt to use file-specific same-gid-as, falling back on
204
+		file-specific gid, falling back on global gid selection mechanism.
205
+		"""
206
+		gid = self.get_stat_attr(filespec.get('same-gid-as', None), 'st_gid', filespec.get('gid', None), stats)
207
+		if gid is None:
208
+			gid = self.get_gid_global(conf, stats)
209
+		return gid
210
+
211
+	def get_uid_global(self, conf, stats):
212
+		"""
213
+		Global uid selection mechanism: attempt to use same-uid-as, falling back on uid.
214
+		"""
215
+		return self.get_stat_attr(conf.same_uid_as, 'st_uid', conf.uid, stats)
216
+
217
+	def get_gid_global(self, conf, stats):
218
+		"""
219
+		Global gid selection mechanism: attempt to use same-gid-as, falling back on gid.
220
+		"""
221
+		return self.get_stat_attr(conf.same_gid_as, 'st_gid', conf.gid, stats)
222
+
223
+	def get_stat_attr(self, path, attr, default, stats):
224
+		"""
225
+		Stat path and return the request attribute, or the default value if something goes wrong.
226
+		"""
227
+		if path is None:
228
+			return default
229
+		try:
230
+			return getattr(self.get_stat(path, stats), attr)
231
+		except:
232
+			return default
233
+
234
+	def get_stat(self, path, stats):
235
+		"""
236
+		Simple wrapper around os.stat() that uses a dict to implement some basic caching (for the sake of
237
+		uid/gid consistency, not actually for performance). Return either None or a stat structure.
238
+		Should throw no exceptions as long as stats is provided.
239
+		"""
240
+		stat = stats.get(path)
241
+		if stat is None:
242
+			try:
243
+				 stats[path] = stat = os.stat(path)
244
+			except:
245
+				pass
246
+		return stat
247
+	# End of uid/gid-related helpers
248
+
174 249
 	def iterate_paths(self, func, paths):
175 250
 		for filepath in paths:
176 251
 			try:
... ...
@@ -215,10 +290,11 @@ class CombinedFS(Operations):
215 290
 			return None
216 291
 
217 292
 	def handle_reload_getattr(self, conf, fh):
293
+		uid, gid = self.get_uid_gid(conf)
218 294
 		return {
219 295
 			'st_nlink': 1,
220
-			'st_uid': conf.uid,
221
-			'st_gid': conf.gid,
296
+			'st_uid': uid,
297
+			'st_gid': gid,
222 298
 			'st_size': RELOAD_FILESIZE,
223 299
 			'st_mode': stat.S_IFREG | conf.key_mode,
224 300
 		}
... ...
@@ -261,14 +337,16 @@ class CombinedFS(Operations):
261 337
 						dir_attrs[prop] = 0
262 338
 				else:
263 339
 					raise
264
-			dir_attrs['st_uid'] = conf.uid
265
-			dir_attrs['st_gid'] = conf.gid
340
+			uid, gid = self.get_uid_gid(conf)
341
+			dir_attrs['st_uid'] = uid
342
+			dir_attrs['st_gid'] = gid
266 343
 			dir_attrs['st_mode'] = stat.S_IFDIR | conf.dir_mode
267 344
 			return dir_attrs
345
+		uid, gid = self.get_uid_gid(conf, file_spec)
268 346
 		attrs = {
269 347
 			'st_nlink': 1,
270
-			'st_uid': file_spec.get('uid', conf.uid),
271
-			'st_gid': file_spec.get('gid', conf.gid),
348
+			'st_uid': uid,
349
+			'st_gid': gid,
272 350
 			'st_size': 0,
273 351
 		}
274 352
 		def_mode = conf.reg_mode
Browse code

Fix trailing whitespace.

Xavier G authored on 13/04/2020 12:04:44
Showing 1 changed files
... ...
@@ -27,7 +27,7 @@ DEFAULT_ROOT = '/etc/letsencrypt/live'
27 27
 DEFAULT_CERT_FILTER = False
28 28
 DEFAULT_WHITELIST = True
29 29
 DEFAULT_CERT_PATTERN = '.'
30
-DEFAULT_SEPARATOR = '/' 
30
+DEFAULT_SEPARATOR = '/'
31 31
 DEFAULT_UID = 0
32 32
 DEFAULT_GID = 0
33 33
 DEFAULT_DIR_MODE = 0o555
Browse code

Keep only directories when looking up certificates.

It appears /etc/letsencrypt/live is liable to contain a README file.
Although it can be filtered out using the certificate filter feature, it is
simpler to focus on directories and thus ignore files.

Xavier G authored on 11/04/2020 18:36:22
Showing 1 changed files
... ...
@@ -161,6 +161,13 @@ class CombinedFS(Operations):
161 161
 		return dict((key, getattr(st, key)) for key in ('st_atime', 'st_ctime',
162 162
 		      'st_gid', 'st_mode', 'st_mtime', 'st_nlink', 'st_size', 'st_uid'))
163 163
 
164
+	def certificates(self, conf):
165
+		for dentry in os.listdir(conf.root):
166
+			if conf.filter_cert(dentry):
167
+				fullpath = os.path.join(conf.root, dentry)
168
+				if os.path.isdir(fullpath):
169
+					yield dentry
170
+
164 171
 	def get_conf(self):
165 172
 		return self.configuration
166 173
 
... ...
@@ -311,7 +318,7 @@ class CombinedFS(Operations):
311 318
 		if not cert:
312 319
 			# Top-level directory
313 320
 			flat_mode = conf.separator != '/'
314
-			for cert in (d for d in os.listdir(conf.root) if conf.filter_cert(d)):
321
+			for cert in self.certificates(conf):
315 322
 				if flat_mode:
316 323
 					for filename in conf.files:
317 324
 						yield cert + conf.separator + filename, reg_attrs, 0
Browse code

Implement reload.

Xavier G authored on 11/04/2020 14:44:50
Showing 1 changed files
... ...
@@ -34,6 +34,12 @@ DEFAULT_DIR_MODE = 0o555
34 34
 DEFAULT_REG_MODE = 0o444
35 35
 DEFAULT_KEY_MODE = 0o400
36 36
 DEFAULT_SENSITIVE_PATTERN = '/privkey.pem$'
37
+
38
+RELOAD_PATH = '/reload'
39
+RELOAD_MSG_OK   = b'reload ok\n'
40
+RELOAD_MSG_FAIL = b'reload fail\n'
41
+RELOAD_FILESIZE = max(len(RELOAD_MSG_OK), len(RELOAD_MSG_FAIL))
42
+
37 43
 TIME_PROPS = ('st_atime', 'st_ctime', 'st_mtime')
38 44
 
39 45
 def read_mode_setting(obj, key, default):
... ...
@@ -188,6 +194,36 @@ class CombinedFS(Operations):
188 194
 			self.filedesc[new_fd_index] = file_descriptor
189 195
 		return new_fd_index
190 196
 
197
+	def reload(self, conf=None):
198
+		try:
199
+			if conf is None:
200
+				conf = self.get_conf()
201
+			new_conf = CombinedFSConfiguration(conf.path)
202
+			# Assuming CPython, this should result in a single STORE_ATTR opcode.
203
+			# Since this class features no __setattr__ implementation, the
204
+			# resulting execution should be atomic.
205
+			self.configuration = new_conf
206
+			return new_conf
207
+		except:
208
+			return None
209
+
210
+	def handle_reload_getattr(self, conf, fh):
211
+		return {
212
+			'st_nlink': 1,
213
+			'st_uid': conf.uid,
214
+			'st_gid': conf.gid,
215
+			'st_size': RELOAD_FILESIZE,
216
+			'st_mode': stat.S_IFREG | conf.key_mode,
217
+		}
218
+
219
+	def handle_reload_open(self, conf, flags):
220
+		new_conf = self.reload(conf)
221
+		new_fd = {
222
+			'conf': conf if new_conf is None else new_conf,
223
+			'data': RELOAD_MSG_FAIL if new_conf is None else RELOAD_MSG_OK,
224
+		}
225
+		return self.register_fd(new_fd)
226
+
191 227
 	# Filesystem methods
192 228
 
193 229
 	def access(self, path, mode):
... ...
@@ -202,10 +238,22 @@ class CombinedFS(Operations):
202 238
 
203 239
 	def getattr(self, path, fh=None):
204 240
 		conf = self.get_conf()
241
+		if path == RELOAD_PATH:
242
+			return self.handle_reload_getattr(conf, fh)
205 243
 		cert, filename, file_spec = conf.analyse_path(path)
206 244
 		if filename is None: # Directory
207 245
 			full_path = os.path.join(conf.root, path.lstrip('/'))
208
-			dir_attrs = self.attributes(full_path)
246
+			try:
247
+				dir_attrs = self.attributes(full_path)
248
+			except OSError as ose:
249
+				if ose.errno == errno.ENOENT and path == '/':
250
+					# Non-existent "live" directory, most likely a misconf,
251
+					# fake it to preserve access to RELOAD_PATH:
252
+					dir_attrs = {'st_nlink': 2, 'st_size': 4096}
253
+					for prop in TIME_PROPS:
254
+						dir_attrs[prop] = 0
255
+				else:
256
+					raise
209 257
 			dir_attrs['st_uid'] = conf.uid
210 258
 			dir_attrs['st_gid'] = conf.gid
211 259
 			dir_attrs['st_mode'] = stat.S_IFDIR | conf.dir_mode
... ...
@@ -276,6 +324,8 @@ class CombinedFS(Operations):
276 324
 
277 325
 	def open(self, path, flags):
278 326
 		conf = self.get_conf()
327
+		if path == RELOAD_PATH:
328
+			return self.handle_reload_open(conf, flags)
279 329
 		cert, filename, file_spec = conf.analyse_path(path)
280 330
 		if not cert or not filename:
281 331
 			raise FuseOSError(errno.ENOENT)
Browse code

Fix a bug triggered when a real filepath appears more than once in a virtual file.

Xavier G authored on 11/04/2020 13:34:54
Showing 1 changed files
... ...
@@ -225,11 +225,16 @@ class CombinedFS(Operations):
225 225
 				attrs[prop] = getattr(root_stats, prop)
226 226
 			attrs['st_mode'] = stat.S_IFREG | read_mode_setting(file_spec, 'mode', def_mode)
227 227
 			return attrs
228
+		# One array to hold the actual, successive filepaths, one dict to hold
229
+		# the latest stat() result for each file:
230
+		filepaths = []
228 231
 		stats = {}
229 232
 		def stat_file(path):
230 233
 			stats[path] = os.stat(path)
234
+			filepaths.append(path)
231 235
 		self.iterate_paths(stat_file, paths)
232
-		for filepath, stat_obj in stats.items():
236
+		for filepath in filepaths:
237
+			stat_obj = stats[filepath]
233 238
 			# Pick the highest/latest value for access/change/modification times:
234 239
 			for prop in TIME_PROPS:
235 240
 				prop_val = getattr(stat_obj, prop)
Browse code

Introduce register_fd().

Xavier G authored on 11/04/2020 13:33:41
Showing 1 changed files
... ...
@@ -181,6 +181,13 @@ class CombinedFS(Operations):
181 181
 			except OSError as ose:
182 182
 				raise FuseOSError(ose.errno)
183 183
 
184
+	def register_fd(self, file_descriptor):
185
+		with self.filedesc_lock:
186
+			self.filedesc_index += 1
187
+			new_fd_index = self.filedesc_index
188
+			self.filedesc[new_fd_index] = file_descriptor
189
+		return new_fd_index
190
+
184 191
 	# Filesystem methods
185 192
 
186 193
 	def access(self, path, mode):
... ...
@@ -274,11 +281,7 @@ class CombinedFS(Operations):
274 281
 			'filename': filename,
275 282
 			'file_spec': file_spec,
276 283
 		}
277
-		with self.filedesc_lock:
278
-			self.filedesc_index += 1
279
-			new_fd_index = self.filedesc_index
280
-			self.filedesc[new_fd_index] = new_fd
281
-		return new_fd_index
284
+		return self.register_fd(new_fd)
282 285
 
283 286
 	def read(self, path, length, offset, fh):
284 287
 		filedesc = self.filedesc.get(fh)
Browse code

Move configuration to a separate object.

Xavier G authored on 11/04/2020 11:04:38
Showing 1 changed files
... ...
@@ -36,8 +36,23 @@ DEFAULT_KEY_MODE = 0o400
36 36
 DEFAULT_SENSITIVE_PATTERN = '/privkey.pem$'
37 37
 TIME_PROPS = ('st_atime', 'st_ctime', 'st_mtime')
38 38
 
39
-class CombinedFS(Operations):
40
-	def __init__(self, conf):
39
+def read_mode_setting(obj, key, default):
40
+	try:
41
+		return int(obj[key], 8)
42
+	except (KeyError, ValueError):
43
+		return default
44
+
45
+class CombinedFSConfiguration(object):
46
+	def __init__(self, conf_path):
47
+		self.read_conf(conf_path)
48
+
49
+	def read_conf(self, conf_path):
50
+		with open(conf_path) as conf_file:
51
+			conf = yaml.load(conf_file.read())
52
+			self.apply_conf(conf)
53
+			self.path = conf_path
54
+
55
+	def apply_conf(self, conf):
41 56
 		self.root = conf.get('letsencrypt_live', DEFAULT_ROOT)
42 57
 		self.filter = conf.get('cert_filter', DEFAULT_CERT_FILTER)
43 58
 		self.whitelist = conf.get('cert_whitelist', DEFAULT_WHITELIST)
... ...
@@ -46,27 +61,16 @@ class CombinedFS(Operations):
46 61
 		self.files = conf.get('files', {})
47 62
 		self.uid = int(conf.get('uid', DEFAULT_UID))
48 63
 		self.gid = int(conf.get('gid', DEFAULT_GID))
49
-		self.dir_mode = self.read_mode_setting(conf, 'dir_mode', DEFAULT_DIR_MODE)
50
-		self.reg_mode = self.read_mode_setting(conf, 'reg_mode', DEFAULT_REG_MODE)
51
-		self.key_mode = self.read_mode_setting(conf, 'key_mode', DEFAULT_KEY_MODE)
64
+		self.dir_mode = read_mode_setting(conf, 'dir_mode', DEFAULT_DIR_MODE)
65
+		self.reg_mode = read_mode_setting(conf, 'reg_mode', DEFAULT_REG_MODE)
66
+		self.key_mode = read_mode_setting(conf, 'key_mode', DEFAULT_KEY_MODE)
52 67
 		self.sensitive_pattern = conf.get('sensitive_pattern', DEFAULT_SENSITIVE_PATTERN)
53
-		# File descriptor management:
54
-		self.filedesc_lock = threading.Lock()
55
-		self.filedesc_index = 0
56
-		self.filedesc = {}
57 68
 		# Compile regexes:
58 69
 		self.sensitive_pattern_re = re.compile(self.sensitive_pattern)
59 70
 		if self.filter:
60 71
 			self.pattern_re = re.compile(self.pattern)
61 72
 
62 73
 	# Helpers:
63
-
64
-	def read_mode_setting(self, obj, key, default):
65
-		try:
66
-			return int(obj[key], 8)
67
-		except (KeyError, ValueError):
68
-			return default
69
-
70 74
 	def filter_cert(self, cert):
71 75
 		if not self.filter:
72 76
 			return True
... ...
@@ -117,11 +121,6 @@ class CombinedFS(Operations):
117 121
 				raise FuseOSError(errno.ENOENT)
118 122
 		return cert, filename, file_spec
119 123
 
120
-	def attributes(self, full_path):
121
-		st = os.lstat(full_path)
122
-		return dict((key, getattr(st, key)) for key in ('st_atime', 'st_ctime',
123
-		      'st_gid', 'st_mode', 'st_mtime', 'st_nlink', 'st_size', 'st_uid'))
124
-
125 124
 	def expand_path(self, cert, path):
126 125
 		expanded_path = path.replace('${cert}', cert)
127 126
 		if not expanded_path.startswith('/'):
... ...
@@ -141,6 +140,24 @@ class CombinedFS(Operations):
141 140
 	def is_sensitive_file(self, filepath):
142 141
 		return self.sensitive_pattern_re.search(filepath)
143 142
 
143
+class CombinedFS(Operations):
144
+	def __init__(self, conf_path):
145
+		# Configuration:
146
+		self.configuration = CombinedFSConfiguration(conf_path)
147
+		# File descriptor management:
148
+		self.filedesc_lock = threading.Lock()
149
+		self.filedesc_index = 0
150
+		self.filedesc = {}
151
+
152
+	# Helpers:
153
+	def attributes(self, full_path):
154
+		st = os.lstat(full_path)
155
+		return dict((key, getattr(st, key)) for key in ('st_atime', 'st_ctime',
156
+		      'st_gid', 'st_mode', 'st_mtime', 'st_nlink', 'st_size', 'st_uid'))
157
+
158
+	def get_conf(self):
159
+		return self.configuration
160
+
144 161
 	def iterate_paths(self, func, paths):
145 162
 		for filepath in paths:
146 163
 			try:
... ...
@@ -177,28 +194,29 @@ class CombinedFS(Operations):
177 194
 		raise FuseOSError(errno.ENOTSUP)
178 195
 
179 196
 	def getattr(self, path, fh=None):
180
-		cert, filename, file_spec = self.analyse_path(path)
197
+		conf = self.get_conf()
198
+		cert, filename, file_spec = conf.analyse_path(path)
181 199
 		if filename is None: # Directory
182
-			full_path = os.path.join(self.root, path.lstrip('/'))
200
+			full_path = os.path.join(conf.root, path.lstrip('/'))
183 201
 			dir_attrs = self.attributes(full_path)
184
-			dir_attrs['st_uid'] = self.uid
185
-			dir_attrs['st_gid'] = self.gid
186
-			dir_attrs['st_mode'] = stat.S_IFDIR | self.dir_mode
202
+			dir_attrs['st_uid'] = conf.uid
203
+			dir_attrs['st_gid'] = conf.gid
204
+			dir_attrs['st_mode'] = stat.S_IFDIR | conf.dir_mode
187 205
 			return dir_attrs
188 206
 		attrs = {
189 207
 			'st_nlink': 1,
190
-			'st_uid': file_spec.get('uid', self.uid),
191
-			'st_gid': file_spec.get('gid', self.gid),
208
+			'st_uid': file_spec.get('uid', conf.uid),
209
+			'st_gid': file_spec.get('gid', conf.gid),
192 210
 			'st_size': 0,
193 211
 		}
194
-		def_mode = self.reg_mode
195
-		paths = self.get_paths(cert, file_spec)
212
+		def_mode = conf.reg_mode
213
+		paths = conf.get_paths(cert, file_spec)
196 214
 		if not paths:
197 215
 			# Virtual empty file:
198
-			root_stats = os.stat(self.root)
216
+			root_stats = os.stat(conf.root)
199 217
 			for prop in TIME_PROPS:
200 218
 				attrs[prop] = getattr(root_stats, prop)
201
-			attrs['st_mode'] = stat.S_IFREG | self.read_mode_setting(file_spec, 'mode', def_mode)
219
+			attrs['st_mode'] = stat.S_IFREG | read_mode_setting(file_spec, 'mode', def_mode)
202 220
 			return attrs
203 221
 		stats = {}
204 222
 		def stat_file(path):
... ...
@@ -213,13 +231,14 @@ class CombinedFS(Operations):
213 231
 			# Add up sizes:
214 232
 			attrs['st_size'] += stat_obj.st_size
215 233
 			# Lower permissions if necessary:
216
-			if self.is_sensitive_file(filepath):
217
-				def_mode = self.key_mode
218
-		attrs['st_mode'] = stat.S_IFREG | self.read_mode_setting(file_spec, 'mode', def_mode)
234
+			if conf.is_sensitive_file(filepath):
235
+				def_mode = conf.key_mode
236
+		attrs['st_mode'] = stat.S_IFREG | read_mode_setting(file_spec, 'mode', def_mode)
219 237
 		return attrs
220 238
 
221 239
 	def readdir(self, path, fh):
222
-		cert, filename, _ = self.analyse_path(path)
240
+		conf = self.get_conf()
241
+		cert, filename, _ = conf.analyse_path(path)
223 242
 		# Deal only with directories:
224 243
 		if filename:
225 244
 			raise FuseOSError(errno.ENOTDIR)
... ...
@@ -231,24 +250,26 @@ class CombinedFS(Operations):
231 250
 		yield '..', dir_attrs, 0
232 251
 		if not cert:
233 252
 			# Top-level directory
234
-			flat_mode = self.separator != '/'
235
-			for cert in (d for d in os.listdir(self.root) if self.filter_cert(d)):
253
+			flat_mode = conf.separator != '/'
254
+			for cert in (d for d in os.listdir(conf.root) if conf.filter_cert(d)):
236 255
 				if flat_mode:
237
-					for filename in self.files:
238
-						yield cert + self.separator + filename, reg_attrs, 0
256
+					for filename in conf.files:
257
+						yield cert + conf.separator + filename, reg_attrs, 0
239 258
 				else:
240 259
 					yield cert, dir_attrs, 0
241 260
 		else:
242 261
 			# Second-level directory
243
-			for filename in self.files:
262
+			for filename in conf.files:
244 263
 				yield filename, reg_attrs, 0
245 264
 
246 265
 	def open(self, path, flags):
247
-		cert, filename, file_spec = self.analyse_path(path)
266
+		conf = self.get_conf()
267
+		cert, filename, file_spec = conf.analyse_path(path)
248 268
 		if not cert or not filename:
249 269
 			raise FuseOSError(errno.ENOENT)
250 270
 		# Being a read-only filesystem spares us the need to check most flags.
251 271
 		new_fd = {
272
+			'conf': conf,
252 273
 			'cert': cert,
253 274
 			'filename': filename,
254 275
 			'file_spec': file_spec,
... ...
@@ -261,11 +282,13 @@ class CombinedFS(Operations):
261 282
 
262 283
 	def read(self, path, length, offset, fh):
263 284
 		filedesc = self.filedesc.get(fh)
285
+		# Use the same configuration as open() when it created the file descriptor:
286
+		conf = filedesc['conf']
264 287
 		if filedesc is None:
265 288
 			raise FuseOSError(errno.EBADF)
266 289
 		data = filedesc.get('data')
267 290
 		if data is None:
268
-			paths = self.get_paths(filedesc['cert'], filedesc['file_spec'])
291
+			paths = conf.get_paths(filedesc['cert'], filedesc['file_spec'])
269 292
 			data = {'data': bytes() }
270 293
 			def concatenate(path):
271 294
 				data['data'] += open(path, 'rb').read()
... ...
@@ -282,7 +305,7 @@ class CombinedFS(Operations):
282 305
 		# pure Python.
283 306
 
284 307
 	def statfs(self, path):
285
-		stv = os.statvfs(self.root)
308
+		stv = os.statvfs(self.get_conf().root)
286 309
 		return dict((key, getattr(stv, key)) for key in ('f_bavail', 'f_bfree',
287 310
 			'f_blocks', 'f_bsize', 'f_favail', 'f_ffree', 'f_files', 'f_flag',
288 311
 			'f_frsize', 'f_namemax'))
... ...
@@ -293,10 +316,7 @@ class CombinedFS(Operations):
293 316
 		raise FuseOSError(errno.EINVAL)
294 317
 
295 318
 def main(conf_path, mountpoint, foreground):
296
-	conf = {}
297
-	with open(conf_path) as conf_file:
298
-		conf = yaml.load(conf_file.read())
299
-	FUSE(CombinedFS(conf), mountpoint, foreground=foreground, ro=True, default_permissions=True, allow_other=True)
319
+	FUSE(CombinedFS(conf_path), mountpoint, foreground=foreground, ro=True, default_permissions=True, allow_other=True)
300 320
 
301 321
 if __name__ == '__main__':
302 322
 	parser = argparse.ArgumentParser(description='Expose a transformed, version of Let\'s Encrypt / Certbot\'s "live" directory')
Browse code

Add support for dirent->d_type.

It turns out that a certain TLS frontend, the name of which starts with "Hit"
and ends with "ch", leverages dirent->d_type without handling DT_UNKNOWN...

Xavier G authored on 05/04/2020 21:52:52
Showing 1 changed files
... ...
@@ -223,22 +223,25 @@ class CombinedFS(Operations):
223 223
 		# Deal only with directories:
224 224
 		if filename:
225 225
 			raise FuseOSError(errno.ENOTDIR)
226
+		# Extra attributes, just what it takes to support dirent->d_type:
227
+		dir_attrs = {'st_mode': stat.S_IFDIR }
228
+		reg_attrs = {'st_mode': stat.S_IFREG }
226 229
 		# Yield common directory entries:
227
-		yield '.'
228
-		yield '..'
230
+		yield '.', dir_attrs, 0
231
+		yield '..', dir_attrs, 0
229 232
 		if not cert:
230 233
 			# Top-level directory
231 234
 			flat_mode = self.separator != '/'
232 235
 			for cert in (d for d in os.listdir(self.root) if self.filter_cert(d)):
233 236
 				if flat_mode:
234 237
 					for filename in self.files:
235
-						yield cert + self.separator + filename
238
+						yield cert + self.separator + filename, reg_attrs, 0
236 239
 				else:
237
-					yield cert
240
+					yield cert, dir_attrs, 0
238 241
 		else:
239 242
 			# Second-level directory
240 243
 			for filename in self.files:
241
-				yield filename
244
+				yield filename, reg_attrs, 0
242 245
 
243 246
 	def open(self, path, flags):
244 247
 		cert, filename, file_spec = self.analyse_path(path)
Browse code

Parse arguments for compatibility with mount.

Xavier G authored on 05/04/2020 19:20:40
Showing 1 changed files
... ...
@@ -11,6 +11,7 @@ import sys
11 11
 import stat
12 12
 import yaml
13 13
 import errno
14
+import argparse
14 15
 import threading
15 16
 
16 17
 # Excerpt from `apt show python3-fusepy`:
... ...
@@ -288,11 +289,17 @@ class CombinedFS(Operations):
288 289
 		# return EINVAL:
289 290
 		raise FuseOSError(errno.EINVAL)
290 291
 
291
-def main(conf_path, mountpoint):
292
+def main(conf_path, mountpoint, foreground):
292 293
 	conf = {}
293 294
 	with open(conf_path) as conf_file:
294 295
 		conf = yaml.load(conf_file.read())
295
-	FUSE(CombinedFS(conf), mountpoint, foreground=True, ro=True, default_permissions=True, allow_other=True)
296
+	FUSE(CombinedFS(conf), mountpoint, foreground=foreground, ro=True, default_permissions=True, allow_other=True)
296 297
 
297 298
 if __name__ == '__main__':
298
-	main(sys.argv[1], sys.argv[2])
299
+	parser = argparse.ArgumentParser(description='Expose a transformed, version of Let\'s Encrypt / Certbot\'s "live" directory')
300
+	parser.add_argument('conf_path', help='CombinedFS configuration file')
301
+	parser.add_argument('mountpoint', help='mount point')
302
+	parser.add_argument('-o', dest='options', help='mount options (ignored, only there for compatibility purposes)')
303
+	parser.add_argument('-f', '--foreground', dest='foreground', help='run in the foreground', action='store_true')
304
+	args = parser.parse_args()
305
+	main(args.conf_path, args.mountpoint, args.foreground)
Browse code

Add copyright and license.

Xavier G authored on 05/04/2020 15:07:57
Showing 1 changed files
... ...
@@ -1,5 +1,10 @@
1 1
 #!/usr/bin/env python3
2 2
 
3
+# Copyright © 2020 Xavier G. <xavier.combinedfs@kindwolf.org>
4
+# This work is free. You can redistribute it and/or modify it under the
5
+# terms of the Do What The Fuck You Want To Public License, Version 2,
6
+# as published by Sam Hocevar. See the COPYING file for more details.
7
+
3 8
 import os
4 9
 import re
5 10
 import sys
Browse code

Finally choose not to check flags in open().

Xavier G authored on 05/04/2020 02:12:41
Showing 1 changed files
... ...
@@ -238,7 +238,7 @@ class CombinedFS(Operations):
238 238
 		cert, filename, file_spec = self.analyse_path(path)
239 239
 		if not cert or not filename:
240 240
 			raise FuseOSError(errno.ENOENT)
241
-		# FIXME take flags into account
241
+		# Being a read-only filesystem spares us the need to check most flags.
242 242
 		new_fd = {
243 243
 			'cert': cert,
244 244
 			'filename': filename,
Browse code

Use a lock to manipulate file descriptors.

Xavier G authored on 05/04/2020 02:03:26
Showing 1 changed files
... ...
@@ -6,6 +6,7 @@ import sys
6 6
 import stat
7 7
 import yaml
8 8
 import errno
9
+import threading
9 10
 
10 11
 # Excerpt from `apt show python3-fusepy`:
11 12
 #   Due to a name clash with the existing API-incompatible python-fuse package,
... ...
@@ -43,6 +44,8 @@ class CombinedFS(Operations):
43 44
 		self.reg_mode = self.read_mode_setting(conf, 'reg_mode', DEFAULT_REG_MODE)
44 45
 		self.key_mode = self.read_mode_setting(conf, 'key_mode', DEFAULT_KEY_MODE)
45 46
 		self.sensitive_pattern = conf.get('sensitive_pattern', DEFAULT_SENSITIVE_PATTERN)
47
+		# File descriptor management:
48
+		self.filedesc_lock = threading.Lock()
46 49
 		self.filedesc_index = 0
47 50
 		self.filedesc = {}
48 51
 		# Compile regexes:
... ...
@@ -236,17 +239,21 @@ class CombinedFS(Operations):
236 239
 		if not cert or not filename:
237 240
 			raise FuseOSError(errno.ENOENT)
238 241
 		# FIXME take flags into account
239
-		# FIXME the code below feels unsafe
240
-		self.filedesc_index += 1
241
-		self.filedesc[self.filedesc_index] = {
242
+		new_fd = {
242 243
 			'cert': cert,
243 244
 			'filename': filename,
244 245
 			'file_spec': file_spec,
245 246
 		}
246
-		return self.filedesc_index
247
+		with self.filedesc_lock:
248
+			self.filedesc_index += 1
249
+			new_fd_index = self.filedesc_index
250
+			self.filedesc[new_fd_index] = new_fd
251
+		return new_fd_index
247 252
 
248 253
 	def read(self, path, length, offset, fh):
249
-		filedesc = self.filedesc[fh]
254
+		filedesc = self.filedesc.get(fh)
255
+		if filedesc is None:
256
+			raise FuseOSError(errno.EBADF)
250 257
 		data = filedesc.get('data')
251 258
 		if data is None:
252 259
 			paths = self.get_paths(filedesc['cert'], filedesc['file_spec'])
... ...
@@ -259,8 +266,11 @@ class CombinedFS(Operations):
259 266
 		return read_chunk
260 267
 
261 268
 	def release(self, path, fh):
262
-		# FIXME reset self.filedesc_index at some point?
263
-		del self.filedesc[fh]
269
+		with self.filedesc_lock:
270
+			del self.filedesc[fh]
271
+		# Integers in Python have arbitrary precision, i.e. they are unbounded
272
+		# and thus exempt from overflows as long as they are manipulated in
273
+		# pure Python.
264 274
 
265 275
 	def statfs(self, path):
266 276
 		stv = os.statvfs(self.root)
... ...
@@ -277,7 +287,7 @@ def main(conf_path, mountpoint):
277 287
 	conf = {}
278 288
 	with open(conf_path) as conf_file:
279 289
 		conf = yaml.load(conf_file.read())
280
-	FUSE(CombinedFS(conf), mountpoint, nothreads=True, foreground=True, ro=True, default_permissions=True, allow_other=True)
290
+	FUSE(CombinedFS(conf), mountpoint, foreground=True, ro=True, default_permissions=True, allow_other=True)
281 291
 
282 292
 if __name__ == '__main__':
283 293
 	main(sys.argv[1], sys.argv[2])
Browse code

Remove flush().

Xavier G authored on 05/04/2020 00:44:24
Showing 1 changed files
... ...
@@ -268,9 +268,6 @@ class CombinedFS(Operations):
268 268
 			'f_blocks', 'f_bsize', 'f_favail', 'f_ffree', 'f_files', 'f_flag',
269 269
 			'f_frsize', 'f_namemax'))
270 270
 
271
-	def flush(self, path, fh):
272
-		pass
273
-
274 271
 	def readlink(self, path):
275 272
 		# We never expose any symlink, therefore it should be safe to always
276 273
 		# return EINVAL:
Browse code

access(): return ENOTSUP, just in case.

Xavier G authored on 05/04/2020 00:43:53
Showing 1 changed files
... ...
@@ -165,7 +165,7 @@ class CombinedFS(Operations):
165 165
 		Since this program enforces default_permissions, this method will never
166 166
 		be called, which makes it dead simple to implement.
167 167
 		"""
168
-		pass
168
+		raise FuseOSError(errno.ENOTSUP)
169 169
 
170 170
 	def getattr(self, path, fh=None):
171 171
 		cert, filename, file_spec = self.analyse_path(path)
Browse code

Move iterate_paths() in the helpers section.

Xavier G authored on 04/04/2020 22:34:43
Showing 1 changed files
... ...
@@ -132,19 +132,6 @@ class CombinedFS(Operations):
132 132
 	def is_sensitive_file(self, filepath):
133 133
 		return self.sensitive_pattern_re.search(filepath)
134 134
 
135
-
136
-	# Filesystem methods
137
-
138
-	def access(self, path, mode):
139
-		"""
140
-		libfuse documentation states:
141
-		  This will be called for the access() system call. If the
142
-		  'default_permissions' mount option is given, this method is not called.
143
-		Since this program enforces default_permissions, this method will never
144
-		be called, which makes it dead simple to implement.
145
-		"""
146
-		pass
147
-
148 135
 	def iterate_paths(self, func, paths):
149 136
 		for filepath in paths:
150 137
 			try:
... ...
@@ -168,6 +155,18 @@ class CombinedFS(Operations):
168 155
 			except OSError as ose:
169 156
 				raise FuseOSError(ose.errno)
170 157
 
158
+	# Filesystem methods
159
+
160
+	def access(self, path, mode):
161
+		"""
162
+		libfuse documentation states:
163
+		  This will be called for the access() system call. If the
164
+		  'default_permissions' mount option is given, this method is not called.
165
+		Since this program enforces default_permissions, this method will never
166
+		be called, which makes it dead simple to implement.
167
+		"""
168
+		pass
169
+
171 170
 	def getattr(self, path, fh=None):
172 171
 		cert, filename, file_spec = self.analyse_path(path)
173 172
 		if filename is None: # Directory
Browse code

Change the way CombinedFS declares itself read-only.

Xavier G authored on 04/04/2020 22:32:45
Showing 1 changed files
... ...
@@ -132,8 +132,6 @@ class CombinedFS(Operations):
132 132
 	def is_sensitive_file(self, filepath):
133 133
 		return self.sensitive_pattern_re.search(filepath)
134 134
 
135
-	def read_only(self):
136
-		raise FuseOSError(errno.EROFS)
137 135
 
138 136
 	# Filesystem methods
139 137
 
... ...
@@ -279,55 +277,11 @@ class CombinedFS(Operations):
279 277
 		# return EINVAL:
280 278
 		raise FuseOSError(errno.EINVAL)
281 279
 
282
-	# Functions that make no sense for a read-only filesystem:
283
-
284
-	def utimens(self, path, times=None):
285
-		return self.read_only()
286
-
287
-	def mknod(self, path, mode, dev):
288
-		return self.read_only()
289
-
290
-	def rmdir(self, path):
291
-		return self.read_only()
292
-
293
-	def mkdir(self, path, mode):
294
-		return self.read_only()
295
-
296
-	def chmod(self, path, mode):
297
-		return self.read_only()
298
-
299
-	def chown(self, path, uid, gid):
300
-		return self.read_only()
301
-
302
-	def unlink(self, path):
303
-		return self.read_only()
304
-
305
-	def symlink(self, name, target):
306
-		return self.read_only()
307
-
308
-	def rename(self, old, new):
309
-		return self.read_only()
310
-
311
-	def link(self, target, name):
312
-		return self.read_only()
313
-
314
-	def create(self, path, mode, fi=None):
315
-		return self.read_only()
316
-
317
-	def write(self, path, buf, offset, fh):
318
-		return self.read_only()
319
-
320
-	def truncate(self, path, length, fh=None):
321
-		return self.read_only()
322
-
323
-	def fsync(self, path, fdatasync, fh):
324
-		return self.read_only()
325
-
326 280
 def main(conf_path, mountpoint):
327 281
 	conf = {}
328 282
 	with open(conf_path) as conf_file:
329 283
 		conf = yaml.load(conf_file.read())
330
-	FUSE(CombinedFS(conf), mountpoint, nothreads=True, foreground=True, default_permissions=True, allow_other=True)
284
+	FUSE(CombinedFS(conf), mountpoint, nothreads=True, foreground=True, ro=True, default_permissions=True, allow_other=True)
331 285
 
332 286
 if __name__ == '__main__':
333 287
 	main(sys.argv[1], sys.argv[2])
Browse code

Introduce fallback paths.

Xavier G authored on 04/04/2020 21:49:03
Showing 1 changed files
... ...
@@ -122,7 +122,8 @@ class CombinedFS(Operations):
122 122
 	def expand_paths(self, cert, paths):
123 123
 		expanded_paths = []
124 124
 		for path in paths:
125
-			expanded_paths.append(self.expand_path(cert, path))
125
+			method = self.expand_paths if type(path) is list else self.expand_path
126
+			expanded_paths.append(method(cert, path))
126 127
 		return expanded_paths
127 128
 
128 129
 	def get_paths(self, cert, file_spec):
... ...
@@ -149,7 +150,23 @@ class CombinedFS(Operations):
149 150
 	def iterate_paths(self, func, paths):
150 151
 		for filepath in paths:
151 152
 			try:
152
-				func(filepath)
153
+				if type(filepath) is list:
154
+					# Array of file paths: look for the first existing path:
155
+					for index, subpath in enumerate(filepath):
156
+						try:
157
+							func(subpath)
158
+							# Still there? The file must exist, exit the loop:
159
+							break
160
+						except OSError as ose:
161
+							if ose.errno == errno.ENOENT and index < len(filepath) - 1:
162
+								# The file does not exist, try the next one, if any:
163
+								continue
164
+							else:
165
+								# Reached the last file path or encountered another error:
166
+								raise
167
+				else:
168
+					# Presumably a regular file path
169
+					func(filepath)
153 170
 			except OSError as ose:
154 171
 				raise FuseOSError(ose.errno)
155 172
 
Browse code

Introduce iterate_paths().

Xavier G authored on 04/04/2020 21:41:09
Showing 1 changed files
... ...
@@ -146,6 +146,13 @@ class CombinedFS(Operations):
146 146
 		"""
147 147
 		pass
148 148
 
149
+	def iterate_paths(self, func, paths):
150
+		for filepath in paths:
151
+			try:
152
+				func(filepath)
153
+			except OSError as ose:
154
+				raise FuseOSError(ose.errno)
155
+
149 156
 	def getattr(self, path, fh=None):
150 157
 		cert, filename, file_spec = self.analyse_path(path)
151 158
 		if filename is None: # Directory
... ...
@@ -171,12 +178,9 @@ class CombinedFS(Operations):
171 178
 			attrs['st_mode'] = stat.S_IFREG | self.read_mode_setting(file_spec, 'mode', def_mode)
172 179
 			return attrs
173 180
 		stats = {}
174
-		for filepath in paths:
175
-			try:
176
-				# DO follow symlinks (stat vs lstat):
177
-				stats[filepath] = os.stat(filepath)
178
-			except OSError as ose:
179
-				raise FuseOSError(ose.errno)
181
+		def stat_file(path):
182
+			stats[path] = os.stat(path)
183
+		self.iterate_paths(stat_file, paths)
180 184
 		for filepath, stat_obj in stats.items():
181 185
 			# Pick the highest/latest value for access/change/modification times:
182 186
 			for prop in TIME_PROPS:
... ...
@@ -231,12 +235,12 @@ class CombinedFS(Operations):
231 235
 		filedesc = self.filedesc[fh]
232 236
 		data = filedesc.get('data')
233 237
 		if data is None:
234
-			data = bytes([])
235 238
 			paths = self.get_paths(filedesc['cert'], filedesc['file_spec'])
236
-			for path in paths:
237
-				with open(path, 'rb') as path_fd:
238
-					data += path_fd.read()
239
-			filedesc['data'] = data
239
+			data = {'data': bytes() }
240
+			def concatenate(path):
241
+				data['data'] += open(path, 'rb').read()
242
+			self.iterate_paths(concatenate, paths)
243
+			filedesc['data'] = data = data['data']
240 244
 		read_chunk = data[offset:offset + length]
241 245
 		return read_chunk
242 246
 
Browse code

Add expand_path().

Xavier G authored on 04/04/2020 21:38:15
Showing 1 changed files
... ...
@@ -113,13 +113,16 @@ class CombinedFS(Operations):
113 113
 		return dict((key, getattr(st, key)) for key in ('st_atime', 'st_ctime',
114 114
 		      'st_gid', 'st_mode', 'st_mtime', 'st_nlink', 'st_size', 'st_uid'))
115 115
 
116
+	def expand_path(self, cert, path):
117
+		expanded_path = path.replace('${cert}', cert)
118
+		if not expanded_path.startswith('/'):
119
+			expanded_path = os.path.join(self.root, cert, expanded_path)
120
+		return expanded_path
121
+
116 122
 	def expand_paths(self, cert, paths):
117 123
 		expanded_paths = []
118 124
 		for path in paths:
119
-			path = path.replace('${cert}', cert)
120
-			if not path.startswith('/'):
121
-				path = os.path.join(self.root, cert, path)
122
-			expanded_paths.append(path)
125
+			expanded_paths.append(self.expand_path(cert, path))
123 126
 		return expanded_paths
124 127
 
125 128
 	def get_paths(self, cert, file_spec):
Browse code

Offer more flexibility regarding permissions.

Xavier G authored on 04/04/2020 18:23:40
Showing 1 changed files
... ...
@@ -21,6 +21,11 @@ DEFAULT_CERT_FILTER = False
21 21
 DEFAULT_WHITELIST = True
22 22
 DEFAULT_CERT_PATTERN = '.'
23 23
 DEFAULT_SEPARATOR = '/' 
24
+DEFAULT_UID = 0
25
+DEFAULT_GID = 0
26
+DEFAULT_DIR_MODE = 0o555
27
+DEFAULT_REG_MODE = 0o444
28
+DEFAULT_KEY_MODE = 0o400
24 29
 DEFAULT_SENSITIVE_PATTERN = '/privkey.pem$'
25 30
 TIME_PROPS = ('st_atime', 'st_ctime', 'st_mtime')
26 31
 
...