diff options
| author | Christian Cleberg <[email protected]> | 2026-03-24 21:48:23 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-24 21:48:23 -0500 |
| commit | cf587aa0573ac4f34e1effe70108a9eca82093ac (patch) | |
| tree | 833e6f4961522e35b08af01b5f012983f8e34678 /Rune/API | |
| download | rune-1.0.0.tar.gz rune-1.0.0.tar.bz2 rune-1.0.0.zip | |
v1.0v1.0.0
Diffstat (limited to 'Rune/API')
| -rw-r--r-- | Rune/API/Models.swift | 385 | ||||
| -rw-r--r-- | Rune/API/NjallaClient.swift | 129 |
2 files changed, 514 insertions, 0 deletions
diff --git a/Rune/API/Models.swift b/Rune/API/Models.swift new file mode 100644 index 0000000..f0209b4 --- /dev/null +++ b/Rune/API/Models.swift @@ -0,0 +1,385 @@ +import Foundation + +struct EmptyResult: Codable {} + +struct WalletBalance: Codable, Equatable { + let balance: Int +} + +struct DomainListResponse: Codable { + let domains: [Domain] +} + +struct RecordListResponse: Codable { + let records: [DNSRecord] +} + +struct TokenListResponse: Codable { + let tokens: [APIToken] +} + +struct Domain: Codable, Identifiable, Hashable { + var id: String { name } + + let name: String + let status: String? + let expiry: String? + let autorenew: Bool? + let mailforwarding: Bool? + let dnssec: Bool? + let lock: Bool? + let nameservers: [String]? +} + +struct DNSRecord: Codable, Identifiable, Hashable { + let id: String + let domain: String + let type: String + let name: String + let content: String? + let ttl: Int? + let prio: Int? + let weight: Int? + let port: Int? + let target: String? + let sshAlgorithm: Int? + let sshType: Int? + + enum CodingKeys: String, CodingKey { + case id + case domain + case type + case name + case content + case ttl + case prio + case weight + case port + case target + case sshAlgorithm = "ssh_algorithm" + case sshType = "ssh_type" + } + + init( + id: String, + domain: String, + type: String, + name: String, + content: String?, + ttl: Int?, + prio: Int?, + weight: Int?, + port: Int?, + target: String?, + sshAlgorithm: Int?, + sshType: Int? + ) { + self.id = id + self.domain = domain + self.type = type + self.name = name + self.content = content + self.ttl = ttl + self.prio = prio + self.weight = weight + self.port = port + self.target = target + self.sshAlgorithm = sshAlgorithm + self.sshType = sshType + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decodeLossyString(forKey: .id) + domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "" + type = try container.decode(String.self, forKey: .type) + name = try container.decode(String.self, forKey: .name) + content = try container.decodeIfPresent(String.self, forKey: .content) + ttl = try container.decodeLossyIntIfPresent(forKey: .ttl) + prio = try container.decodeLossyIntIfPresent(forKey: .prio) + weight = try container.decodeLossyIntIfPresent(forKey: .weight) + port = try container.decodeLossyIntIfPresent(forKey: .port) + target = try container.decodeIfPresent(String.self, forKey: .target) + sshAlgorithm = try container.decodeLossyIntIfPresent(forKey: .sshAlgorithm) + sshType = try container.decodeLossyIntIfPresent(forKey: .sshType) + } + + func withDomain(_ domain: String) -> DNSRecord { + DNSRecord( + id: id, + domain: domain, + type: type, + name: name, + content: content, + ttl: ttl, + prio: prio, + weight: weight, + port: port, + target: target, + sshAlgorithm: sshAlgorithm, + sshType: sshType + ) + } +} + +struct APIToken: Codable, Identifiable, Hashable { + var id: String { key } + + let key: String + let comment: String? + let from: [String]? + let allowedDomains: [String]? + let allowedServers: [String]? + let allowedMethods: [String]? + let allowedPrefixes: [String]? + let allowedTypes: [String]? + + enum CodingKeys: String, CodingKey { + case key + case comment + case from + case allowedDomains = "allowed_domains" + case allowedServers = "allowed_servers" + case allowedMethods = "allowed_methods" + case allowedPrefixes = "allowed_prefixes" + case allowedTypes = "allowed_types" + } +} + +enum DNSRecordType: String, CaseIterable, Identifiable, Codable { + case a = "A" + case aaaa = "AAAA" + case aname = "ANAME" + case caa = "CAA" + case cname = "CNAME" + case ds = "DS" + case dynamic = "Dynamic" + case https = "HTTPS" + case mx = "MX" + case naptr = "NAPTR" + case ns = "NS" + case ptr = "PTR" + case srv = "SRV" + case sshfp = "SSHFP" + case svcb = "SVCB" + case tlsa = "TLSA" + case txt = "TXT" + + var id: String { rawValue } + + var usesContent: Bool { + switch self { + case .dynamic, .https, .svcb: + return false + default: + return true + } + } + + var usesTTL: Bool { + usesContent + } + + var usesPriority: Bool { + switch self { + case .https, .mx, .srv, .svcb: + return true + default: + return false + } + } + + var usesWeight: Bool { + self == .srv + } + + var usesPort: Bool { + self == .srv + } + + var usesTarget: Bool { + self == .https || self == .svcb + } + + var usesSSHFields: Bool { + self == .sshfp + } +} + +struct DNSRecordDraft: Equatable { + var type: DNSRecordType = .a + var name = "" + var content = "" + var ttl = "" + var prio = "" + var weight = "" + var port = "" + var target = "" + var sshAlgorithm = "" + var sshType = "" + + init() {} + + init(record: DNSRecord) { + type = DNSRecordType(rawValue: record.type) ?? .a + name = record.name + content = record.content ?? "" + ttl = record.ttl.map(String.init) ?? "" + prio = record.prio.map(String.init) ?? "" + weight = record.weight.map(String.init) ?? "" + port = record.port.map(String.init) ?? "" + target = record.target ?? "" + sshAlgorithm = record.sshAlgorithm.map(String.init) ?? "" + sshType = record.sshType.map(String.init) ?? "" + } + + mutating func resetTypeSpecificFields() { + content = "" + ttl = "" + prio = "" + weight = "" + port = "" + target = "" + sshAlgorithm = "" + sshType = "" + } + + var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var canSubmit: Bool { + !trimmedName.isEmpty + } + + func params(domain: String, id: String? = nil) -> [String: Any] { + var params: [String: Any] = [ + "domain": domain, + "type": type.rawValue, + "name": trimmedName + ] + + if let id { + params["id"] = id + } + + if type.usesContent { + params["content"] = content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + if type.usesTTL, let value = Int(ttl.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["ttl"] = value + } + + if type.usesPriority, let value = Int(prio.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["prio"] = value + } + + if type.usesWeight, let value = Int(weight.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["weight"] = value + } + + if type.usesPort, let value = Int(port.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["port"] = value + } + + if type.usesTarget { + params["target"] = target.trimmingCharacters(in: .whitespacesAndNewlines) + } + + if type.usesSSHFields { + if let value = Int(sshAlgorithm.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["ssh_algorithm"] = value + } + if let value = Int(sshType.trimmingCharacters(in: .whitespacesAndNewlines)) { + params["ssh_type"] = value + } + } + + return params + } +} + +struct DomainUpdateRequest { + var autorenew: Bool + var mailforwarding: Bool + var dnssec: Bool + var lock: Bool + var nameservers: [String] + + var params: [String: Any] { + [ + "autorenew": autorenew, + "mailforwarding": mailforwarding, + "dnssec": dnssec, + "lock": lock, + "nameservers": nameservers + ] + } +} + +struct TokenCreateRequest { + var comment: String + var from: [String] + var allowedMethods: [String] + + var params: [String: Any] { + var params: [String: Any] = [:] + + let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedComment.isEmpty { + params["comment"] = trimmedComment + } + + if !from.isEmpty { + params["from"] = from + } + + if !allowedMethods.isEmpty { + params["allowed_methods"] = allowedMethods + } + + return params + } +} + +extension String { + func formattedExpiry() -> String { + let parser = ISO8601DateFormatter() + guard let date = parser.date(from: self) else { return self } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} + +private extension KeyedDecodingContainer where K == DNSRecord.CodingKeys { + func decodeLossyString(forKey key: K) throws -> String { + if let stringValue = try decodeIfPresent(String.self, forKey: key) { + return stringValue + } + + if let intValue = try decodeIfPresent(Int.self, forKey: key) { + return String(intValue) + } + + throw DecodingError.keyNotFound( + key, + DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).") + ) + } + + func decodeLossyIntIfPresent(forKey key: K) throws -> Int? { + if let intValue = try decodeIfPresent(Int.self, forKey: key) { + return intValue + } + + if let stringValue = try decodeIfPresent(String.self, forKey: key) { + return Int(stringValue) + } + + return nil + } +} diff --git a/Rune/API/NjallaClient.swift b/Rune/API/NjallaClient.swift new file mode 100644 index 0000000..9158e20 --- /dev/null +++ b/Rune/API/NjallaClient.swift @@ -0,0 +1,129 @@ +import Foundation + +struct NjallaClient: Sendable, Equatable { + private let token: String + private let endpoint = URL(string: "https://njal.la/api/1/")! + + init(token: String) { + self.token = token + } + + func call<T: Decodable>(_ method: String, params: [String: Any] = [:]) async throws -> T { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Njalla \(token)", forHTTPHeaderField: "Authorization") + + let body: [String: Any] = [ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": "1" + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + + #if DEBUG + debugPrint("Njalla method:", method) + debugPrint("Njalla status:", statusCode) + #endif + + let decoder = JSONDecoder() + let envelope = try decoder.decode(RPCResponse<T>.self, from: data) + + if let error = envelope.error { + throw NjallaError.api(message: error.message) + } + + guard let result = envelope.result else { + throw NjallaError.missingResult + } + + return result + } + + func listDomains() async throws -> [Domain] { + let response: DomainListResponse = try await call("list-domains") + return response.domains + } + + func getDomain(named domain: String) async throws -> Domain { + try await call("get-domain", params: ["domain": domain]) + } + + func editDomain(named domain: String, request: DomainUpdateRequest) async throws -> Domain { + var params = request.params + params["domain"] = domain + return try await call("edit-domain", params: params) + } + + func listRecords(for domain: String) async throws -> [DNSRecord] { + let response: RecordListResponse = try await call("list-records", params: ["domain": domain]) + return response.records.map { record in + record.domain.isEmpty ? record.withDomain(domain) : record + } + } + + func addRecord(for domain: String, draft: DNSRecordDraft) async throws -> DNSRecord { + try await call("add-record", params: draft.params(domain: domain)) + } + + func editRecord(for domain: String, id: String, draft: DNSRecordDraft) async throws -> DNSRecord { + try await call("edit-record", params: draft.params(domain: domain, id: id)) + } + + func removeRecord(_ record: DNSRecord) async throws { + let params: [String: Any] = [ + "domain": record.domain, + "id": record.id, + "name": record.name, + "type": record.type + ] + let _: RecordListResponse = try await call("remove-record", params: params) + } + + func listTokens() async throws -> [APIToken] { + let response: TokenListResponse = try await call("list-tokens") + return response.tokens + } + + func addToken(request: TokenCreateRequest) async throws { + let _: EmptyResult = try await call("add-token", params: request.params) + } + + func removeToken(key: String) async throws { + let _: EmptyResult = try await call("remove-token", params: ["key": key]) + } + + func getBalance() async throws -> WalletBalance { + try await call("get-balance") + } +} + +enum NjallaError: LocalizedError { + case api(message: String) + case missingResult + + var errorDescription: String? { + switch self { + case .api(let message): + return message + case .missingResult: + return "The API response did not include a result." + } + } +} + +private struct RPCResponse<Result: Decodable>: Decodable { + let result: Result? + let error: RPCError? +} + +private struct RPCError: Decodable { + let code: Int + let message: String +} |
