def dump(self, f, now=None): """Write the current data to a file handle 'f'.""" if now is None: now = time() try: self._lock.acquire() startTime = self.lastRotation endTime = now print >>f, "========== From %s to %s:" % (formatTime(startTime,1), formatTime(endTime,1)) for event in _EVENTS: count = self.count[event] if len(count) == 0: print >>f, " %s: 0" % event continue elif len(count) == 1 and count.keys()[0] is None: print >>f, " %s: %s" % (event, count[None]) continue print >>f, " %s:" % event total = 0 args = count.keys() args.sort() length = max([ len(str(arg)) for arg in args ]) length = max((length, 10)) fmt = " %"+str(length)+"s: %s" for arg in args: v = count[arg] if arg is None: arg = "{Unknown}" print >>f, fmt % (arg, v) total += v print >>f, fmt % ("Total", total) finally: self._lock.release()
def checkDescriptorConsistency(self, regen=1): """Check whether the server descriptors in this keyring are consistent with the server's configuration. If 'regen' is true, inconsistent descriptors are regenerated.""" identity = None state = [] for _, _, ks in self.keySets: ok = ks.checkConsistency(self.config, 0) if ok == 'good': continue state.append((ok, ks)) if not state: return LOG.warn("Some generated keysets do not match " "current configuration...") for ok, ks in state: va, vu = ks.getLiveness() LOG.warn("Keyset %s (%s--%s):", ks.keyname, formatTime(va, 1), formatTime(vu, 1)) ks.checkConsistency(self.config, 1) if regen and ok == 'bad': if not identity: identity = self.getIdentityKey() ks.regenerateServerDescriptor(self.config, identity)
def checkDescriptorConsistency(self, regen=1): """Check whether the server descriptors in this keyring are consistent with the server's configuration. If 'regen' is true, inconsistent descriptors are regenerated.""" identity = None state = [] for _,_,ks in self.keySets: ok = ks.checkConsistency(self.config, 0) if ok == 'good': continue state.append((ok, ks)) if not state: return LOG.warn("Some generated keysets do not match " "current configuration...") for ok, ks in state: va,vu = ks.getLiveness() LOG.warn("Keyset %s (%s--%s):",ks.keyname,formatTime(va,1), formatTime(vu,1)) ks.checkConsistency(self.config, 1) if regen and ok == 'bad': if not identity: identity = self.getIdentityKey() ks.regenerateServerDescriptor(self.config, identity)
def dump(self, f, now=None): """Write the current data to a file handle 'f'.""" if now is None: now = time() try: self._lock.acquire() startTime = self.lastRotation endTime = now print >> f, "========== From %s to %s:" % (formatTime( startTime, 1), formatTime(endTime, 1)) for event in _EVENTS: count = self.count[event] if len(count) == 0: print >> f, " %s: 0" % event continue elif len(count) == 1 and count.keys()[0] is None: print >> f, " %s: %s" % (event, count[None]) continue print >> f, " %s:" % event total = 0 args = count.keys() args.sort() length = max([len(str(arg)) for arg in args]) length = max((length, 10)) fmt = " %" + str(length) + "s: %s" for arg in args: v = count[arg] if arg is None: arg = "{Unknown}" print >> f, fmt % (arg, v) total += v print >> f, fmt % ("Total", total) finally: self._lock.release()
def regenerateServerDescriptor(self, config, identityKey): """Regenerate the server descriptor for this keyset, keeping the original keys.""" self.load() self.markAsUnpublished() validAt,validUntil = self.getLiveness() LOG.info("Regenerating descriptor for keyset %s (%s--%s)", self.keyname, formatTime(validAt,1), formatTime(validUntil,1)) generateServerDescriptorAndKeys(config, identityKey, self.keyroot, self.keyname, self.hashroot, validAt=validAt, validUntil=validUntil, useServerKeys=1) self.serverinfo = self.validAfter = self.validUntil = None
def deliveryFailed(self, handle, retriable=0, now=None): """Removes a message from the outgoing queue, or requeues it for delivery at a later time. This method should be invoked after the corresponding message has been unsuccessfully delivered.""" assert self.retrySchedule is not None LOG.trace("DeliveryQueue failed to deliver %s from %s", handle, self.qname) try: self._lock.acquire() try: ds = self.store.getMetadata(handle) except KeyError: ds = None except CorruptedFile: return if ds is None: # This should never happen LOG.error_exc(sys.exc_info(), "Handle %s had no state", handle) ds = _DeliveryState(now) ds.setNextAttempt(self.retrySchedule, now) self.store.setMetadata(handle, ds) return if not ds.isPending(): LOG.error("Handle %s was not pending", handle) return last = ds.pending ds.setNonPending() if retriable: # If we can retry the message, update the deliveryState # with the most recent attempt, and see if there's another # attempt in the future. ds.setLastAttempt(last) ds.setNextAttempt(self.retrySchedule, now) if ds.nextAttempt is not None: # There is another scheduled delivery attempt. Remember # it, mark the message sendable again, and save our state. LOG.trace(" (We'll try %s again at %s)", handle, formatTime(ds.nextAttempt, 1)) self.store.setMetadata(handle, ds) return else: assert ds.isRemovable() # Otherwise, fallthrough. # If we reach this point, the message is undeliverable, either # because 'retriable' is false, or because we've run out of # retries. LOG.trace(" (Giving up on %s)", handle) self.removeMessage(handle) finally: self._lock.release()
def getNextKeygen(self): """Return the time (in seconds) when we should next generate keys. If -1 is returned, keygen should occur immediately. """ if not self.keySets: return -1 # Our last current key expires at 'lastExpiry'. lastExpiry = self.keySets[-1][1] # We want to have keys in the directory valid for # PREPUBLICATION_INTERVAL seconds after that, and we assume that # a key takes up to PUBLICATION_LATENCY seconds to make it into the # directory. nextKeygen = lastExpiry - PUBLICATION_LATENCY - PREPUBLICATION_INTERVAL LOG.info("Last expiry at %s; next keygen at %s", formatTime(lastExpiry, 1), formatTime(nextKeygen, 1)) return nextKeygen
def regenerateServerDescriptor(self, config, identityKey): """Regenerate the server descriptor for this keyset, keeping the original keys.""" self.load() self.markAsUnpublished() validAt, validUntil = self.getLiveness() LOG.info("Regenerating descriptor for keyset %s (%s--%s)", self.keyname, formatTime(validAt, 1), formatTime(validUntil, 1)) generateServerDescriptorAndKeys(config, identityKey, self.keyroot, self.keyname, self.hashroot, validAt=validAt, validUntil=validUntil, useServerKeys=1) self.serverinfo = self.validAfter = self.validUntil = None
def getNextKeygen(self): """Return the time (in seconds) when we should next generate keys. If -1 is returned, keygen should occur immediately. """ if not self.keySets: return -1 # Our last current key expires at 'lastExpiry'. lastExpiry = self.keySets[-1][1] # We want to have keys in the directory valid for # PREPUBLICATION_INTERVAL seconds after that, and we assume that # a key takes up to PUBLICATION_LATENCY seconds to make it into the # directory. nextKeygen = lastExpiry - PUBLICATION_LATENCY - PREPUBLICATION_INTERVAL LOG.info("Last expiry at %s; next keygen at %s", formatTime(lastExpiry,1), formatTime(nextKeygen, 1)) return nextKeygen
def getNextKeyRotation(self, curKeys=None): """Calculate the next time at which we should change the set of live keys.""" if self.nextUpdate is None: if curKeys is None: if self.currentKeys is None: curKeys = self.getServerKeysets() else: curKeys = self.currentKeys events = [] curNames = {} # For every current keyset, we'll remove it at keyOverlap # seconds after its stated expiry time. for k in curKeys: va, vu = k.getLiveness() events.append((vu + self.keyOverlap, "RM")) curNames[k.keyname] = 1 # For every other keyset, we'll add it when it becomes valid. for va, vu, k in self.keySets: if curNames.has_key(k.keyname): continue events.append((va, "ADD")) # Which even happens first? events.sort() if not events: LOG.info("No future key rotation events.") self.nextUpdate = sys.maxint return self.nextUpdate self.nextUpdate, eventType = events[0] if eventType == "RM": LOG.info("Next key event: old key is removed at %s", formatTime(self.nextUpdate, 1)) else: assert eventType == "ADD" LOG.info("Next key event: new key becomes valid at %s", formatTime(self.nextUpdate, 1)) return self.nextUpdate
def getNextKeyRotation(self, curKeys=None): """Calculate the next time at which we should change the set of live keys.""" if self.nextUpdate is None: if curKeys is None: if self.currentKeys is None: curKeys = self.getServerKeysets() else: curKeys = self.currentKeys events = [] curNames = {} # For every current keyset, we'll remove it at keyOverlap # seconds after its stated expiry time. for k in curKeys: va, vu = k.getLiveness() events.append((vu+self.keyOverlap, "RM")) curNames[k.keyname] = 1 # For every other keyset, we'll add it when it becomes valid. for va, vu, k in self.keySets: if curNames.has_key(k.keyname): continue events.append((va, "ADD")) # Which even happens first? events.sort() if not events: LOG.info("No future key rotation events.") self.nextUpdate = sys.maxint return self.nextUpdate self.nextUpdate, eventType = events[0] if eventType == "RM": LOG.info("Next key event: old key is removed at %s", formatTime(self.nextUpdate,1)) else: assert eventType == "ADD" LOG.info("Next key event: new key becomes valid at %s", formatTime(self.nextUpdate,1)) return self.nextUpdate
def format(self): """DOCDOC""" import mixminion.ServerInfo digest = self.getHexDigest() expiry = formatTime(self.timestamp) if self.routingType == SWAP_FWD_IPV4_TYPE: routing = parseIPV4Info(self.routingInfo) elif self.routingType == SWAP_FWD_HOST_TYPE: routing = parseMMTPHostInfo(self.routingInfo) else: routing = None server = mixminion.ServerInfo.displayServerByRouting(routing) return """Reply block hash: %s Expires at: %s GMT First server is: %s""" % (digest, expiry, server)
def markAsPublished(self): """Mark this keyset as published.""" contents = "%s\n"%formatTime(time.time(),1) writeFile(self.publishedFile, contents, mode=0600) self.published = 1
def generateServerDescriptorAndKeys(config, identityKey, keydir, keyname, hashdir, validAt=None, now=None, useServerKeys=0, validUntil=None): """Generate and sign a new server descriptor, and generate all the keys to go with it. config -- Our ServerConfig object. identityKey -- This server's private identity key keydir -- The root directory for storing key sets. keyname -- The name of this new key set within keydir hashdir -- The root directory for storing hash logs. validAt -- The starting time (in seconds) for this key's lifetime. useServerKeys -- If true, try to read an existing keyset from (keydir,keyname,hashdir) rather than generating a fresh one. validUntil -- Time at which the generated descriptor should expire. """ if useServerKeys: serverKeys = ServerKeyset(keydir, keyname, hashdir) serverKeys.load() packetKey = serverKeys.packetKey else: # First, we generate both of our short-term keys... packetKey = mixminion.Crypto.pk_generate(PACKET_KEY_BYTES * 8) # ...and save them to disk, setting up our directory structure while # we're at it. serverKeys = ServerKeyset(keydir, keyname, hashdir) serverKeys.packetKey = packetKey serverKeys.save() # FFFF unused # allowIncoming = config['Incoming/MMTP'].get('Enabled', 0) # Now, we pull all the information we need from our configuration. nickname = config['Server']['Nickname'] contact = config['Server']['Contact-Email'] fingerprint = config['Server']['Contact-Fingerprint'] comments = config['Server']['Comments'] if not now: now = time.time() if not validAt: validAt = now insecurities = config.getInsecurities() if insecurities: secure = "no" else: secure = "yes" # Calculate descriptor and X509 certificate lifetimes. # (Round validAt to previous midnight.) validAt = mixminion.Common.previousMidnight(validAt + 30) if not validUntil: keyLifetime = config['Server']['PublicKeyLifetime'].getSeconds() validUntil = previousMidnight(validAt + keyLifetime + 30) mmtpProtocolsIn = mixminion.server.MMTPServer.MMTPServerConnection \ .PROTOCOL_VERSIONS[:] mmtpProtocolsOut = mixminion.server.MMTPServer.MMTPClientConnection \ .PROTOCOL_VERSIONS[:] mmtpProtocolsIn.sort() mmtpProtocolsOut.sort() mmtpProtocolsIn = ",".join(mmtpProtocolsIn) mmtpProtocolsOut = ",".join(mmtpProtocolsOut) #XXXX009 remove: hasn't been checked since 007 or used since 005. identityKeyID = formatBase64( mixminion.Crypto.sha1( mixminion.Crypto.pk_encode_public_key(identityKey))) fields = { # XXXX009 remove: hasn't been checked since 007. "IP": config['Incoming/MMTP'].get('IP', "0.0.0.0"), "Hostname": config['Incoming/MMTP'].get('Hostname', None), "Port": config['Incoming/MMTP'].get('Port', 0), "Nickname": nickname, "Identity": formatBase64(mixminion.Crypto.pk_encode_public_key(identityKey)), "Published": formatTime(now), "ValidAfter": formatDate(validAt), "ValidUntil": formatDate(validUntil), "PacketKey": formatBase64(mixminion.Crypto.pk_encode_public_key(packetKey)), "KeyID": identityKeyID, "MMTPProtocolsIn": mmtpProtocolsIn, "MMTPProtocolsOut": mmtpProtocolsOut, "PacketVersion": mixminion.Packet.PACKET_VERSION, "mm_version": mixminion.__version__, "Secure": secure, "Contact": contact, } # If we don't know our IP address, try to guess if fields['IP'] == '0.0.0.0': #XXXX008 remove; not needed since 005. try: fields['IP'] = _guessLocalIP() LOG.warn("No IP configured; guessing %s", fields['IP']) except IPGuessError, e: LOG.error("Can't guess IP: %s", str(e)) raise UIError("Can't guess IP: %s" % str(e))
def generateServerDescriptorAndKeys(config, identityKey, keydir, keyname, hashdir, validAt=None, now=None, useServerKeys=0, validUntil=None): """Generate and sign a new server descriptor, and generate all the keys to go with it. config -- Our ServerConfig object. identityKey -- This server's private identity key keydir -- The root directory for storing key sets. keyname -- The name of this new key set within keydir hashdir -- The root directory for storing hash logs. validAt -- The starting time (in seconds) for this key's lifetime. useServerKeys -- If true, try to read an existing keyset from (keydir,keyname,hashdir) rather than generating a fresh one. validUntil -- Time at which the generated descriptor should expire. """ if useServerKeys: serverKeys = ServerKeyset(keydir, keyname, hashdir) serverKeys.load() packetKey = serverKeys.packetKey else: # First, we generate both of our short-term keys... packetKey = mixminion.Crypto.pk_generate(PACKET_KEY_BYTES*8) # ...and save them to disk, setting up our directory structure while # we're at it. serverKeys = ServerKeyset(keydir, keyname, hashdir) serverKeys.packetKey = packetKey serverKeys.save() # FFFF unused # allowIncoming = config['Incoming/MMTP'].get('Enabled', 0) # Now, we pull all the information we need from our configuration. nickname = config['Server']['Nickname'] contact = config['Server']['Contact-Email'] fingerprint = config['Server']['Contact-Fingerprint'] comments = config['Server']['Comments'] if not now: now = time.time() if not validAt: validAt = now insecurities = config.getInsecurities() if insecurities: secure = "no" else: secure = "yes" # Calculate descriptor and X509 certificate lifetimes. # (Round validAt to previous midnight.) validAt = mixminion.Common.previousMidnight(validAt+30) if not validUntil: keyLifetime = config['Server']['PublicKeyLifetime'].getSeconds() validUntil = previousMidnight(validAt + keyLifetime + 30) mmtpProtocolsIn = mixminion.server.MMTPServer.MMTPServerConnection \ .PROTOCOL_VERSIONS[:] mmtpProtocolsOut = mixminion.server.MMTPServer.MMTPClientConnection \ .PROTOCOL_VERSIONS[:] mmtpProtocolsIn.sort() mmtpProtocolsOut.sort() mmtpProtocolsIn = ",".join(mmtpProtocolsIn) mmtpProtocolsOut = ",".join(mmtpProtocolsOut) #XXXX009 remove: hasn't been checked since 007 or used since 005. identityKeyID = formatBase64( mixminion.Crypto.sha1( mixminion.Crypto.pk_encode_public_key(identityKey))) fields = { # XXXX009 remove: hasn't been checked since 007. "IP": config['Incoming/MMTP'].get('IP', "0.0.0.0"), "Hostname": config['Incoming/MMTP'].get('Hostname', None), "Port": config['Incoming/MMTP'].get('Port', 0), "Nickname": nickname, "Identity": formatBase64(mixminion.Crypto.pk_encode_public_key(identityKey)), "Published": formatTime(now), "ValidAfter": formatDate(validAt), "ValidUntil": formatDate(validUntil), "PacketKey": formatBase64(mixminion.Crypto.pk_encode_public_key(packetKey)), "KeyID": identityKeyID, "MMTPProtocolsIn" : mmtpProtocolsIn, "MMTPProtocolsOut" : mmtpProtocolsOut, "PacketVersion" : mixminion.Packet.PACKET_VERSION, "mm_version" : mixminion.__version__, "Secure" : secure, "Contact" : contact, } # If we don't know our IP address, try to guess if fields['IP'] == '0.0.0.0': #XXXX008 remove; not needed since 005. try: fields['IP'] = _guessLocalIP() LOG.warn("No IP configured; guessing %s",fields['IP']) except IPGuessError, e: LOG.error("Can't guess IP: %s", str(e)) raise UIError("Can't guess IP: %s" % str(e))
def markAsPublished(self): """Mark this keyset as published.""" contents = "%s\n" % formatTime(time.time(), 1) writeFile(self.publishedFile, contents, mode=0600) self.published = 1
def generateDirectory(self, startAt, endAt, extraTime, identityKey, publicationTime=None, badServers=(), excludeServers=()): """Generate and sign a new directory, to be effective from <startAt> through <endAt>. It includes all servers that are valid at any time between <startAt> and <endAt>+<extraTime>. The directory is signed with <identityKey>. Any servers whose nicknames appear in 'badServers' are marked as not recommended; any servers whose nicknames appear in 'excludeServers' are left off the directory entirely. """ try: self._lock() self.clean() if publicationTime is None: publicationTime = time.time() if previousMidnight(startAt) >= previousMidnight(endAt): raise MixError("Validity range does not contain a full day.") excludeServers = [nickname.lower() for nickname in excludeServers] # First, sort all servers by nickname. includedByNickname = {} for fn, s in self.servers.items(): nickname = s.getNickname().lower() if nickname in excludeServers: continue includedByNickname.setdefault(nickname, []).append((s, fn)) # Second, find all servers that are valid for part of the period, # and that aren't superseded for the whole period. timeRange = IntervalSet([(previousMidnight(startAt), endAt + extraTime)]) for nickname, ss in includedByNickname.items(): # We prefer the most-recently-published descriptor. If two # are published at the same time, we prefer the one that # expires last. ss = [(s['Server']['Published'], s['Server']['Valid-Until'], s, fn) for s, fn in ss] ss.sort() ss.reverse() uncovered = timeRange.copy() included = [] for _, _, s, fn in ss: valid = s.getIntervalSet() if (uncovered * valid): included.append((s, fn)) uncovered -= valid includedByNickname[nickname] = included # Now sort the remaining servers by nickname, then by valid-after. included = [] for ss in includedByNickname.values(): for s, fn in ss: nickname = s.getNickname() validAfter = s['Server']['Valid-After'] included.append((nickname, validAfter, fn)) included.sort() # FFFF We should probably not do all of this in RAM, but # FFFF what the hey. It will only matter if we have many, many # FFFF servers in the system. contents = [] for _, _, fn in included: txt = readFile(os.path.join(self.serverDir, fn)) contents.append(txt) goodServers = [n for n, _, _ in included if n not in badServers] g = {} for n in goodServers: g[n] = 1 goodServers = g.keys() goodServers.sort() goodServers = ", ".join(goodServers) clientVersions = self.config['Directory']['ClientVersions'] serverVersions = self.config['Directory']['ServerVersions'] #FFFF Support for multiple signatures header = """\ [Directory] Version: 0.2 Published: %s Valid-After: %s Valid-Until: %s Recommended-Servers: %s [Signature] DirectoryIdentity: %s DirectoryDigest: DirectorySignature: [Recommended-Software] MixminionClient: %s MixminionServer: %s """ % (formatTime(publicationTime), formatDate(startAt), formatDate(endAt), goodServers, formatBase64(pk_encode_public_key(identityKey)), ", ".join(clientVersions), ", ".join(serverVersions)) directory = header + "".join(contents) directory = _getDirectoryDigestImpl(directory, identityKey) # Make sure that the directory checks out # FFFF remove this once we are _very_ confident. if 1: parsed = ServerDirectory(string=directory) includedDigests = {} for _, _, fn in included: includedDigests[self.servers[fn]['Server']['Digest']] = 1 foundDigests = {} for s in parsed.getAllServers(): foundDigests[s['Server']['Digest']] = 1 assert foundDigests == includedDigests writeFile(os.path.join(self.baseDir, "directory"), directory, mode=0644) f, _ = openUnique( os.path.join(self.dirArchiveDir, "dir-" + formatFnameTime())) f.write(directory) f.close() finally: self._unlock()
def generateDirectory(self, startAt, endAt, extraTime, identityKey, publicationTime=None, badServers=(), excludeServers=()): """Generate and sign a new directory, to be effective from <startAt> through <endAt>. It includes all servers that are valid at any time between <startAt> and <endAt>+<extraTime>. The directory is signed with <identityKey>. Any servers whose nicknames appear in 'badServers' are marked as not recommended; any servers whose nicknames appear in 'excludeServers' are left off the directory entirely. """ try: self._lock() self.clean() if publicationTime is None: publicationTime = time.time() if previousMidnight(startAt) >= previousMidnight(endAt): raise MixError("Validity range does not contain a full day.") excludeServers = [ nickname.lower() for nickname in excludeServers] # First, sort all servers by nickname. includedByNickname = {} for fn, s in self.servers.items(): nickname = s.getNickname().lower() if nickname in excludeServers: continue includedByNickname.setdefault(nickname, []).append((s, fn)) # Second, find all servers that are valid for part of the period, # and that aren't superseded for the whole period. timeRange = IntervalSet([(previousMidnight(startAt), endAt+extraTime)]) for nickname, ss in includedByNickname.items(): # We prefer the most-recently-published descriptor. If two # are published at the same time, we prefer the one that # expires last. ss = [ (s['Server']['Published'], s['Server']['Valid-Until'], s, fn) for s,fn in ss] ss.sort() ss.reverse() uncovered = timeRange.copy() included = [] for _, _, s, fn in ss: valid = s.getIntervalSet() if (uncovered * valid): included.append((s, fn)) uncovered -= valid includedByNickname[nickname] = included # Now sort the remaining servers by nickname, then by valid-after. included = [] for ss in includedByNickname.values(): for s,fn in ss: nickname = s.getNickname() validAfter = s['Server']['Valid-After'] included.append((nickname, validAfter, fn)) included.sort() # FFFF We should probably not do all of this in RAM, but # FFFF what the hey. It will only matter if we have many, many # FFFF servers in the system. contents = [ ] for _, _, fn in included: txt = readFile(os.path.join(self.serverDir, fn)) contents.append(txt) goodServers = [n for n,_,_ in included if n not in badServers] g = {} for n in goodServers: g[n]=1 goodServers = g.keys() goodServers.sort() goodServers = ", ".join(goodServers) clientVersions = self.config['Directory']['ClientVersions'] serverVersions = self.config['Directory']['ServerVersions'] #FFFF Support for multiple signatures header = """\ [Directory] Version: 0.2 Published: %s Valid-After: %s Valid-Until: %s Recommended-Servers: %s [Signature] DirectoryIdentity: %s DirectoryDigest: DirectorySignature: [Recommended-Software] MixminionClient: %s MixminionServer: %s """ % (formatTime(publicationTime), formatDate(startAt), formatDate(endAt), goodServers, formatBase64(pk_encode_public_key(identityKey)), ", ".join(clientVersions), ", ".join(serverVersions)) directory = header+"".join(contents) directory = _getDirectoryDigestImpl(directory, identityKey) # Make sure that the directory checks out # FFFF remove this once we are _very_ confident. if 1: parsed = ServerDirectory(string=directory) includedDigests = {} for _, _, fn in included: includedDigests[self.servers[fn]['Server']['Digest']] = 1 foundDigests = {} for s in parsed.getAllServers(): foundDigests[s['Server']['Digest']] = 1 assert foundDigests == includedDigests writeFile(os.path.join(self.baseDir, "directory"), directory, mode=0644) f, _ = openUnique(os.path.join(self.dirArchiveDir, "dir-"+formatFnameTime())) f.write(directory) f.close() finally: self._unlock()