Compare commits
5
Commits
d67085a13c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a146fdcb2 | ||
|
|
98febaa1de | ||
|
|
4e79f4bf82 | ||
|
|
ffe24a4f51 | ||
|
|
d3ea638f0a |
@@ -3,7 +3,8 @@ name: Build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
# Release tags, e.g. 1.0, 1.2.3 or v1.0
|
||||
tags: ["v[0-9]*", "[0-9]*"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -15,6 +16,9 @@ jobs:
|
||||
PROJECT: proxy studio.xcodeproj
|
||||
TARGET: proxy studio
|
||||
APP_NAME: Proxy Studio
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -23,9 +27,25 @@ jobs:
|
||||
xcode-select -p
|
||||
xcodebuild -version
|
||||
|
||||
- name: Determine version
|
||||
run: |
|
||||
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="0.0.0-${GITHUB_SHA::7}"
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "ZIP_NAME=ProxyStudio-$VERSION.zip" >> "$GITHUB_ENV"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -o pipefail
|
||||
# MARKETING_VERSION only accepts dotted numbers, so dev builds keep the project default.
|
||||
VERSION_SETTINGS=()
|
||||
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
|
||||
VERSION_SETTINGS+=(MARKETING_VERSION="$VERSION")
|
||||
fi
|
||||
xcodebuild build \
|
||||
-project "$PROJECT" \
|
||||
-target "$TARGET" \
|
||||
@@ -34,17 +54,48 @@ jobs:
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY=- \
|
||||
DEVELOPMENT_TEAM= \
|
||||
CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" \
|
||||
"${VERSION_SETTINGS[@]}" \
|
||||
| tee xcodebuild.log
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
cd build/Release
|
||||
ditto -c -k --keepParent "$APP_NAME.app" "$GITHUB_WORKSPACE/ProxyStudio.zip"
|
||||
ditto -c -k --keepParent "$APP_NAME.app" "$GITHUB_WORKSPACE/$ZIP_NAME"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ProxyStudio-${{ github.sha }}
|
||||
path: ProxyStudio.zip
|
||||
name: ProxyStudio-${{ env.VERSION }}
|
||||
path: ${{ env.ZIP_NAME }}
|
||||
|
||||
- name: Publish release
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
auth=(-H "Authorization: token $TOKEN")
|
||||
|
||||
# Reuse the release if it was created in the Gitea UI, otherwise create it.
|
||||
release_id=$(curl -sf "${auth[@]}" "$API/releases/tags/$GITHUB_REF_NAME" | jq -r .id || true)
|
||||
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
|
||||
release_id=$(curl -sf "${auth[@]}" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "$APP_NAME $VERSION" \
|
||||
'{tag_name: $tag, name: $name}')" \
|
||||
"$API/releases" | jq -r .id)
|
||||
fi
|
||||
|
||||
# Replace an existing asset of the same name (e.g. when re-running the job).
|
||||
existing=$(curl -sf "${auth[@]}" "$API/releases/$release_id/assets" \
|
||||
| jq -r --arg n "$ZIP_NAME" '.[] | select(.name == $n) | .id')
|
||||
for id in $existing; do
|
||||
curl -sf -X DELETE "${auth[@]}" "$API/releases/$release_id/assets/$id"
|
||||
done
|
||||
|
||||
curl -sf "${auth[@]}" -F "attachment=@$ZIP_NAME" \
|
||||
"$API/releases/$release_id/assets?name=$ZIP_NAME" > /dev/null
|
||||
echo "Uploaded $ZIP_NAME to release $GITHUB_REF_NAME"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Deploy website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "website/**"
|
||||
- ".gitea/workflows/deploy-website.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-website
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 12
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 26
|
||||
cache: pnpm
|
||||
cache-dependency-path: website/pnpm-lock.yaml
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm build
|
||||
|
||||
- run: pnpm exec wrangler deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
#Ignore vscode AI rules
|
||||
.github/instructions/codacy.instructions.md
|
||||
@@ -67,7 +67,11 @@ struct ContentView: View {
|
||||
.padding()
|
||||
}
|
||||
} detail: {
|
||||
// The minimum size belongs to the detail column, not the split view. A fixed
|
||||
// window minimum smaller than sidebar + detail + inspector minimums makes the
|
||||
// split view re-solve its constraints forever and AppKit aborts.
|
||||
detail
|
||||
.frame(minWidth: 520, minHeight: 550)
|
||||
.navigationTitle(destination?.rawValue ?? "Proxy Studio")
|
||||
.navigationSubtitle(destination == .dns ? unifi.connectionLabel : (store.isDemo ? "Demo Server" : store.serverName))
|
||||
.toolbar {
|
||||
@@ -128,7 +132,6 @@ struct ContentView: View {
|
||||
),
|
||||
prompt: destination == .dns ? "Search DNS Records" : "Search Hosts"
|
||||
)
|
||||
.frame(minWidth: 900, minHeight: 550)
|
||||
.task {
|
||||
guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return }
|
||||
await store.restoreConnection()
|
||||
|
||||
+120
-21
@@ -38,7 +38,7 @@ struct UniFiDNSView: View {
|
||||
Table(filteredRecords, selection: $selectedID, sortOrder: $sortOrder) {
|
||||
TableColumn("Domain", value: \.domainName)
|
||||
.width(min: 160, ideal: 220)
|
||||
TableColumn("Type", value: \.recordType).width(70)
|
||||
TableColumn("Type", value: \.recordType).width(80)
|
||||
TableColumn("Value", value: \.value).width(min: 150, ideal: 200)
|
||||
TableColumn("TTL") { record in
|
||||
Text(record.ttlSeconds.map { "\($0) s" } ?? "—")
|
||||
@@ -49,16 +49,16 @@ struct UniFiDNSView: View {
|
||||
}
|
||||
.contextMenu(forSelectionType: String.self) { ids in
|
||||
if let id = ids.first, let record = store.records.first(where: { $0.id == id }) {
|
||||
Button("Edit A Record…") { edit(record) }.disabled(record.type != "A_RECORD" || store.isBusy)
|
||||
Button("Edit \(record.recordType) Record…") { edit(record) }.disabled(record.kind == nil || store.isBusy)
|
||||
Divider()
|
||||
Button("Delete Record…", role: .destructive) { deleting = record }.disabled(store.isBusy)
|
||||
}
|
||||
} primaryAction: { ids in
|
||||
if let id = ids.first, let record = store.records.first(where: { $0.id == id }), record.type == "A_RECORD", !store.isBusy { edit(record) }
|
||||
if let id = ids.first, let record = store.records.first(where: { $0.id == id }), record.kind != nil, !store.isBusy { edit(record) }
|
||||
}
|
||||
.overlay {
|
||||
if filteredRecords.isEmpty {
|
||||
ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add an A record for the selected site." : "No records match your search."))
|
||||
ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add a DNS record for the selected site." : "No records match your search."))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
@@ -83,7 +83,7 @@ struct UniFiDNSView: View {
|
||||
.help("Add DNS Record").keyboardShortcut("n").disabled(store.isBusy)
|
||||
Button { if let record = selectedRecord { edit(record) } } label: {
|
||||
Label("Edit DNS Record", systemImage: "square.and.pencil")
|
||||
}.help("Edit A Record").disabled(selectedRecord?.type != "A_RECORD" || store.isBusy)
|
||||
}.help("Edit DNS Record").disabled(selectedRecord?.kind == nil || store.isBusy)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showConnection) { UniFiConnectionSheet(store: store) }
|
||||
@@ -173,24 +173,64 @@ struct DNSRecordEditor: View {
|
||||
let isNew: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var record: DNSRecord
|
||||
@State private var kind: DNSRecordKind
|
||||
@State private var domain: String
|
||||
@State private var address: String
|
||||
@State private var target: String
|
||||
@State private var ttl: String
|
||||
@State private var priority: String
|
||||
@State private var weight: String
|
||||
@State private var port: String
|
||||
@State private var service: String
|
||||
@State private var srvProtocol: String
|
||||
@State private var error: String?
|
||||
|
||||
init(store: UniFiStore, record: DNSRecord, isNew: Bool) {
|
||||
self.store = store
|
||||
self.isNew = isNew
|
||||
_record = State(initialValue: record)
|
||||
_kind = State(initialValue: record.kind ?? .a)
|
||||
_domain = State(initialValue: record.domain ?? "")
|
||||
_address = State(initialValue: record.ipv4Address ?? "")
|
||||
_target = State(initialValue: record.target ?? "")
|
||||
_ttl = State(initialValue: String(record.ttlSeconds ?? 14400))
|
||||
_priority = State(initialValue: String(record.priority ?? 10))
|
||||
_weight = State(initialValue: String(record.weight ?? 0))
|
||||
_port = State(initialValue: record.port.map(String.init) ?? "")
|
||||
_service = State(initialValue: record.service ?? "")
|
||||
_srvProtocol = State(initialValue: record.protocol ?? "_tcp")
|
||||
}
|
||||
|
||||
private var valid: Bool {
|
||||
DNSRecord.isValidDomain(domain.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
&& DNSRecord.isValidIPv4(address.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
&& Int(ttl).map { (0...86400).contains($0) } == true
|
||||
/// Builds the record from the form, keeping only the fields that belong to the selected type.
|
||||
private var draft: DNSRecord {
|
||||
var draft = DNSRecord(id: record.id, type: kind.rawValue, enabled: record.enabled,
|
||||
domain: Self.normalizedDomain(domain))
|
||||
draft.target = kind == .txt ? target : kind.targetIsDomain
|
||||
? Self.normalizedDomain(target) : target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if kind.usesTTL { draft.ttlSeconds = Int(ttl) }
|
||||
if kind.usesPriority { draft.priority = Int(priority) }
|
||||
if kind == .srv {
|
||||
draft.weight = Int(weight)
|
||||
draft.port = Int(port)
|
||||
draft.service = Self.normalizedServiceLabel(service)
|
||||
draft.protocol = Self.normalizedServiceLabel(srvProtocol)
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
private var valid: Bool { (try? draft.payload()) != nil }
|
||||
|
||||
private var protocolOptions: [String] {
|
||||
["_tcp", "_udp"].contains(srvProtocol) ? ["_tcp", "_udp"] : ["_tcp", "_udp", srvProtocol]
|
||||
}
|
||||
|
||||
private static func normalizedDomain(_ value: String) -> String {
|
||||
var value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if value.hasSuffix(".") { value.removeLast() }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func normalizedServiceLabel(_ value: String) -> String {
|
||||
let value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return value.isEmpty || value.hasPrefix("_") ? value : "_" + value
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -200,15 +240,37 @@ struct DNSRecordEditor: View {
|
||||
Spacer()
|
||||
}.padding(20)
|
||||
Form {
|
||||
Section("A Record") {
|
||||
Section(kind == .forwardDomain ? "Forward Domain" : "\(kind.label) Record") {
|
||||
LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "")
|
||||
TextField("Domain", text: $domain, prompt: Text("app.example.com"))
|
||||
TextField("IPv4 Address", text: $address, prompt: Text("192.168.1.20"))
|
||||
TextField("TTL (seconds)", text: $ttl)
|
||||
Picker("Type", selection: $kind) {
|
||||
ForEach(DNSRecordKind.allCases) { kind in
|
||||
Text(kind == .forwardDomain ? "Forward Domain" : kind.label).tag(kind)
|
||||
}
|
||||
}
|
||||
.disabled(!isNew)
|
||||
if kind == .srv {
|
||||
TextField("Service", text: $service, prompt: Text("_ldap"))
|
||||
Picker("Protocol", selection: $srvProtocol) {
|
||||
ForEach(protocolOptions, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
}
|
||||
TextField("Domain", text: $domain, prompt: Text(kind == .srv ? "example.com" : "app.example.com"))
|
||||
if kind == .txt {
|
||||
TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt), axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
} else {
|
||||
TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt))
|
||||
}
|
||||
if kind.usesPriority { TextField("Priority", text: $priority) }
|
||||
if kind == .srv {
|
||||
TextField("Weight", text: $weight)
|
||||
TextField("Port", text: $port, prompt: Text("389"))
|
||||
}
|
||||
if kind.usesTTL { TextField("TTL (seconds)", text: $ttl) }
|
||||
Toggle("Enabled", isOn: $record.enabled)
|
||||
}
|
||||
Section {
|
||||
Text("TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy.")
|
||||
Text(kind.helpText)
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -223,19 +285,56 @@ struct DNSRecordEditor: View {
|
||||
Button(isNew ? "Add" : "Save") {
|
||||
Task {
|
||||
error = nil
|
||||
record.domain = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
record.ipv4Address = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
record.ttlSeconds = Int(ttl)
|
||||
do {
|
||||
try await store.save(record, isNew: isNew)
|
||||
try await store.save(draft, isNew: isNew)
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy)
|
||||
}.padding(20)
|
||||
}
|
||||
.frame(width: 500, height: 440)
|
||||
.frame(width: 500, height: kind == .srv ? 620 : 480)
|
||||
.interactiveDismissDisabled(store.isBusy)
|
||||
// A value typed for one type (such as an IPv4 address) rarely fits another.
|
||||
.onChange(of: kind) { target = "" }
|
||||
}
|
||||
}
|
||||
|
||||
private extension DNSRecordKind {
|
||||
var targetLabel: String {
|
||||
switch self {
|
||||
case .a: "IPv4 Address"
|
||||
case .aaaa: "IPv6 Address"
|
||||
case .cname: "Target Domain"
|
||||
case .mx: "Mail Server"
|
||||
case .txt: "Text"
|
||||
case .srv: "Target Server"
|
||||
case .forwardDomain: "DNS Server"
|
||||
}
|
||||
}
|
||||
|
||||
var targetPrompt: String {
|
||||
switch self {
|
||||
case .a: "192.168.1.20"
|
||||
case .aaaa: "fd00::20"
|
||||
case .cname: "server.example.com"
|
||||
case .mx: "mail.example.com"
|
||||
case .txt: "v=spf1 -all"
|
||||
case .srv: "server.example.com"
|
||||
case .forwardDomain: "192.168.1.53"
|
||||
}
|
||||
}
|
||||
|
||||
var helpText: String {
|
||||
switch self {
|
||||
case .a: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy."
|
||||
case .aaaa: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IPv6 address of your reverse proxy."
|
||||
case .cname: "The domain resolves to the same address as the target domain. TTL must be between 0 and 86400 seconds."
|
||||
case .mx: "Mail servers with lower priority values are preferred. Priority must be between 0 and 65535."
|
||||
case .txt: "TXT records hold free-form text, commonly used for domain verification and SPF."
|
||||
case .srv: "The record is published as _service._protocol.domain. Lower priority values are preferred; weight balances servers with the same priority."
|
||||
case .forwardDomain: "Queries for this domain and its subdomains are forwarded to the DNS server instead of the gateway's upstream resolvers."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+177
-14
@@ -7,6 +7,47 @@ struct UniFiSite: Codable, Identifiable, Hashable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// DNS policy types supported by the UniFi Network Integration API.
|
||||
enum DNSRecordKind: String, CaseIterable, Identifiable {
|
||||
case a = "A_RECORD"
|
||||
case aaaa = "AAAA_RECORD"
|
||||
case cname = "CNAME_RECORD"
|
||||
case mx = "MX_RECORD"
|
||||
case txt = "TXT_RECORD"
|
||||
case srv = "SRV_RECORD"
|
||||
case forwardDomain = "FORWARD_DOMAIN"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Accepts both the `A_RECORD` form and the short `A` form some firmware versions report.
|
||||
init?(apiType: String) {
|
||||
let upper = apiType.uppercased()
|
||||
guard let kind = Self(rawValue: upper) ?? Self(rawValue: upper + "_RECORD") else { return nil }
|
||||
self = kind
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .a: "A"
|
||||
case .aaaa: "AAAA"
|
||||
case .cname: "CNAME"
|
||||
case .mx: "MX"
|
||||
case .txt: "TXT"
|
||||
case .srv: "SRV"
|
||||
case .forwardDomain: "Forward"
|
||||
}
|
||||
}
|
||||
|
||||
/// Only address and alias records carry a TTL in the Integration API.
|
||||
var usesTTL: Bool { self == .a || self == .aaaa || self == .cname }
|
||||
var usesPriority: Bool { self == .mx || self == .srv }
|
||||
var targetIsDomain: Bool { self == .cname || self == .mx || self == .srv }
|
||||
/// Owner names such as `_dmarc` or `_acme-challenge` are common for these types.
|
||||
var allowsUnderscoreOwner: Bool { self == .txt || self == .cname }
|
||||
/// MX, TXT, and SRV records routinely share a domain; other types are one per domain.
|
||||
var allowsMultiplePerDomain: Bool { self == .mx || self == .txt || self == .srv }
|
||||
}
|
||||
|
||||
struct DNSRecord: Codable, Identifiable, Hashable {
|
||||
var id: String
|
||||
var type: String
|
||||
@@ -16,23 +57,90 @@ struct DNSRecord: Codable, Identifiable, Hashable {
|
||||
var ipv6Address: String?
|
||||
var targetDomain: String?
|
||||
var ttlSeconds: Int?
|
||||
var mailServerDomain: String?
|
||||
var text: String?
|
||||
var serverDomain: String?
|
||||
var ipAddress: String?
|
||||
var priority: Int?
|
||||
var weight: Int?
|
||||
var port: Int?
|
||||
var service: String?
|
||||
var `protocol`: String?
|
||||
|
||||
var domainName: String { domain ?? "—" }
|
||||
var recordType: String { type.replacingOccurrences(of: "_RECORD", with: "") }
|
||||
var value: String { ipv4Address ?? ipv6Address ?? targetDomain ?? "See UniFi Network" }
|
||||
var kind: DNSRecordKind? { DNSRecordKind(apiType: type) }
|
||||
var recordType: String { kind?.label ?? type.replacingOccurrences(of: "_RECORD", with: "") }
|
||||
|
||||
var domainName: String {
|
||||
guard let domain else { return "—" }
|
||||
if kind == .srv, let service, let `protocol` { return "\(service).\(`protocol`).\(domain)" }
|
||||
return domain
|
||||
}
|
||||
|
||||
/// The type-specific field holding the record's main value.
|
||||
var target: String? {
|
||||
get {
|
||||
switch kind {
|
||||
case .a: ipv4Address
|
||||
case .aaaa: ipv6Address
|
||||
case .cname: targetDomain
|
||||
case .mx: mailServerDomain
|
||||
case .txt: text
|
||||
case .srv: serverDomain
|
||||
case .forwardDomain: ipAddress
|
||||
case nil: nil
|
||||
}
|
||||
}
|
||||
set {
|
||||
switch kind {
|
||||
case .a: ipv4Address = newValue
|
||||
case .aaaa: ipv6Address = newValue
|
||||
case .cname: targetDomain = newValue
|
||||
case .mx: mailServerDomain = newValue
|
||||
case .txt: text = newValue
|
||||
case .srv: serverDomain = newValue
|
||||
case .forwardDomain: ipAddress = newValue
|
||||
case nil: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var value: String {
|
||||
guard let kind, let target else { return "See UniFi Network" }
|
||||
switch kind {
|
||||
case .mx: return "\(priority.map(String.init) ?? "—") \(target)"
|
||||
case .srv: return "\(priority.map(String.init) ?? "—") \(weight.map(String.init) ?? "—") \(port.map(String.init) ?? "—") \(target)"
|
||||
case .txt: return "\"\(target)\""
|
||||
case .forwardDomain: return "→ \(target)"
|
||||
case .a, .aaaa, .cname: return target
|
||||
}
|
||||
}
|
||||
|
||||
static let examples = [
|
||||
DNSRecord(id: "demo-home", type: "A_RECORD", enabled: true, domain: "home.lab", ipv4Address: "192.168.1.20", ttlSeconds: 14400),
|
||||
DNSRecord(id: "demo-photos", type: "A_RECORD", enabled: true, domain: "photos.lab", ipv4Address: "192.168.1.24", ttlSeconds: 14400)
|
||||
]
|
||||
|
||||
static func isValidDomain(_ value: String) -> Bool {
|
||||
static func isValidDomain(_ value: String, allowUnderscore: Bool = false) -> Bool {
|
||||
guard !value.isEmpty, value.count <= 127 else { return false }
|
||||
return value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { label in
|
||||
!label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-"
|
||||
&& label.utf8.allSatisfy { (97...122).contains($0) || (65...90).contains($0) || (48...57).contains($0) || $0 == 45 }
|
||||
&& label.utf8.allSatisfy {
|
||||
(97...122).contains($0) || (65...90).contains($0) || (48...57).contains($0) || $0 == 45
|
||||
|| (allowUnderscore && $0 == 95)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SRV service and protocol labels, such as `_ldap` and `_tcp`.
|
||||
static func isValidServiceLabel(_ value: String) -> Bool {
|
||||
value.count >= 2 && value.count <= 63 && value.first == "_" && value.dropFirst().first != "-" && value.last != "-"
|
||||
&& value.dropFirst().utf8.allSatisfy { (97...122).contains($0) || (65...90).contains($0) || (48...57).contains($0) || $0 == 45 }
|
||||
}
|
||||
|
||||
static func isValidIPv6(_ value: String) -> Bool {
|
||||
var address = in6_addr()
|
||||
return !value.contains("%") && value.withCString { inet_pton(AF_INET6, $0, &address) } == 1
|
||||
}
|
||||
|
||||
static func isValidIPv4(_ value: String) -> Bool {
|
||||
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
||||
@@ -44,14 +152,62 @@ struct DNSRecord: Codable, Identifiable, Hashable {
|
||||
}
|
||||
|
||||
func payload() throws -> Data {
|
||||
guard type == "A_RECORD", Self.isValidDomain(domainName),
|
||||
Self.isValidIPv4(ipv4Address ?? ""), let ttlSeconds, (0...86400).contains(ttlSeconds) else {
|
||||
throw ServiceError.message("Enter a valid domain, IPv4 address, and TTL between 0 and 86400 seconds.")
|
||||
guard let kind else { throw ServiceError.message("This record type can only be edited in UniFi Network.") }
|
||||
guard let domain, Self.isValidDomain(domain, allowUnderscore: kind.allowsUnderscoreOwner) else {
|
||||
throw ServiceError.message("Enter a valid domain.")
|
||||
}
|
||||
return try JSONSerialization.data(withJSONObject: [
|
||||
"type": "A_RECORD", "domain": domainName, "ipv4Address": ipv4Address ?? "",
|
||||
"ttlSeconds": ttlSeconds, "enabled": enabled
|
||||
])
|
||||
var body: [String: Any] = ["type": kind.rawValue, "domain": domain, "enabled": enabled]
|
||||
if kind.usesTTL {
|
||||
guard let ttlSeconds, (0...86400).contains(ttlSeconds) else {
|
||||
throw ServiceError.message("Enter a TTL between 0 and 86400 seconds.")
|
||||
}
|
||||
body["ttlSeconds"] = ttlSeconds
|
||||
}
|
||||
if kind.usesPriority {
|
||||
guard let priority, (0...65535).contains(priority) else {
|
||||
throw ServiceError.message("Enter a priority between 0 and 65535.")
|
||||
}
|
||||
body["priority"] = priority
|
||||
}
|
||||
let target = target ?? ""
|
||||
switch kind {
|
||||
case .a:
|
||||
guard Self.isValidIPv4(target) else { throw ServiceError.message("Enter a valid IPv4 address.") }
|
||||
body["ipv4Address"] = target
|
||||
case .aaaa:
|
||||
guard Self.isValidIPv6(target) else { throw ServiceError.message("Enter a valid IPv6 address.") }
|
||||
body["ipv6Address"] = target
|
||||
case .cname:
|
||||
guard Self.isValidDomain(target) else { throw ServiceError.message("Enter a valid target domain.") }
|
||||
guard target.caseInsensitiveCompare(domain) != .orderedSame else {
|
||||
throw ServiceError.message("A CNAME record cannot point to itself.")
|
||||
}
|
||||
body["targetDomain"] = target
|
||||
case .mx:
|
||||
guard Self.isValidDomain(target) else { throw ServiceError.message("Enter a valid mail server domain.") }
|
||||
body["mailServerDomain"] = target
|
||||
case .txt:
|
||||
guard !target.isEmpty else { throw ServiceError.message("Enter the TXT record text.") }
|
||||
body["text"] = target
|
||||
case .srv:
|
||||
guard let service, Self.isValidServiceLabel(service), let `protocol`, Self.isValidServiceLabel(`protocol`) else {
|
||||
throw ServiceError.message("Enter a valid service and protocol, such as _ldap and _tcp.")
|
||||
}
|
||||
guard Self.isValidDomain(target) else { throw ServiceError.message("Enter a valid target server domain.") }
|
||||
guard let weight, (0...65535).contains(weight) else { throw ServiceError.message("Enter a weight between 0 and 65535.") }
|
||||
guard let port, (1...65535).contains(port) else { throw ServiceError.message("Enter a port between 1 and 65535.") }
|
||||
body["service"] = service
|
||||
body["protocol"] = `protocol`
|
||||
body["serverDomain"] = target
|
||||
body["weight"] = weight
|
||||
body["port"] = port
|
||||
case .forwardDomain:
|
||||
guard Self.isValidIPv4(target) || Self.isValidIPv6(target) else {
|
||||
throw ServiceError.message("Enter the IPv4 or IPv6 address of the DNS server.")
|
||||
}
|
||||
body["ipAddress"] = target
|
||||
}
|
||||
return try JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +339,15 @@ final class UniFiStore {
|
||||
func save(_ record: DNSRecord, isNew: Bool) async throws {
|
||||
guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") }
|
||||
let body = try record.payload()
|
||||
if records.contains(where: { $0.id != record.id && $0.domainName.caseInsensitiveCompare(record.domainName) == .orderedSame && $0.type == record.type }) {
|
||||
throw ServiceError.message("An A record already exists for this domain in the selected site.")
|
||||
let allowsMultiple = record.kind?.allowsMultiplePerDomain == true
|
||||
if records.contains(where: {
|
||||
$0.id != record.id && $0.kind == record.kind
|
||||
&& $0.domainName.caseInsensitiveCompare(record.domainName) == .orderedSame
|
||||
&& (!allowsMultiple || $0.value == record.value)
|
||||
}) {
|
||||
throw ServiceError.message(allowsMultiple
|
||||
? "An identical \(record.recordType) record already exists in the selected site."
|
||||
: "A \(record.recordType) record already exists for this domain in the selected site.")
|
||||
}
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.wrangler/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,110 @@
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--fg: #1d1d1f;
|
||||
--muted: #6e6e73;
|
||||
--card: #ffffff;
|
||||
--border: #d2d2d7;
|
||||
--accent: #0071e3;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--fg: #f5f5f7;
|
||||
--muted: #a1a1a6;
|
||||
--card: #1c1c1e;
|
||||
--border: #38383a;
|
||||
--accent: #2997ff;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 72px 16px 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2.5rem, 7vw, 4rem);
|
||||
letter-spacing: -0.02em;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.25rem;
|
||||
color: var(--muted);
|
||||
max-width: 36ch;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 22px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
.features article {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.features h2 {
|
||||
font-size: 1.05rem;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.features p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 64px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Proxy Studio",
|
||||
description:
|
||||
"A native macOS app for managing Nginx Proxy Manager hosts and UniFi DNS records.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const RELEASES_URL = "https://git.nhaiden.io/niklas/proxy-studio/releases";
|
||||
const SOURCE_URL = "https://git.nhaiden.io/niklas/proxy-studio";
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "Proxy hosts",
|
||||
body: "Browse, search and sort every host on your Nginx Proxy Manager. Create, edit, enable or disable them in a native editor.",
|
||||
},
|
||||
{
|
||||
title: "Certificates",
|
||||
body: "See your SSL certificates at a glance and attach them to hosts, with Force HTTPS, HTTP/2 and exploit blocking a toggle away.",
|
||||
},
|
||||
{
|
||||
title: "UniFi DNS",
|
||||
body: "Manage local DNS records on your UniFi Network controller: A, AAAA, CNAME, MX, TXT, SRV and forward domains.",
|
||||
},
|
||||
{
|
||||
title: "Keychain storage",
|
||||
body: "Server credentials and API keys are kept in your macOS login keychain, never in plain text.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<header className="hero">
|
||||
<img src="/app-icon.png" alt="" width={128} height={128} />
|
||||
<h1>Proxy Studio</h1>
|
||||
<p className="tagline">
|
||||
A native macOS app for your homelab’s Nginx Proxy Manager and UniFi DNS.
|
||||
</p>
|
||||
<div className="actions">
|
||||
<a className="button primary" href={RELEASES_URL}>
|
||||
Download for macOS
|
||||
</a>
|
||||
<a className="button" href={SOURCE_URL}>
|
||||
View source
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="features">
|
||||
{features.map((f) => (
|
||||
<article key={f.title}>
|
||||
<h2>{f.title}</h2>
|
||||
<p>{f.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Not affiliated with Nginx Proxy Manager or Ubiquiti. Try it without a server using the
|
||||
built-in demo mode.
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Static export: the site is served as plain assets by Cloudflare.
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "proxy-studio-website",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"deploy": "next build && wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.3.6",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"typescript": "^7.0.2",
|
||||
"wrangler": "^4.142.0"
|
||||
}
|
||||
}
|
||||
Generated
+1669
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
workerd: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "proxy-studio-website",
|
||||
"compatibility_date": "2026-09-29",
|
||||
"routes": [
|
||||
{ "pattern": "proxy-studio.site", "custom_domain": true }
|
||||
],
|
||||
"assets": {
|
||||
"directory": "./out",
|
||||
"not_found_handling": "404-page"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user