Merge branch 'upgrade-tunnelkit-1.6.0'

This commit is contained in:
Davide De Rosa 2019-04-04 19:14:25 +02:00
commit 86f60aefbd
16 changed files with 126 additions and 66 deletions

View File

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Fixed
- Pushing DOMAIN has no effect. [#48](https://github.com/passepartoutvpn/passepartout-ios/issues/48)
## 1.3.0 (2019-04-03)
### Added

View File

@ -41,7 +41,7 @@ extension ConfigurationParser.ParsingResult {
log.debug("Parsing configuration URL: \(url)")
do {
result = try ConfigurationParser.parsed(fromURL: url, passphrase: passphrase)
} catch let e as ConfigurationParser.ParsingError {
} catch let e as ConfigurationError {
switch e {
case .encryptionPassphrase, .unableToDecrypt(_):
let alert = Macros.alert(url.normalizedFilename, L10n.ParsedFile.Alerts.EncryptionPassphrase.message)
@ -85,7 +85,7 @@ extension ConfigurationParser.ParsingResult {
vc.present(alert, animated: true, completion: nil)
}
static func alertImportWarning(url: URL, in vc: UIViewController, withWarning warning: ConfigurationParser.ParsingError, completionHandler: @escaping (Bool) -> Void) {
static func alertImportWarning(url: URL, in vc: UIViewController, withWarning warning: ConfigurationError, completionHandler: @escaping (Bool) -> Void) {
let message = details(forWarning: warning)
let alert = Macros.alert(url.normalizedFilename, L10n.ParsedFile.Alerts.PotentiallyUnsupported.message(message))
alert.addDefaultAction(L10n.Global.ok) {
@ -98,8 +98,12 @@ extension ConfigurationParser.ParsingResult {
}
private static func localizedMessage(forError error: Error) -> String {
if let appError = error as? ConfigurationParser.ParsingError {
if let appError = error as? ConfigurationError {
switch appError {
case .malformed(let option):
log.error("Could not parse configuration URL: malformed option, \(option)")
return L10n.ParsedFile.Alerts.Malformed.message(option)
case .missingConfiguration(let option):
log.error("Could not parse configuration URL: missing configuration, \(option)")
return L10n.ParsedFile.Alerts.Missing.message(option)
@ -116,8 +120,11 @@ extension ConfigurationParser.ParsingResult {
return L10n.ParsedFile.Alerts.Parsing.message(error.localizedDescription)
}
private static func details(forWarning warning: ConfigurationParser.ParsingError) -> String {
private static func details(forWarning warning: ConfigurationError) -> String {
switch warning {
case .malformed(let option):
return option
case .missingConfiguration(let option):
return option

View File

@ -59,18 +59,14 @@ class ConfigurationViewController: UIViewController, TableModelHost {
}
model.add(.tls)
model.add(.compression)
if let _ = configuration.dnsServers {
model.add(.dns)
}
model.add(.dns)
model.add(.other)
// headers
model.setHeader(L10n.Configuration.Sections.Communication.header, for: .communication)
model.setHeader(L10n.Configuration.Sections.Tls.header, for: .tls)
model.setHeader(L10n.Configuration.Sections.Compression.header, for: .compression)
if let _ = configuration.dnsServers {
model.setHeader(L10n.Configuration.Sections.Dns.header, for: .dns)
}
model.setHeader(L10n.Configuration.Sections.Dns.header, for: .dns)
model.setHeader(L10n.Configuration.Sections.Other.header, for: .other)
// footers
@ -85,9 +81,14 @@ class ConfigurationViewController: UIViewController, TableModelHost {
}
model.set([.client, .tlsWrapping, .eku], in: .tls)
model.set([.compressionFraming, .compressionAlgorithm], in: .compression)
var dnsRows: [RowType]
if let dnsServers = configuration.dnsServers {
model.set(.dnsServer, count: dnsServers.count, in: .dns)
dnsRows = [RowType](repeating: .dnsServer, count: dnsServers.count)
} else {
dnsRows = []
}
dnsRows.append(.dnsDomain)
model.set(dnsRows, in: .dns)
model.set([.keepAlive, .renegSeconds, .randomEndpoint], in: .other)
return model
@ -196,6 +197,8 @@ extension ConfigurationViewController: UITableViewDataSource, UITableViewDelegat
case dnsServer
case dnsDomain
case keepAlive
case renegSeconds
@ -241,12 +244,12 @@ extension ConfigurationViewController: UITableViewDataSource, UITableViewDelegat
switch row {
case .cipher:
cell.leftText = L10n.Configuration.Cells.Cipher.caption
cell.rightText = configuration.cipher.description
cell.rightText = configuration.fallbackCipher.description
case .digest:
cell.leftText = L10n.Configuration.Cells.Digest.caption
if !configuration.cipher.embedsDigest {
cell.rightText = configuration.digest.description
if !configuration.fallbackCipher.embedsDigest {
cell.rightText = configuration.fallbackDigest.description
} else {
cell.rightText = L10n.Configuration.Cells.Digest.Value.embedded
cell.accessoryType = .none
@ -287,7 +290,7 @@ extension ConfigurationViewController: UITableViewDataSource, UITableViewDelegat
case .compressionFraming:
cell.leftText = L10n.Configuration.Cells.CompressionFraming.caption
cell.rightText = configuration.compressionFraming.cellDescription
cell.rightText = configuration.fallbackCompressionFraming.cellDescription
cell.accessoryType = .none
cell.isTappable = false
@ -309,6 +312,12 @@ extension ConfigurationViewController: UITableViewDataSource, UITableViewDelegat
cell.rightText = dnsServers[indexPath.row]
cell.accessoryType = .none
cell.isTappable = false
case .dnsDomain:
cell.leftText = L10n.Configuration.Cells.DnsDomain.caption
cell.rightText = configuration.searchDomain ?? L10n.Configuration.Cells.DnsDomain.Value.none
cell.accessoryType = .none
cell.isTappable = false
case .keepAlive:
cell.leftText = L10n.Configuration.Cells.KeepAlive.caption
@ -360,7 +369,7 @@ extension ConfigurationViewController: UITableViewDataSource, UITableViewDelegat
navigationController?.pushViewController(vc, animated: true)
case .digest:
guard !configuration.cipher.embedsDigest else {
guard !configuration.fallbackCipher.embedsDigest else {
return
}

View File

@ -99,10 +99,12 @@ class WizardHostViewController: UITableViewController, TableModelHost {
guard let result = parsingResult else {
return
}
let profile = HostConnectionProfile(title: enteredTitle, hostname: result.hostname)
var builder = TunnelKitProvider.ConfigurationBuilder(sessionConfiguration: result.configuration)
builder.endpointProtocols = result.protocols
guard let hostname = result.configuration.hostname else {
return
}
let profile = HostConnectionProfile(title: enteredTitle, hostname: hostname)
let builder = TunnelKitProvider.ConfigurationBuilder(sessionConfiguration: result.configuration)
profile.parameters = builder.build()
let service = TransientStore.shared.service

View File

@ -701,10 +701,10 @@ extension ServiceViewController: UITableViewDataSource, UITableViewDelegate, Tog
let cell = Cells.setting.dequeue(from: tableView, for: indexPath)
cell.leftText = L10n.Service.Cells.Host.Parameters.caption
let V = L10n.Service.Cells.Host.Parameters.Value.self
if !parameters.sessionConfiguration.cipher.embedsDigest {
cell.rightText = V.cipherDigest(parameters.sessionConfiguration.cipher.genericName, parameters.sessionConfiguration.digest.genericName)
if !parameters.sessionConfiguration.fallbackCipher.embedsDigest {
cell.rightText = V.cipherDigest(parameters.sessionConfiguration.fallbackCipher.genericName, parameters.sessionConfiguration.fallbackDigest.genericName)
} else {
cell.rightText = V.cipher(parameters.sessionConfiguration.cipher.genericName)
cell.rightText = V.cipher(parameters.sessionConfiguration.fallbackCipher.genericName)
}
return cell

View File

@ -56,6 +56,7 @@
"wizards.host.sections.existing.header" = "Existing profiles";
"wizards.host.alerts.existing.message" = "A host profile with the same title already exists. Replace it?";
"parsed_file.alerts.malformed.message" = "The configuration file contains a malformed option (%@).";
"parsed_file.alerts.missing.message" = "The configuration file lacks a required option (%@).";
"parsed_file.alerts.unsupported.message" = "The configuration file contains an unsupported option (%@).";
"parsed_file.alerts.potentially_unsupported.message" = "The configuration file is correct but contains a potentially unsupported option (%@).\n\nConnectivity may break depending on server settings.";
@ -167,6 +168,8 @@
"configuration.cells.tls_wrapping.value.crypt" = "Encryption";
"configuration.cells.eku.caption" = "Extended verification";
"configuration.cells.dns_server.caption" = "Address";
"configuration.cells.dns_domain.caption" = "Domain";
"configuration.cells.dns_domain.value.none" = "None";
"configuration.cells.compression_framing.caption" = "Framing";
"configuration.cells.compression_framing.value.lzo" = "--comp-lzo";
"configuration.cells.compression_framing.value.compress" = "--compress";

View File

@ -263,7 +263,18 @@ public class ConnectionService: Codable {
profile = try decoder.decode(ProviderConnectionProfile.self, from: data)
case .host:
profile = try decoder.decode(HostConnectionProfile.self, from: data)
let hostProfile = try decoder.decode(HostConnectionProfile.self, from: data)
// migrate old endpointProtocols
if hostProfile.parameters.sessionConfiguration.endpointProtocols == nil {
var sessionBuilder = hostProfile.parameters.sessionConfiguration.builder()
sessionBuilder.endpointProtocols = hostProfile.parameters.endpointProtocols
var parametersBuilder = hostProfile.parameters.builder()
parametersBuilder.sessionConfiguration = sessionBuilder.build()
hostProfile.parameters = parametersBuilder.build()
}
profile = hostProfile
}
cache[key] = profile
} catch let e {

View File

@ -36,7 +36,7 @@ public class HostConnectionProfile: ConnectionProfile, Codable, Equatable {
public init(title: String, hostname: String) {
self.title = title
self.hostname = hostname
let sessionConfiguration = SessionProxy.ConfigurationBuilder(ca: CryptoContainer(pem: "")).build()
let sessionConfiguration = SessionProxy.ConfigurationBuilder().build()
parameters = TunnelKitProvider.ConfigurationBuilder(sessionConfiguration: sessionConfiguration).build()
}
@ -55,7 +55,9 @@ public class HostConnectionProfile: ConnectionProfile, Codable, Equatable {
}
public func generate(from configuration: TunnelKitProvider.Configuration, preferences: Preferences) throws -> TunnelKitProvider.Configuration {
precondition(!parameters.endpointProtocols.isEmpty)
guard let endpointProtocols = parameters.sessionConfiguration.endpointProtocols, !endpointProtocols.isEmpty else {
preconditionFailure("No endpointProtocols")
}
// XXX: copy paste, error prone
var builder = parameters.builder()
@ -91,7 +93,7 @@ public extension HostConnectionProfile {
}
var protocols: [EndpointProtocol] {
return parameters.endpointProtocols
return parameters.sessionConfiguration.endpointProtocols ?? []
}
var canCustomizeEndpoint: Bool {

View File

@ -130,10 +130,10 @@ public class ProviderConnectionProfile: ConnectionProfile, Codable, Equatable {
}
if let proto = manualProtocol {
builder.endpointProtocols = [proto]
builder.sessionConfiguration.endpointProtocols = [proto]
} else {
builder.endpointProtocols = preset.configuration.endpointProtocols
// builder.endpointProtocols = [
builder.sessionConfiguration.endpointProtocols = preset.configuration.sessionConfiguration.endpointProtocols
// builder.sessionConfiguration.endpointProtocols = [
// EndpointProtocol(.udp, 8080),
// EndpointProtocol(.tcp, 443)
// ]
@ -163,7 +163,7 @@ public extension ProviderConnectionProfile {
}
var protocols: [EndpointProtocol] {
return preset?.configuration.endpointProtocols ?? []
return preset?.configuration.sessionConfiguration.endpointProtocols ?? []
}
var canCustomizeEndpoint: Bool {

View File

@ -36,7 +36,7 @@ public extension SessionProxy.ConfigurationBuilder {
func canCommunicate(with other: SessionProxy.Configuration) -> Bool {
return
(cipher == other.cipher) &&
((digest == other.digest) || cipher.embedsDigest) &&
((digest == other.digest) || fallbackCipher.embedsDigest) &&
(compressionFraming == other.compressionFraming)
}
}

View File

@ -63,7 +63,7 @@ public class TransientStore {
}
public static var baseVPNConfiguration: TunnelKitProvider.ConfigurationBuilder {
let sessionBuilder = SessionProxy.ConfigurationBuilder(ca: CryptoContainer(pem: ""))
let sessionBuilder = SessionProxy.ConfigurationBuilder()
var builder = TunnelKitProvider.ConfigurationBuilder(sessionConfiguration: sessionBuilder.build())
builder.mtu = 1250
builder.shouldDebug = true

View File

@ -63,11 +63,11 @@ public struct InfrastructurePreset: Codable {
case tlsWrap = "wrap"
case usesPIAPatches = "pia"
case checksEKU = "eku"
case randomizeEndpoint = "random"
case usesPIAPatches = "pia"
}
public let id: String
@ -79,7 +79,7 @@ public struct InfrastructurePreset: Codable {
public let configuration: TunnelKitProvider.Configuration
public func hasProtocol(_ proto: EndpointProtocol) -> Bool {
return configuration.endpointProtocols.index(of: proto) != nil
return configuration.sessionConfiguration.endpointProtocols?.index(of: proto) != nil
}
// MARK: Codable
@ -91,50 +91,56 @@ public struct InfrastructurePreset: Codable {
comment = try container.decode(String.self, forKey: .comment)
let cfgContainer = try container.nestedContainer(keyedBy: ConfigurationKeys.self, forKey: .configuration)
let ca = try cfgContainer.decode(CryptoContainer.self, forKey: .ca)
var sessionBuilder = SessionProxy.ConfigurationBuilder(ca: ca)
var sessionBuilder = SessionProxy.ConfigurationBuilder()
sessionBuilder.cipher = try cfgContainer.decode(SessionProxy.Cipher.self, forKey: .cipher)
if let digest = try cfgContainer.decodeIfPresent(SessionProxy.Digest.self, forKey: .digest) {
sessionBuilder.digest = digest
}
sessionBuilder.clientCertificate = try cfgContainer.decodeIfPresent(CryptoContainer.self, forKey: .clientCertificate)
sessionBuilder.clientKey = try cfgContainer.decodeIfPresent(CryptoContainer.self, forKey: .clientKey)
sessionBuilder.compressionFraming = try cfgContainer.decode(SessionProxy.CompressionFraming.self, forKey: .compressionFraming)
sessionBuilder.compressionAlgorithm = try cfgContainer.decodeIfPresent(SessionProxy.CompressionAlgorithm.self, forKey: .compressionAlgorithm) ?? .disabled
sessionBuilder.ca = try cfgContainer.decode(CryptoContainer.self, forKey: .ca)
sessionBuilder.clientCertificate = try cfgContainer.decodeIfPresent(CryptoContainer.self, forKey: .clientCertificate)
sessionBuilder.clientKey = try cfgContainer.decodeIfPresent(CryptoContainer.self, forKey: .clientKey)
sessionBuilder.tlsWrap = try cfgContainer.decodeIfPresent(SessionProxy.TLSWrap.self, forKey: .tlsWrap)
sessionBuilder.keepAliveInterval = try cfgContainer.decodeIfPresent(TimeInterval.self, forKey: .keepAliveSeconds)
sessionBuilder.renegotiatesAfter = try cfgContainer.decodeIfPresent(TimeInterval.self, forKey: .renegotiatesAfterSeconds)
sessionBuilder.usesPIAPatches = try cfgContainer.decodeIfPresent(Bool.self, forKey: .usesPIAPatches) ?? false
sessionBuilder.endpointProtocols = try cfgContainer.decode([EndpointProtocol].self, forKey: .endpointProtocols)
sessionBuilder.checksEKU = try cfgContainer.decodeIfPresent(Bool.self, forKey: .checksEKU) ?? false
sessionBuilder.randomizeEndpoint = try cfgContainer.decodeIfPresent(Bool.self, forKey: .randomizeEndpoint) ?? false
sessionBuilder.usesPIAPatches = try cfgContainer.decodeIfPresent(Bool.self, forKey: .usesPIAPatches) ?? false
var builder = TunnelKitProvider.ConfigurationBuilder(sessionConfiguration: sessionBuilder.build())
builder.endpointProtocols = try cfgContainer.decode([EndpointProtocol].self, forKey: .endpointProtocols)
let builder = TunnelKitProvider.ConfigurationBuilder(sessionConfiguration: sessionBuilder.build())
configuration = builder.build()
}
public func encode(to encoder: Encoder) throws {
guard let ca = configuration.sessionConfiguration.ca else {
fatalError("Could not encode nil ca")
}
guard let endpointProtocols = configuration.sessionConfiguration.endpointProtocols else {
fatalError("Could not encode nil endpointProtocols")
}
var container = encoder.container(keyedBy: PresetKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(comment, forKey: .comment)
var cfgContainer = container.nestedContainer(keyedBy: ConfigurationKeys.self, forKey: .configuration)
try cfgContainer.encode(configuration.endpointProtocols, forKey: .endpointProtocols)
try cfgContainer.encode(configuration.sessionConfiguration.cipher, forKey: .cipher)
try cfgContainer.encode(configuration.sessionConfiguration.digest, forKey: .digest)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.ca, forKey: .ca)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.clientCertificate, forKey: .clientCertificate)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.clientKey, forKey: .clientKey)
try cfgContainer.encode(configuration.sessionConfiguration.compressionFraming, forKey: .compressionFraming)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.compressionAlgorithm, forKey: .compressionAlgorithm)
try cfgContainer.encode(ca, forKey: .ca)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.clientCertificate, forKey: .clientCertificate)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.clientKey, forKey: .clientKey)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.tlsWrap, forKey: .tlsWrap)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.keepAliveInterval, forKey: .keepAliveSeconds)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.renegotiatesAfter, forKey: .renegotiatesAfterSeconds)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.usesPIAPatches, forKey: .usesPIAPatches)
try cfgContainer.encode(endpointProtocols, forKey: .endpointProtocols)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.checksEKU, forKey: .checksEKU)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.randomizeEndpoint, forKey: .randomizeEndpoint)
try cfgContainer.encodeIfPresent(configuration.sessionConfiguration.usesPIAPatches, forKey: .usesPIAPatches)
}
}

View File

@ -150,6 +150,14 @@ public enum L10n {
public static let embedded = L10n.tr("Localizable", "configuration.cells.digest.value.embedded")
}
}
public enum DnsDomain {
/// Domain
public static let caption = L10n.tr("Localizable", "configuration.cells.dns_domain.caption")
public enum Value {
/// None
public static let `none` = L10n.tr("Localizable", "configuration.cells.dns_domain.value.none")
}
}
public enum DnsServer {
/// Address
public static let caption = L10n.tr("Localizable", "configuration.cells.dns_server.caption")
@ -419,6 +427,12 @@ public enum L10n {
/// Please enter the encryption passphrase.
public static let message = L10n.tr("Localizable", "parsed_file.alerts.encryption_passphrase.message")
}
public enum Malformed {
/// The configuration file contains a malformed option (%@).
public static func message(_ p1: String) -> String {
return L10n.tr("Localizable", "parsed_file.alerts.malformed.message", p1)
}
}
public enum Missing {
/// The configuration file lacks a required option (%@).
public static func message(_ p1: String) -> String {

View File

@ -3,10 +3,10 @@ platform :ios, '11.0'
use_frameworks!
def shared_pods
#pod 'TunnelKit', '~> 1.5.0'
#pod 'TunnelKit/LZO', '~> 1.5.0'
pod 'TunnelKit', :git => 'https://github.com/keeshux/tunnelkit', :commit => 'ccb6329'
pod 'TunnelKit/LZO', :git => 'https://github.com/keeshux/tunnelkit', :commit => 'ccb6329'
#pod 'TunnelKit', '~> 1.6.0'
#pod 'TunnelKit/LZO', '~> 1.6.0'
pod 'TunnelKit', :git => 'https://github.com/keeshux/tunnelkit', :commit => '7333ea2'
pod 'TunnelKit/LZO', :git => 'https://github.com/keeshux/tunnelkit', :commit => '7333ea2'
#pod 'TunnelKit', :path => '../../personal/tunnelkit'
#pod 'TunnelKit/LZO', :path => '../../personal/tunnelkit'
end

View File

@ -2,21 +2,21 @@ PODS:
- MBProgressHUD (1.1.0)
- OpenSSL-Apple (1.1.0i.2)
- SwiftyBeaver (1.7.0)
- TunnelKit (1.5.3):
- TunnelKit/AppExtension (= 1.5.3)
- TunnelKit/Core (= 1.5.3)
- TunnelKit/AppExtension (1.5.3):
- TunnelKit (1.6.0):
- TunnelKit/AppExtension (= 1.6.0)
- TunnelKit/Core (= 1.6.0)
- TunnelKit/AppExtension (1.6.0):
- SwiftyBeaver
- TunnelKit/Core
- TunnelKit/Core (1.5.3):
- TunnelKit/Core (1.6.0):
- OpenSSL-Apple (~> 1.1.0i.2)
- SwiftyBeaver
- TunnelKit/LZO (1.5.3)
- TunnelKit/LZO (1.6.0)
DEPENDENCIES:
- MBProgressHUD
- TunnelKit (from `https://github.com/keeshux/tunnelkit`, commit `ccb6329`)
- TunnelKit/LZO (from `https://github.com/keeshux/tunnelkit`, commit `ccb6329`)
- TunnelKit (from `https://github.com/keeshux/tunnelkit`, commit `7333ea2`)
- TunnelKit/LZO (from `https://github.com/keeshux/tunnelkit`, commit `7333ea2`)
SPEC REPOS:
https://github.com/cocoapods/specs.git:
@ -26,20 +26,20 @@ SPEC REPOS:
EXTERNAL SOURCES:
TunnelKit:
:commit: ccb6329
:commit: 7333ea2
:git: https://github.com/keeshux/tunnelkit
CHECKOUT OPTIONS:
TunnelKit:
:commit: ccb6329
:commit: 7333ea2
:git: https://github.com/keeshux/tunnelkit
SPEC CHECKSUMS:
MBProgressHUD: e7baa36a220447d8aeb12769bf0585582f3866d9
OpenSSL-Apple: 37a8c0b04df4bb8971deef4671cc29222861319c
SwiftyBeaver: 4cc0080d2e23f980652e28978db11a5c9da39165
TunnelKit: 21f5d336698d2de6126232f6b5d7e9be4d999af0
TunnelKit: cd10ff6f4368e82414a72e6a111dae369252964e
PODFILE CHECKSUM: cc0564f2cee9d614d40e73ea13d19c96e0ddb1f2
PODFILE CHECKSUM: ab7fe69f86411d5848909b12bfeb846d738d004d
COCOAPODS: 1.6.1

View File

@ -3,7 +3,7 @@
# [Passepartout][about-website]
![iOS 11+](https://img.shields.io/badge/ios-11+-green.svg)
[![TunnelKit 1.5.x](https://img.shields.io/badge/tunnelkit-1.5-d69c68.svg)][dep-tunnelkit]
[![TunnelKit 1.6.x](https://img.shields.io/badge/tunnelkit-1.6-d69c68.svg)][dep-tunnelkit]
[![License GPLv3](https://img.shields.io/badge/license-GPLv3-lightgray.svg)](LICENSE)
[![Join Reddit](https://img.shields.io/badge/discuss-Reddit-orange.svg)][about-reddit]
[![Join Telegram](https://img.shields.io/badge/chat-Telegram-blue.svg)][about-telegram]