Compare commits
No commits in common. "3ebb0a8e61b3420097483bf5a9f033c53e1cd5cf" and "1da00ecdf3126cc33bd718efd00a72deb39d610f" have entirely different histories.
3ebb0a8e61
...
1da00ecdf3
46 changed files with 789 additions and 4548 deletions
|
|
@ -1,27 +0,0 @@
|
||||||
name: Lint Governance
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- "**"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
governance:
|
|
||||||
name: BEP Metadata
|
|
||||||
runs-on: [self-hosted, linux, x86_64, burrow-forge]
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ github.token }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Validate BEP metadata
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
python3 Scripts/check-bep-metadata.py
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Release Build
|
|
||||||
runs-on: namespace-profile-linux-medium
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ github.token }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Bootstrap Nix
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
chmod +x Scripts/ci/ensure-nix.sh
|
|
||||||
Scripts/ci/ensure-nix.sh
|
|
||||||
|
|
||||||
- name: Build release artifacts
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
RELEASE_REF: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
ref="${RELEASE_REF:-manual-${GITHUB_SHA::7}}"
|
|
||||||
export RELEASE_REF="${ref}"
|
|
||||||
chmod +x Scripts/ci/build-release-artifacts.sh
|
|
||||||
nix develop .#ci -c Scripts/ci/build-release-artifacts.sh
|
|
||||||
|
|
||||||
- name: Upload release artifacts
|
|
||||||
uses: https://code.forgejo.org/actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: burrow-release-${{ github.ref_name }}
|
|
||||||
path: dist/*
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Publish Forgejo release
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
|
||||||
API_URL: ${{ github.api_url }}
|
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
chmod +x Scripts/ci/publish-forgejo-release.sh
|
|
||||||
nix develop .#ci -c Scripts/ci/publish-forgejo-release.sh
|
|
||||||
23
.github/workflows/lint-governance.yml
vendored
23
.github/workflows/lint-governance.yml
vendored
|
|
@ -1,23 +0,0 @@
|
||||||
name: Governance Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
governance:
|
|
||||||
name: BEP Metadata
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Validate BEP metadata
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
python3 Scripts/check-bep-metadata.py
|
|
||||||
14
AGENTS.md
14
AGENTS.md
|
|
@ -1,14 +0,0 @@
|
||||||
# instructions for agents
|
|
||||||
|
|
||||||
1. Spell the project name as `Burrow` in user-facing copy and `burrow` in code, package, and protocol identifiers unless an existing integration requires a different literal.
|
|
||||||
2. Read [CONSTITUTION.md](CONSTITUTION.md) before changing Apple clients, the daemon, the control plane, forge infrastructure, identity, or security-sensitive code.
|
|
||||||
3. Anchor non-trivial changes in a Burrow Evolution Proposal (BEP) under [evolution/](evolution/README.md) so future contributors can inherit the rationale, safeguards, and rollout shape.
|
|
||||||
4. Before touching the Apple app, daemon IPC, or Tailnet flows, review:
|
|
||||||
- [evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md](evolution/proposals/BEP-0002-control-plane-bootstrap-and-local-auth.md)
|
|
||||||
- [evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md](evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md)
|
|
||||||
- [evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md](evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md)
|
|
||||||
- [evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md](evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md)
|
|
||||||
5. Apple clients must talk only to the daemon over gRPC. Do not add direct HTTP, control-plane, or helper-process calls from Swift UI code.
|
|
||||||
6. Treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a separate user-facing protocol surface.
|
|
||||||
7. Maintain canonical identity and operator metadata in [contributors.nix](contributors.nix). If Burrow forge, Authentik, Headscale, or admin/group mappings need to change, edit that registry first and derive runtime configuration from it.
|
|
||||||
8. When process or architecture is unclear, stop and draft or update a BEP instead of improvising durable behavior in code.
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class BurrowTailnetLoginUITests: XCTestCase {
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTailnetLoginThroughAuthentikWebSession() throws {
|
|
||||||
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL")
|
|
||||||
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email
|
|
||||||
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD")
|
|
||||||
|
|
||||||
let app = XCUIApplication()
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
let tailnetButton = app.buttons["quick-add-tailnet"]
|
|
||||||
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
|
|
||||||
tailnetButton.tap()
|
|
||||||
|
|
||||||
let discoveryField = app.textFields["tailnet-discovery-email"]
|
|
||||||
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
|
|
||||||
replaceText(in: discoveryField, with: email)
|
|
||||||
|
|
||||||
let findServerButton = app.buttons["tailnet-find-server"]
|
|
||||||
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear")
|
|
||||||
findServerButton.tap()
|
|
||||||
|
|
||||||
let discoveryCard = app.otherElements["tailnet-discovery-card"]
|
|
||||||
XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear")
|
|
||||||
|
|
||||||
let authorityField = app.textFields["tailnet-authority"]
|
|
||||||
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
|
||||||
XCTAssertTrue(
|
|
||||||
waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20),
|
|
||||||
"Tailnet authority was not populated from discovery"
|
|
||||||
)
|
|
||||||
|
|
||||||
let probeButton = app.buttons["tailnet-check-connection"]
|
|
||||||
XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear")
|
|
||||||
probeButton.tap()
|
|
||||||
|
|
||||||
let probeCard = app.otherElements["tailnet-authority-probe-card"]
|
|
||||||
XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete")
|
|
||||||
|
|
||||||
let signInButton = app.buttons["tailnet-start-sign-in"]
|
|
||||||
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
|
|
||||||
signInButton.tap()
|
|
||||||
|
|
||||||
acceptAuthenticationPromptIfNeeded(in: app)
|
|
||||||
|
|
||||||
let webSession = webAuthenticationSession()
|
|
||||||
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
|
|
||||||
|
|
||||||
signIntoAuthentik(in: webSession, username: username, password: password)
|
|
||||||
|
|
||||||
app.activate()
|
|
||||||
XCTAssertTrue(
|
|
||||||
waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60),
|
|
||||||
"Tailnet sign-in never reached the running state"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) {
|
|
||||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
||||||
let promptCandidates = [
|
|
||||||
springboard.buttons["Continue"],
|
|
||||||
springboard.buttons["Allow"],
|
|
||||||
app.buttons["Continue"],
|
|
||||||
app.buttons["Allow"],
|
|
||||||
]
|
|
||||||
|
|
||||||
for button in promptCandidates where button.waitForExistence(timeout: 3) {
|
|
||||||
button.tap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func webAuthenticationSession() -> XCUIApplication {
|
|
||||||
let safariViewService = XCUIApplication(bundleIdentifier: "com.apple.SafariViewService")
|
|
||||||
if safariViewService.waitForExistence(timeout: 5) {
|
|
||||||
return safariViewService
|
|
||||||
}
|
|
||||||
|
|
||||||
let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
|
|
||||||
_ = safari.waitForExistence(timeout: 5)
|
|
||||||
return safari
|
|
||||||
}
|
|
||||||
|
|
||||||
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
|
|
||||||
let usernameField = firstExistingElement(
|
|
||||||
in: webSession,
|
|
||||||
queries: [
|
|
||||||
{ $0.textFields["Username"] },
|
|
||||||
{ $0.textFields["Email or Username"] },
|
|
||||||
{ $0.textFields["Email address"] },
|
|
||||||
{ $0.textFields["Email"] },
|
|
||||||
{ $0.webViews.textFields["Username"] },
|
|
||||||
{ $0.webViews.textFields["Email or Username"] },
|
|
||||||
{ $0.descendants(matching: .textField).firstMatch },
|
|
||||||
],
|
|
||||||
timeout: 25
|
|
||||||
)
|
|
||||||
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear")
|
|
||||||
replaceText(in: usernameField, with: username)
|
|
||||||
|
|
||||||
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
|
||||||
if immediatePasswordField.exists {
|
|
||||||
replaceSecureText(in: immediatePasswordField, with: password)
|
|
||||||
tapFirstExistingButton(
|
|
||||||
in: webSession,
|
|
||||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
|
||||||
timeout: 5
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tapFirstExistingButton(
|
|
||||||
in: webSession,
|
|
||||||
titles: ["Continue", "Next", "Sign In", "Log in", "Login"],
|
|
||||||
timeout: 5
|
|
||||||
)
|
|
||||||
|
|
||||||
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
|
|
||||||
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
|
|
||||||
replaceSecureText(in: passwordField, with: password)
|
|
||||||
tapFirstExistingButton(
|
|
||||||
in: webSession,
|
|
||||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
|
||||||
timeout: 5
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
|
|
||||||
let candidates = [
|
|
||||||
app.secureTextFields["Password"],
|
|
||||||
app.secureTextFields["Password or Token"],
|
|
||||||
app.webViews.secureTextFields["Password"],
|
|
||||||
app.webViews.secureTextFields["Password or Token"],
|
|
||||||
app.descendants(matching: .secureTextField).firstMatch,
|
|
||||||
]
|
|
||||||
|
|
||||||
return firstExistingElement(from: candidates, timeout: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tapFirstExistingButton(
|
|
||||||
in app: XCUIApplication,
|
|
||||||
titles: [String],
|
|
||||||
timeout: TimeInterval
|
|
||||||
) {
|
|
||||||
let candidates = titles.flatMap { title in
|
|
||||||
[
|
|
||||||
app.buttons[title],
|
|
||||||
app.webViews.buttons[title],
|
|
||||||
]
|
|
||||||
} + [app.descendants(matching: .button).firstMatch]
|
|
||||||
|
|
||||||
let button = firstExistingElement(from: candidates, timeout: timeout)
|
|
||||||
XCTAssertTrue(button.exists, "Expected one of \(titles.joined(separator: ", ")) to appear")
|
|
||||||
button.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requiredEnvironment(_ key: String) throws -> String {
|
|
||||||
guard let value = ProcessInfo.processInfo.environment[key],
|
|
||||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
else {
|
|
||||||
throw XCTSkip("Missing required UI test environment variable \(key)")
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForFieldValue(
|
|
||||||
_ field: XCUIElement,
|
|
||||||
containing substring: String,
|
|
||||||
timeout: TimeInterval
|
|
||||||
) -> Bool {
|
|
||||||
let predicate = NSPredicate(format: "value CONTAINS %@", substring)
|
|
||||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: field)
|
|
||||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForButtonLabel(
|
|
||||||
_ button: XCUIElement,
|
|
||||||
equals expected: String,
|
|
||||||
timeout: TimeInterval
|
|
||||||
) -> Bool {
|
|
||||||
let predicate = NSPredicate(format: "label == %@", expected)
|
|
||||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
|
|
||||||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func firstExistingElement(
|
|
||||||
in app: XCUIApplication,
|
|
||||||
queries: [(XCUIApplication) -> XCUIElement],
|
|
||||||
timeout: TimeInterval
|
|
||||||
) -> XCUIElement {
|
|
||||||
firstExistingElement(from: queries.map { $0(app) }, timeout: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func firstExistingElement(from candidates: [XCUIElement], timeout: TimeInterval) -> XCUIElement {
|
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
|
||||||
repeat {
|
|
||||||
for candidate in candidates where candidate.exists {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
|
||||||
} while Date() < deadline
|
|
||||||
|
|
||||||
return candidates[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func replaceText(in element: XCUIElement, with value: String) {
|
|
||||||
element.tap()
|
|
||||||
clearText(in: element)
|
|
||||||
element.typeText(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func replaceSecureText(in element: XCUIElement, with value: String) {
|
|
||||||
element.tap()
|
|
||||||
clearText(in: element)
|
|
||||||
element.typeText(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearText(in element: XCUIElement) {
|
|
||||||
guard let currentValue = element.value as? String, !currentValue.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
|
||||||
element.typeText(deleteSequence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; };
|
D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; };
|
||||||
D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.swift */; };
|
|
||||||
D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; };
|
D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; };
|
||||||
D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; };
|
D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; };
|
||||||
|
|
@ -50,13 +49,6 @@
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
D11000022F70000100112233 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = D05B9F7129E39EEC008CB1F9;
|
|
||||||
remoteInfo = App;
|
|
||||||
};
|
|
||||||
D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = {
|
D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */;
|
containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */;
|
||||||
|
|
@ -138,9 +130,6 @@
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = "<group>"; };
|
D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = "<group>"; };
|
||||||
D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
D11000032F70000100112233 /* BurrowUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BurrowUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
D11000042F70000100112233 /* BurrowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowUITests.swift; sourceTree = "<group>"; };
|
|
||||||
D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = "<group>"; };
|
|
||||||
D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = "<group>"; };
|
D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = "<group>"; };
|
||||||
D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = "<group>"; };
|
D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = "<group>"; };
|
||||||
D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
|
@ -193,13 +182,6 @@
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
D11000062F70000100112233 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
D020F65029E4A697002790F6 /* Frameworks */ = {
|
D020F65029E4A697002790F6 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
|
@ -261,7 +243,6 @@
|
||||||
D0D4E4F72C8D941D007F820A /* Framework.xcconfig */,
|
D0D4E4F72C8D941D007F820A /* Framework.xcconfig */,
|
||||||
D020F64029E4A1FF002790F6 /* Compiler.xcconfig */,
|
D020F64029E4A1FF002790F6 /* Compiler.xcconfig */,
|
||||||
D0D4E4F62C8D932D007F820A /* Debug.xcconfig */,
|
D0D4E4F62C8D932D007F820A /* Debug.xcconfig */,
|
||||||
D11000052F70000100112233 /* UITests.xcconfig */,
|
|
||||||
D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */,
|
D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */,
|
||||||
D020F64229E4A1FF002790F6 /* Info.plist */,
|
D020F64229E4A1FF002790F6 /* Info.plist */,
|
||||||
D0D4E5912C8D9D0A007F820A /* Constants */,
|
D0D4E5912C8D9D0A007F820A /* Constants */,
|
||||||
|
|
@ -287,7 +268,6 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D05B9F7429E39EEC008CB1F9 /* App */,
|
D05B9F7429E39EEC008CB1F9 /* App */,
|
||||||
D11000072F70000100112233 /* AppUITests */,
|
|
||||||
D020F65629E4A697002790F6 /* NetworkExtension */,
|
D020F65629E4A697002790F6 /* NetworkExtension */,
|
||||||
D0D4E49C2C8D921A007F820A /* Core */,
|
D0D4E49C2C8D921A007F820A /* Core */,
|
||||||
D0D4E4AD2C8D921A007F820A /* UI */,
|
D0D4E4AD2C8D921A007F820A /* UI */,
|
||||||
|
|
@ -301,7 +281,6 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D05B9F7229E39EEC008CB1F9 /* Burrow.app */,
|
D05B9F7229E39EEC008CB1F9 /* Burrow.app */,
|
||||||
D11000032F70000100112233 /* BurrowUITests.xctest */,
|
|
||||||
D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */,
|
D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */,
|
||||||
D0BCC6032A09535900AD070D /* libburrow.a */,
|
D0BCC6032A09535900AD070D /* libburrow.a */,
|
||||||
D0D4E5312C8D996F007F820A /* BurrowCore.framework */,
|
D0D4E5312C8D996F007F820A /* BurrowCore.framework */,
|
||||||
|
|
@ -324,14 +303,6 @@
|
||||||
path = App;
|
path = App;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
D11000072F70000100112233 /* AppUITests */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
D11000042F70000100112233 /* BurrowUITests.swift */,
|
|
||||||
);
|
|
||||||
path = AppUITests;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
D0B98FD729FDDB57004E7149 /* libburrow */ = {
|
D0B98FD729FDDB57004E7149 /* libburrow */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -404,24 +375,6 @@
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
D11000082F70000100112233 /* BurrowUITests */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */;
|
|
||||||
buildPhases = (
|
|
||||||
D110000A2F70000100112233 /* Sources */,
|
|
||||||
D11000062F70000100112233 /* Frameworks */,
|
|
||||||
D11000092F70000100112233 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
D110000B2F70000100112233 /* PBXTargetDependency */,
|
|
||||||
);
|
|
||||||
name = BurrowUITests;
|
|
||||||
productName = BurrowUITests;
|
|
||||||
productReference = D11000032F70000100112233 /* BurrowUITests.xctest */;
|
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
|
||||||
};
|
|
||||||
D020F65229E4A697002790F6 /* NetworkExtension */ = {
|
D020F65229E4A697002790F6 /* NetworkExtension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */;
|
buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */;
|
||||||
|
|
@ -537,10 +490,6 @@
|
||||||
LastSwiftUpdateCheck = 1600;
|
LastSwiftUpdateCheck = 1600;
|
||||||
LastUpgradeCheck = 1520;
|
LastUpgradeCheck = 1520;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
D11000082F70000100112233 = {
|
|
||||||
CreatedOnToolsVersion = 16.0;
|
|
||||||
TestTargetID = D05B9F7129E39EEC008CB1F9;
|
|
||||||
};
|
|
||||||
D020F65229E4A697002790F6 = {
|
D020F65229E4A697002790F6 = {
|
||||||
CreatedOnToolsVersion = 14.3;
|
CreatedOnToolsVersion = 14.3;
|
||||||
};
|
};
|
||||||
|
|
@ -573,7 +522,6 @@
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
D05B9F7129E39EEC008CB1F9 /* App */,
|
D05B9F7129E39EEC008CB1F9 /* App */,
|
||||||
D11000082F70000100112233 /* BurrowUITests */,
|
|
||||||
D020F65229E4A697002790F6 /* NetworkExtension */,
|
D020F65229E4A697002790F6 /* NetworkExtension */,
|
||||||
D0D4E5502C8D9BF2007F820A /* UI */,
|
D0D4E5502C8D9BF2007F820A /* UI */,
|
||||||
D0D4E5302C8D996F007F820A /* Core */,
|
D0D4E5302C8D996F007F820A /* Core */,
|
||||||
|
|
@ -583,13 +531,6 @@
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
D11000092F70000100112233 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
D05B9F7029E39EEC008CB1F9 /* Resources */ = {
|
D05B9F7029E39EEC008CB1F9 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
|
@ -653,14 +594,6 @@
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
D110000A2F70000100112233 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
D11000012F70000100112233 /* BurrowUITests.swift in Sources */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
D020F64F29E4A697002790F6 /* Sources */ = {
|
D020F64F29E4A697002790F6 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
|
@ -719,11 +652,6 @@
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
D110000B2F70000100112233 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = D05B9F7129E39EEC008CB1F9 /* App */;
|
|
||||||
targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
D020F65C29E4A697002790F6 /* PBXTargetDependency */ = {
|
D020F65C29E4A697002790F6 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = D020F65229E4A697002790F6 /* NetworkExtension */;
|
target = D020F65229E4A697002790F6 /* NetworkExtension */;
|
||||||
|
|
@ -766,20 +694,6 @@
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
D110000C2F70000100112233 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
D110000D2F70000100112233 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
D020F65F29E4A697002790F6 /* Debug */ = {
|
D020F65F29E4A697002790F6 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */;
|
baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */;
|
||||||
|
|
@ -867,15 +781,6 @@
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
D110000C2F70000100112233 /* Debug */,
|
|
||||||
D110000D2F70000100112233 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = {
|
D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|
|
||||||
|
|
@ -28,20 +28,7 @@
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
shouldAutocreateTestPlan = "NO">
|
shouldAutocreateTestPlan = "YES">
|
||||||
<Testables>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "D11000082F70000100112233"
|
|
||||||
BuildableName = "BurrowUITests.xctest"
|
|
||||||
BlueprintName = "BurrowUITests"
|
|
||||||
ReferencedContainer = "container:Burrow.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
#include "Compiler.xcconfig"
|
|
||||||
|
|
||||||
SUPPORTED_PLATFORMS = iphonesimulator iphoneos
|
|
||||||
TARGETED_DEVICE_FAMILY[sdk=iphone*] = 1,2
|
|
||||||
|
|
||||||
PRODUCT_NAME = $(TARGET_NAME)
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).uitests
|
|
||||||
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO
|
|
||||||
|
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks
|
|
||||||
TEST_TARGET_NAME = App
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import Foundation
|
|
||||||
import GRPC
|
import GRPC
|
||||||
import NIOTransportServices
|
import NIOTransportServices
|
||||||
import SwiftProtobuf
|
|
||||||
|
|
||||||
public typealias TunnelClient = Burrow_TunnelAsyncClient
|
public typealias TunnelClient = Burrow_TunnelAsyncClient
|
||||||
public typealias NetworksClient = Burrow_NetworksAsyncClient
|
public typealias NetworksClient = Burrow_NetworksAsyncClient
|
||||||
|
|
@ -32,427 +30,3 @@ extension NetworksClient: Client {
|
||||||
self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none)
|
self.init(channel: channel, defaultCallOptions: .init(), interceptors: .none)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Burrow_TailnetDiscoverRequest: Sendable {
|
|
||||||
public var email: String = ""
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Burrow_TailnetDiscoverResponse: Sendable {
|
|
||||||
public var domain: String = ""
|
|
||||||
public var authority: String = ""
|
|
||||||
public var oidcIssuer: String = ""
|
|
||||||
public var managed: Bool = false
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Burrow_TailnetProbeRequest: Sendable {
|
|
||||||
public var authority: String = ""
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Burrow_TailnetProbeResponse: Sendable {
|
|
||||||
public var authority: String = ""
|
|
||||||
public var statusCode: Int32 = 0
|
|
||||||
public var summary: String = ""
|
|
||||||
public var detail: String = ""
|
|
||||||
public var reachable: Bool = false
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Burrow_TailnetLoginStartRequest: Sendable {
|
|
||||||
public var accountName: String = ""
|
|
||||||
public var identityName: String = ""
|
|
||||||
public var hostname: String = ""
|
|
||||||
public var authority: String = ""
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Burrow_TailnetLoginStatusRequest: Sendable {
|
|
||||||
public var sessionID: String = ""
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Burrow_TailnetLoginCancelRequest: Sendable {
|
|
||||||
public var sessionID: String = ""
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Burrow_TailnetLoginStatusResponse: Sendable {
|
|
||||||
public var sessionID: String = ""
|
|
||||||
public var backendState: String = ""
|
|
||||||
public var authURL: String = ""
|
|
||||||
public var running: Bool = false
|
|
||||||
public var needsLogin: Bool = false
|
|
||||||
public var tailnetName: String = ""
|
|
||||||
public var magicDNSSuffix: String = ""
|
|
||||||
public var selfDNSName: String = ""
|
|
||||||
public var tailnetIPs: [String] = []
|
|
||||||
public var health: [String] = []
|
|
||||||
public var unknownFields = SwiftProtobuf.UnknownStorage()
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .same(proto: "email")
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.email)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.email.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.email, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetDiscoverResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetDiscoverResponse"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .same(proto: "domain"),
|
|
||||||
2: .same(proto: "authority"),
|
|
||||||
3: .same(proto: "oidc_issuer"),
|
|
||||||
4: .same(proto: "managed"),
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.domain)
|
|
||||||
case 2: try decoder.decodeSingularStringField(value: &self.authority)
|
|
||||||
case 3: try decoder.decodeSingularStringField(value: &self.oidcIssuer)
|
|
||||||
case 4: try decoder.decodeSingularBoolField(value: &self.managed)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.domain.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.domain, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
if !self.authority.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 2)
|
|
||||||
}
|
|
||||||
if !self.oidcIssuer.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.oidcIssuer, fieldNumber: 3)
|
|
||||||
}
|
|
||||||
if self.managed {
|
|
||||||
try visitor.visitSingularBoolField(value: self.managed, fieldNumber: 4)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetProbeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetProbeRequest"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .same(proto: "authority")
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.authority)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.authority.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetProbeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetProbeResponse"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .same(proto: "authority"),
|
|
||||||
2: .same(proto: "status_code"),
|
|
||||||
3: .same(proto: "summary"),
|
|
||||||
4: .same(proto: "detail"),
|
|
||||||
5: .same(proto: "reachable"),
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.authority)
|
|
||||||
case 2: try decoder.decodeSingularInt32Field(value: &self.statusCode)
|
|
||||||
case 3: try decoder.decodeSingularStringField(value: &self.summary)
|
|
||||||
case 4: try decoder.decodeSingularStringField(value: &self.detail)
|
|
||||||
case 5: try decoder.decodeSingularBoolField(value: &self.reachable)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.authority.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
if self.statusCode != 0 {
|
|
||||||
try visitor.visitSingularInt32Field(value: self.statusCode, fieldNumber: 2)
|
|
||||||
}
|
|
||||||
if !self.summary.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.summary, fieldNumber: 3)
|
|
||||||
}
|
|
||||||
if !self.detail.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.detail, fieldNumber: 4)
|
|
||||||
}
|
|
||||||
if self.reachable {
|
|
||||||
try visitor.visitSingularBoolField(value: self.reachable, fieldNumber: 5)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetLoginStartRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetLoginStartRequest"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .standard(proto: "account_name"),
|
|
||||||
2: .standard(proto: "identity_name"),
|
|
||||||
3: .same(proto: "hostname"),
|
|
||||||
4: .same(proto: "authority"),
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.accountName)
|
|
||||||
case 2: try decoder.decodeSingularStringField(value: &self.identityName)
|
|
||||||
case 3: try decoder.decodeSingularStringField(value: &self.hostname)
|
|
||||||
case 4: try decoder.decodeSingularStringField(value: &self.authority)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.accountName.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.accountName, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
if !self.identityName.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.identityName, fieldNumber: 2)
|
|
||||||
}
|
|
||||||
if !self.hostname.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.hostname, fieldNumber: 3)
|
|
||||||
}
|
|
||||||
if !self.authority.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.authority, fieldNumber: 4)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetLoginStatusRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetLoginStatusRequest"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .standard(proto: "session_id")
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.sessionID)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.sessionID.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetLoginCancelRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetLoginCancelRequest"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .standard(proto: "session_id")
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.sessionID)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.sessionID.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
|
||||||
public static let protoMessageName: String = "burrow.TailnetLoginStatusResponse"
|
|
||||||
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
|
||||||
1: .standard(proto: "session_id"),
|
|
||||||
2: .standard(proto: "backend_state"),
|
|
||||||
3: .standard(proto: "auth_url"),
|
|
||||||
4: .same(proto: "running"),
|
|
||||||
5: .standard(proto: "needs_login"),
|
|
||||||
6: .standard(proto: "tailnet_name"),
|
|
||||||
7: .standard(proto: "magic_dns_suffix"),
|
|
||||||
8: .standard(proto: "self_dns_name"),
|
|
||||||
9: .standard(proto: "tailnet_ips"),
|
|
||||||
10: .same(proto: "health"),
|
|
||||||
]
|
|
||||||
|
|
||||||
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
|
||||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
|
||||||
switch fieldNumber {
|
|
||||||
case 1: try decoder.decodeSingularStringField(value: &self.sessionID)
|
|
||||||
case 2: try decoder.decodeSingularStringField(value: &self.backendState)
|
|
||||||
case 3: try decoder.decodeSingularStringField(value: &self.authURL)
|
|
||||||
case 4: try decoder.decodeSingularBoolField(value: &self.running)
|
|
||||||
case 5: try decoder.decodeSingularBoolField(value: &self.needsLogin)
|
|
||||||
case 6: try decoder.decodeSingularStringField(value: &self.tailnetName)
|
|
||||||
case 7: try decoder.decodeSingularStringField(value: &self.magicDNSSuffix)
|
|
||||||
case 8: try decoder.decodeSingularStringField(value: &self.selfDNSName)
|
|
||||||
case 9: try decoder.decodeRepeatedStringField(value: &self.tailnetIPs)
|
|
||||||
case 10: try decoder.decodeRepeatedStringField(value: &self.health)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
|
||||||
if !self.sessionID.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.sessionID, fieldNumber: 1)
|
|
||||||
}
|
|
||||||
if !self.backendState.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.backendState, fieldNumber: 2)
|
|
||||||
}
|
|
||||||
if !self.authURL.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.authURL, fieldNumber: 3)
|
|
||||||
}
|
|
||||||
if self.running {
|
|
||||||
try visitor.visitSingularBoolField(value: self.running, fieldNumber: 4)
|
|
||||||
}
|
|
||||||
if self.needsLogin {
|
|
||||||
try visitor.visitSingularBoolField(value: self.needsLogin, fieldNumber: 5)
|
|
||||||
}
|
|
||||||
if !self.tailnetName.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.tailnetName, fieldNumber: 6)
|
|
||||||
}
|
|
||||||
if !self.magicDNSSuffix.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.magicDNSSuffix, fieldNumber: 7)
|
|
||||||
}
|
|
||||||
if !self.selfDNSName.isEmpty {
|
|
||||||
try visitor.visitSingularStringField(value: self.selfDNSName, fieldNumber: 8)
|
|
||||||
}
|
|
||||||
if !self.tailnetIPs.isEmpty {
|
|
||||||
try visitor.visitRepeatedStringField(value: self.tailnetIPs, fieldNumber: 9)
|
|
||||||
}
|
|
||||||
if !self.health.isEmpty {
|
|
||||||
try visitor.visitRepeatedStringField(value: self.health, fieldNumber: 10)
|
|
||||||
}
|
|
||||||
try unknownFields.traverse(visitor: &visitor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct TailnetClient: Client, GRPCClient {
|
|
||||||
public let channel: GRPCChannel
|
|
||||||
public var defaultCallOptions: CallOptions
|
|
||||||
|
|
||||||
public init(channel: any GRPCChannel) {
|
|
||||||
self.channel = channel
|
|
||||||
self.defaultCallOptions = .init()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func discover(
|
|
||||||
_ request: Burrow_TailnetDiscoverRequest,
|
|
||||||
callOptions: CallOptions? = nil
|
|
||||||
) async throws -> Burrow_TailnetDiscoverResponse {
|
|
||||||
try await self.performAsyncUnaryCall(
|
|
||||||
path: "/burrow.TailnetControl/Discover",
|
|
||||||
request: request,
|
|
||||||
callOptions: callOptions ?? self.defaultCallOptions,
|
|
||||||
interceptors: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func probe(
|
|
||||||
_ request: Burrow_TailnetProbeRequest,
|
|
||||||
callOptions: CallOptions? = nil
|
|
||||||
) async throws -> Burrow_TailnetProbeResponse {
|
|
||||||
try await self.performAsyncUnaryCall(
|
|
||||||
path: "/burrow.TailnetControl/Probe",
|
|
||||||
request: request,
|
|
||||||
callOptions: callOptions ?? self.defaultCallOptions,
|
|
||||||
interceptors: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func loginStart(
|
|
||||||
_ request: Burrow_TailnetLoginStartRequest,
|
|
||||||
callOptions: CallOptions? = nil
|
|
||||||
) async throws -> Burrow_TailnetLoginStatusResponse {
|
|
||||||
try await self.performAsyncUnaryCall(
|
|
||||||
path: "/burrow.TailnetControl/LoginStart",
|
|
||||||
request: request,
|
|
||||||
callOptions: callOptions ?? self.defaultCallOptions,
|
|
||||||
interceptors: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func loginStatus(
|
|
||||||
_ request: Burrow_TailnetLoginStatusRequest,
|
|
||||||
callOptions: CallOptions? = nil
|
|
||||||
) async throws -> Burrow_TailnetLoginStatusResponse {
|
|
||||||
try await self.performAsyncUnaryCall(
|
|
||||||
path: "/burrow.TailnetControl/LoginStatus",
|
|
||||||
request: request,
|
|
||||||
callOptions: callOptions ?? self.defaultCallOptions,
|
|
||||||
interceptors: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func loginCancel(
|
|
||||||
_ request: Burrow_TailnetLoginCancelRequest,
|
|
||||||
callOptions: CallOptions? = nil
|
|
||||||
) async throws -> Burrow_Empty {
|
|
||||||
try await self.performAsyncUnaryCall(
|
|
||||||
path: "/burrow.TailnetControl/LoginCancel",
|
|
||||||
request: request,
|
|
||||||
callOptions: callOptions ?? self.defaultCallOptions,
|
|
||||||
interceptors: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
|
import AuthenticationServices
|
||||||
import BurrowConfiguration
|
import BurrowConfiguration
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(AuthenticationServices)
|
|
||||||
import AuthenticationServices
|
|
||||||
#endif
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#elseif canImport(AppKit)
|
#elseif canImport(AppKit)
|
||||||
|
|
@ -206,7 +204,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: .wireGuard
|
case .wireGuard: .wireGuard
|
||||||
case .tor: .tor
|
case .tor: .tor
|
||||||
case .tailnet: .tailnet
|
case .tailnet: .headscale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +274,6 @@ private struct QuickAddButton: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
|
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("quick-add-\(sheet.rawValue)")
|
|
||||||
.buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18))
|
.buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -288,12 +285,13 @@ private struct AccountDraft {
|
||||||
var wireGuardConfig = ""
|
var wireGuardConfig = ""
|
||||||
|
|
||||||
var discoveryEmail = ""
|
var discoveryEmail = ""
|
||||||
|
var tailnetProvider: TailnetProvider = .tailscale
|
||||||
var authority = ""
|
var authority = ""
|
||||||
var tailnet = ""
|
var tailnet = ""
|
||||||
var hostname = ProcessInfo.processInfo.hostName
|
var hostname = ProcessInfo.processInfo.hostName
|
||||||
var username = ""
|
var username = ""
|
||||||
var secret = ""
|
var secret = ""
|
||||||
var authMode: AccountAuthMode = .none
|
var authMode: AccountAuthMode = .web
|
||||||
|
|
||||||
var torAddresses = "100.64.0.2/32"
|
var torAddresses = "100.64.0.2/32"
|
||||||
var torDNS = "1.1.1.1, 1.0.0.1"
|
var torDNS = "1.1.1.1, 1.0.0.1"
|
||||||
|
|
@ -313,13 +311,13 @@ private struct AccountDraft {
|
||||||
accountName = "default"
|
accountName = "default"
|
||||||
identityName = "apple"
|
identityName = "apple"
|
||||||
authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||||
authMode = .web
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ConfigurationSheetView: View {
|
private struct ConfigurationSheetView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
|
||||||
|
|
||||||
let sheet: ConfigurationSheet
|
let sheet: ConfigurationSheet
|
||||||
let networkViewModel: NetworkViewModel
|
let networkViewModel: NetworkViewModel
|
||||||
|
|
@ -328,21 +326,17 @@ private struct ConfigurationSheetView: View {
|
||||||
@State private var draft: AccountDraft
|
@State private var draft: AccountDraft
|
||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var loginSessionID: String?
|
||||||
|
@State private var loginStatus: TailnetLoginStatus?
|
||||||
@State private var discoveryStatus: TailnetDiscoveryResponse?
|
@State private var discoveryStatus: TailnetDiscoveryResponse?
|
||||||
@State private var discoveryError: String?
|
@State private var discoveryError: String?
|
||||||
@State private var isDiscoveringTailnet = false
|
@State private var isDiscoveringTailnet = false
|
||||||
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
|
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
|
||||||
@State private var authorityProbeError: String?
|
@State private var authorityProbeError: String?
|
||||||
@State private var isProbingAuthority = false
|
@State private var isProbingAuthority = false
|
||||||
@State private var tailnetLoginStatus: TailnetLoginStatus?
|
@State private var pollingTask: Task<Void, Never>?
|
||||||
@State private var tailnetLoginError: String?
|
|
||||||
@State private var tailnetLoginSessionID: String?
|
|
||||||
@State private var isStartingTailnetLogin = false
|
|
||||||
@State private var tailnetPresentedAuthURL: URL?
|
|
||||||
@State private var preserveTailnetLoginSession = false
|
|
||||||
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
|
|
||||||
@State private var tailnetLoginPollTask: Task<Void, Never>?
|
|
||||||
@State private var didRunAutomation = false
|
@State private var didRunAutomation = false
|
||||||
|
@State private var webAuthenticationTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
sheet: ConfigurationSheet,
|
sheet: ConfigurationSheet,
|
||||||
|
|
@ -410,10 +404,7 @@ private struct ConfigurationSheetView: View {
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
Task { @MainActor in
|
dismiss()
|
||||||
await cancelTailnetLoginIfNeeded()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|
@ -456,26 +447,19 @@ private struct ConfigurationSheetView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
runAutomationIfNeeded()
|
runAutomationIfNeeded()
|
||||||
}
|
}
|
||||||
|
.onChange(of: draft.tailnetProvider) { _, _ in
|
||||||
|
resetAuthorityProbe()
|
||||||
|
}
|
||||||
.onChange(of: draft.authority) { _, _ in
|
.onChange(of: draft.authority) { _, _ in
|
||||||
resetAuthorityProbe()
|
resetAuthorityProbe()
|
||||||
}
|
}
|
||||||
.onChange(of: draft.discoveryEmail) { _, _ in
|
.onChange(of: draft.discoveryEmail) { _, _ in
|
||||||
resetTailnetDiscoveryFeedback()
|
resetTailnetDiscoveryFeedback()
|
||||||
}
|
}
|
||||||
.onChange(of: draft.authMode) { _, newMode in
|
|
||||||
guard newMode != .web else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
await cancelTailnetLoginIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
tailnetLoginPollTask?.cancel()
|
pollingTask?.cancel()
|
||||||
browserAuthenticator.cancel()
|
webAuthenticationTask?.cancel()
|
||||||
if !preserveTailnetLoginSession {
|
webAuthenticationTask = nil
|
||||||
Task { @MainActor in
|
|
||||||
await cancelTailnetLoginIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,10 +467,10 @@ private struct ConfigurationSheetView: View {
|
||||||
private var tailnetSections: some View {
|
private var tailnetSections: some View {
|
||||||
Section("Connection") {
|
Section("Connection") {
|
||||||
TextField("Email address", text: $draft.discoveryEmail)
|
TextField("Email address", text: $draft.discoveryEmail)
|
||||||
.burrowEmailField()
|
.textInputAutocapitalization(.never)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.accessibilityIdentifier("tailnet-discovery-email")
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
discoverTailnetAuthority()
|
discoverTailnetAuthority()
|
||||||
|
|
@ -499,7 +483,6 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil)
|
.disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil)
|
||||||
.accessibilityIdentifier("tailnet-find-server")
|
|
||||||
|
|
||||||
if let discoveryStatus {
|
if let discoveryStatus {
|
||||||
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
|
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
|
||||||
|
|
@ -507,82 +490,78 @@ private struct ConfigurationSheetView: View {
|
||||||
tailnetDiscoveryCard(status: nil, failure: discoveryError)
|
tailnetDiscoveryCard(status: nil, failure: discoveryError)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextField("Authority URL", text: $draft.authority)
|
Picker(
|
||||||
.burrowLoginField()
|
"Provider",
|
||||||
.autocorrectionDisabled()
|
selection: Binding(
|
||||||
.accessibilityIdentifier("tailnet-authority")
|
get: { draft.tailnetProvider },
|
||||||
|
set: { applyTailnetProvider($0) }
|
||||||
Text("Use the managed Tailnet authority or enter a custom Tailnet control server.")
|
)
|
||||||
.font(.footnote)
|
) {
|
||||||
.foregroundStyle(.secondary)
|
ForEach(TailnetProvider.allCases) { provider in
|
||||||
|
Text(provider.title).tag(provider)
|
||||||
Button {
|
|
||||||
probeTailnetAuthority()
|
|
||||||
} label: {
|
|
||||||
Label {
|
|
||||||
Text(isProbingAuthority ? "Checking Connection" : "Check Connection")
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.pickerStyle(.menu)
|
||||||
.disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil)
|
|
||||||
.accessibilityIdentifier("tailnet-check-connection")
|
|
||||||
|
|
||||||
if let authorityProbeStatus {
|
tailnetProviderCard
|
||||||
tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil)
|
|
||||||
} else if let authorityProbeError {
|
if draft.tailnetProvider.requiresControlURL {
|
||||||
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
|
TextField("Server URL", text: $draft.authority)
|
||||||
|
.burrowLoginField()
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
probeTailnetAuthority()
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
Text(isProbingAuthority ? "Checking Connection" : "Check Connection")
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil)
|
||||||
|
|
||||||
|
if let authorityProbeStatus {
|
||||||
|
tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil)
|
||||||
|
} else if let authorityProbeError {
|
||||||
|
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LabeledContent("Server") {
|
||||||
|
Text("Tailscale managed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextField("Tailnet", text: $draft.tailnet)
|
TextField("Tailnet", text: $draft.tailnet)
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.accessibilityIdentifier("tailnet-name")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Authentication") {
|
Section("Authentication") {
|
||||||
Picker("Authentication", selection: $draft.authMode) {
|
if tailnetUsesWebLogin {
|
||||||
ForEach(availableTailnetAuthModes) { mode in
|
tailnetWebLoginCard
|
||||||
Text(mode.title).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
if draft.authMode == .web {
|
|
||||||
Button {
|
|
||||||
startTailnetLogin()
|
|
||||||
} label: {
|
|
||||||
Label {
|
|
||||||
Text(isStartingTailnetLogin ? "Starting Sign-In" : tailnetSignInActionTitle)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: isStartingTailnetLogin ? "hourglass" : "person.badge.key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil)
|
|
||||||
.accessibilityIdentifier("tailnet-start-sign-in")
|
|
||||||
|
|
||||||
if let tailnetLoginStatus {
|
|
||||||
tailnetLoginCard(status: tailnetLoginStatus, failure: nil)
|
|
||||||
} else if let tailnetLoginError {
|
|
||||||
tailnetLoginCard(status: nil, failure: tailnetLoginError)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
TextField("Username", text: $draft.username)
|
TextField("Username", text: $draft.username)
|
||||||
.burrowLoginField()
|
.burrowLoginField()
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
Picker("Authentication", selection: $draft.authMode) {
|
||||||
|
ForEach(availableTailnetAuthModes) { mode in
|
||||||
|
Text(mode.title).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
if draft.authMode != .none {
|
if draft.authMode != .none {
|
||||||
SecureField(
|
SecureField(
|
||||||
draft.authMode == .password ? "Password" : "Preauth Key",
|
draft.authMode == .password ? "Password" : "Preauth Key",
|
||||||
text: $draft.secret
|
text: $draft.secret
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text("Credentials stay on-device. Burrow uses them when it needs to register or refresh this identity.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(tailnetAuthenticationFootnote)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -639,11 +618,10 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
if sheet == .tailnet {
|
if sheet == .tailnet {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
|
summaryBadge(draft.tailnetProvider.title)
|
||||||
summaryBadge(draft.authMode.title)
|
summaryBadge(
|
||||||
if tailnetLoginStatus?.running == true {
|
tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title
|
||||||
summaryBadge("Signed In")
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -654,6 +632,79 @@ private struct ConfigurationSheetView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tailnetProviderCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: tailnetProviderIconName)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(sheetAccentColor)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(sheetAccentColor.opacity(0.14))
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(draft.tailnetProvider.title)
|
||||||
|
.font(.headline)
|
||||||
|
Text(draft.tailnetProvider.subtitle)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(.thinMaterial)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var tailnetWebLoginCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Sign in with the shared browser session.")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
|
||||||
|
if let loginStatus {
|
||||||
|
labeledValue("State", loginStatus.backendState)
|
||||||
|
if let tailnetName = loginStatus.tailnetName {
|
||||||
|
labeledValue("Tailnet", tailnetName)
|
||||||
|
}
|
||||||
|
if let dnsName = loginStatus.selfDNSName {
|
||||||
|
labeledValue("Device", dnsName)
|
||||||
|
}
|
||||||
|
if !loginStatus.tailscaleIPs.isEmpty {
|
||||||
|
labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
if let authURL = loginStatus.authURL {
|
||||||
|
Button("Resume Sign-In") {
|
||||||
|
if let url = URL(string: authURL) {
|
||||||
|
openLoginURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
if !loginStatus.health.isEmpty {
|
||||||
|
Text(loginStatus.health.joined(separator: " • "))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Burrow launches the local bridge, then opens the real provider sign-in page in-app.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(.thinMaterial)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func tailnetAuthorityProbeCard(
|
private func tailnetAuthorityProbeCard(
|
||||||
status: TailnetAuthorityProbeStatus?,
|
status: TailnetAuthorityProbeStatus?,
|
||||||
failure: String?
|
failure: String?
|
||||||
|
|
@ -680,7 +731,6 @@ private struct ConfigurationSheetView: View {
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(.thinMaterial)
|
.fill(.thinMaterial)
|
||||||
)
|
)
|
||||||
.accessibilityIdentifier("tailnet-authority-probe-card")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tailnetDiscoveryCard(
|
private func tailnetDiscoveryCard(
|
||||||
|
|
@ -689,15 +739,12 @@ private struct ConfigurationSheetView: View {
|
||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
if let status {
|
if let status {
|
||||||
Text("Discovered Tailnet Server")
|
Text("Discovered \(status.provider.title)")
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
Text(status.authority)
|
Text(status.authority)
|
||||||
.font(.footnote.monospaced())
|
.font(.footnote.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
Text(status.provider == .tailscale ? "Managed authority" : "Custom authority")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
if let oidcIssuer = status.oidcIssuer {
|
if let oidcIssuer = status.oidcIssuer {
|
||||||
Text("OIDC: \(oidcIssuer)")
|
Text("OIDC: \(oidcIssuer)")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
|
@ -719,54 +766,6 @@ private struct ConfigurationSheetView: View {
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(.thinMaterial)
|
.fill(.thinMaterial)
|
||||||
)
|
)
|
||||||
.accessibilityIdentifier("tailnet-discovery-card")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tailnetLoginCard(
|
|
||||||
status: TailnetLoginStatus?,
|
|
||||||
failure: String?
|
|
||||||
) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
if let status {
|
|
||||||
Text(status.running ? "Signed In" : status.needsLogin ? "Browser Sign-In Required" : "Checking Sign-In")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
if let tailnetName = status.tailnetName, !tailnetName.isEmpty {
|
|
||||||
Text("Tailnet: \(tailnetName)")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
if let selfDNSName = status.selfDNSName, !selfDNSName.isEmpty {
|
|
||||||
Text(selfDNSName)
|
|
||||||
.font(.footnote.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
if !status.tailnetIPs.isEmpty {
|
|
||||||
Text(status.tailnetIPs.joined(separator: ", "))
|
|
||||||
.font(.footnote.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
if !status.health.isEmpty {
|
|
||||||
Text(status.health.joined(separator: " • "))
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else if let failure {
|
|
||||||
Text("Sign-In failed")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
Text(failure)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 16)
|
|
||||||
.fill(.thinMaterial)
|
|
||||||
)
|
|
||||||
.accessibilityIdentifier("tailnet-login-card")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func summaryBadge(_ label: String) -> some View {
|
private func summaryBadge(_ label: String) -> some View {
|
||||||
|
|
@ -827,8 +826,12 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
Button("Use Tailscale Managed Server") {
|
Menu("Provider") {
|
||||||
applyTailnetDefaults(for: .tailscale)
|
ForEach(TailnetProvider.allCases) { provider in
|
||||||
|
Button(provider.title) {
|
||||||
|
applyTailnetProvider(provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if availableTailnetAuthModes.count > 1 {
|
if availableTailnetAuthModes.count > 1 {
|
||||||
|
|
@ -836,7 +839,7 @@ private struct ConfigurationSheetView: View {
|
||||||
ForEach(availableTailnetAuthModes) { mode in
|
ForEach(availableTailnetAuthModes) { mode in
|
||||||
Button(mode.title) {
|
Button(mode.title) {
|
||||||
draft.authMode = mode
|
draft.authMode = mode
|
||||||
if mode == .none {
|
if mode == .none || mode == .web {
|
||||||
draft.secret = ""
|
draft.secret = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -844,8 +847,8 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Clear Discovery Result") {
|
Button("Restore Provider Defaults") {
|
||||||
resetTailnetDiscoveryFeedback()
|
applyTailnetDefaults(for: draft.tailnetProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -883,6 +886,17 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tailnetProviderIconName: String {
|
||||||
|
switch draft.tailnetProvider {
|
||||||
|
case .tailscale:
|
||||||
|
"globe.badge.chevron.backward"
|
||||||
|
case .headscale:
|
||||||
|
"server.rack"
|
||||||
|
case .burrow:
|
||||||
|
"shield"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var showsBottomActionButton: Bool {
|
private var showsBottomActionButton: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
true
|
true
|
||||||
|
|
@ -906,6 +920,9 @@ private struct ConfigurationSheetView: View {
|
||||||
case .tor:
|
case .tor:
|
||||||
return "Save Account"
|
return "Save Account"
|
||||||
case .tailnet:
|
case .tailnet:
|
||||||
|
if tailnetUsesWebLogin {
|
||||||
|
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
|
||||||
|
}
|
||||||
return "Save Account"
|
return "Save Account"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -920,11 +937,11 @@ private struct ConfigurationSheetView: View {
|
||||||
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
|
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if normalizedOptional(draft.authority) == nil {
|
if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if draft.authMode == .web {
|
if tailnetUsesWebLogin {
|
||||||
return tailnetLoginStatus?.running != true
|
return false
|
||||||
}
|
}
|
||||||
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
|
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
|
||||||
return true
|
return true
|
||||||
|
|
@ -1010,13 +1027,41 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitTailnet() async throws {
|
private func submitTailnet() async throws {
|
||||||
let secret = (draft.authMode == .none || draft.authMode == .web) ? nil : draft.secret
|
if tailnetUsesWebLogin {
|
||||||
|
if loginStatus?.running == true {
|
||||||
|
webAuthenticationTask?.cancel()
|
||||||
|
webAuthenticationTask = nil
|
||||||
|
try await saveTailnetAccount(secret: nil, username: nil)
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
try await startTailnetLogin()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let secret = draft.authMode == .none ? nil : draft.secret
|
||||||
let username = normalizedOptional(draft.username)
|
let username = normalizedOptional(draft.username)
|
||||||
preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true
|
|
||||||
try await saveTailnetAccount(secret: secret, username: username)
|
try await saveTailnetAccount(secret: secret, username: username)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startTailnetLogin() async throws {
|
||||||
|
let response = try await TailnetBridgeClient.startLogin(
|
||||||
|
TailnetLoginStartRequest(
|
||||||
|
accountName: normalized(draft.accountName, fallback: "default"),
|
||||||
|
identityName: normalized(draft.identityName, fallback: "apple"),
|
||||||
|
hostname: normalizedOptional(draft.hostname),
|
||||||
|
controlURL: normalizedOptional(draft.authority) ?? draft.tailnetProvider.defaultAuthority
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loginSessionID = response.sessionID
|
||||||
|
loginStatus = response.status
|
||||||
|
if let authURL = response.status.authURL, let url = URL(string: authURL) {
|
||||||
|
openLoginURL(url)
|
||||||
|
}
|
||||||
|
startPollingTailscaleLogin()
|
||||||
|
}
|
||||||
|
|
||||||
private func runAutomationIfNeeded() {
|
private func runAutomationIfNeeded() {
|
||||||
guard !didRunAutomation,
|
guard !didRunAutomation,
|
||||||
sheet == .tailnet,
|
sheet == .tailnet,
|
||||||
|
|
@ -1035,19 +1080,79 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch automation.action {
|
switch automation.action {
|
||||||
case .tailnetLogin:
|
case .tailnetLogin:
|
||||||
applyTailnetDefaults(for: .tailscale)
|
draft.tailnetProvider = .tailscale
|
||||||
startTailnetLogin()
|
do {
|
||||||
|
try await startTailnetLogin()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
case .headscaleProbe:
|
case .headscaleProbe:
|
||||||
|
applyTailnetProvider(.headscale)
|
||||||
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
|
||||||
probeTailnetAuthority()
|
probeTailnetAuthority()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startPollingTailscaleLogin() {
|
||||||
|
pollingTask?.cancel()
|
||||||
|
guard let loginSessionID else { return }
|
||||||
|
pollingTask = Task { @MainActor in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
let status = try await TailnetBridgeClient.status(sessionID: loginSessionID)
|
||||||
|
let previousAuthURL = loginStatus?.authURL
|
||||||
|
loginStatus = status
|
||||||
|
if previousAuthURL == nil,
|
||||||
|
let authURL = status.authURL,
|
||||||
|
let url = URL(string: authURL)
|
||||||
|
{
|
||||||
|
openLoginURL(url)
|
||||||
|
}
|
||||||
|
if status.running {
|
||||||
|
webAuthenticationTask?.cancel()
|
||||||
|
webAuthenticationTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openLoginURL(_ url: URL) {
|
||||||
|
webAuthenticationTask?.cancel()
|
||||||
|
webAuthenticationTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
do {
|
||||||
|
_ = try await webAuthenticationSession.authenticate(
|
||||||
|
using: url,
|
||||||
|
callbackURLScheme: "burrow",
|
||||||
|
preferredBrowserSession: .shared
|
||||||
|
)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch let error as ASWebAuthenticationSessionError
|
||||||
|
where error.code == .canceledLogin
|
||||||
|
{
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
webAuthenticationTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func saveTailnetAccount(secret: String?, username: String?) async throws {
|
private func saveTailnetAccount(secret: String?, username: String?) async throws {
|
||||||
let provider = inferredTailnetProvider
|
let provider = draft.tailnetProvider
|
||||||
let title = titleOrFallback(
|
let title = titleOrFallback(
|
||||||
hostnameFallback(from: draft.authority, fallback: "Tailnet")
|
hostnameFallback(
|
||||||
|
from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
|
||||||
|
fallback: provider.title
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
let payload = TailnetNetworkPayload(
|
let payload = TailnetNetworkPayload(
|
||||||
|
|
@ -1055,17 +1160,21 @@ private struct ConfigurationSheetView: View {
|
||||||
authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""),
|
authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""),
|
||||||
account: normalized(draft.accountName, fallback: "default"),
|
account: normalized(draft.accountName, fallback: "default"),
|
||||||
identity: normalized(draft.identityName, fallback: "apple"),
|
identity: normalized(draft.identityName, fallback: "apple"),
|
||||||
tailnet: normalizedOptional(draft.tailnet),
|
tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet),
|
||||||
hostname: normalizedOptional(draft.hostname)
|
hostname: normalizedOptional(draft.hostname)
|
||||||
)
|
)
|
||||||
|
|
||||||
var noteParts: [String] = [
|
var noteParts: [String] = [
|
||||||
isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet",
|
provider.title,
|
||||||
"Auth: \(draft.authMode.title)",
|
tailnetUsesWebLogin
|
||||||
|
? "State: \(loginStatus?.backendState ?? "NeedsLogin")"
|
||||||
|
: "Auth: \(draft.authMode.title)",
|
||||||
]
|
]
|
||||||
|
if let dnsName = loginStatus?.selfDNSName {
|
||||||
if draft.authMode == .web, tailnetLoginStatus?.running == true {
|
noteParts.append("Device: \(dnsName)")
|
||||||
noteParts.append("Browser sign-in complete")
|
}
|
||||||
|
if let magicDNSSuffix = loginStatus?.magicDNSSuffix {
|
||||||
|
noteParts.append("MagicDNS: \(magicDNSSuffix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|
@ -1077,7 +1186,7 @@ private struct ConfigurationSheetView: View {
|
||||||
|
|
||||||
let record = NetworkAccountRecord(
|
let record = NetworkAccountRecord(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .tailnet,
|
kind: .headscale,
|
||||||
title: title,
|
title: title,
|
||||||
authority: payload.authority,
|
authority: payload.authority,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
|
@ -1086,7 +1195,7 @@ private struct ConfigurationSheetView: View {
|
||||||
hostname: payload.hostname,
|
hostname: payload.hostname,
|
||||||
username: username,
|
username: username,
|
||||||
tailnet: payload.tailnet,
|
tailnet: payload.tailnet,
|
||||||
authMode: draft.authMode,
|
authMode: tailnetUsesWebLogin ? .web : draft.authMode,
|
||||||
note: noteParts.joined(separator: " • "),
|
note: noteParts.joined(separator: " • "),
|
||||||
createdAt: .now,
|
createdAt: .now,
|
||||||
updatedAt: .now
|
updatedAt: .now
|
||||||
|
|
@ -1117,44 +1226,33 @@ private struct ConfigurationSheetView: View {
|
||||||
draft.torListen = defaults.torListen
|
draft.torListen = defaults.torListen
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
private func applyTailnetProvider(_ provider: TailnetProvider) {
|
||||||
resetTailnetDiscoveryFeedback()
|
resetTailnetDiscoveryFeedback()
|
||||||
draft.authority = provider.defaultAuthority ?? ""
|
draft.tailnetProvider = provider
|
||||||
if !availableTailnetAuthModes.contains(draft.authMode) {
|
applyTailnetDefaults(for: provider)
|
||||||
draft.authMode = .web
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTailnetLogin() {
|
private func applyTailnetDefaults(for provider: TailnetProvider) {
|
||||||
guard let authority = normalizedOptional(draft.authority) else {
|
draft.authority = provider.defaultAuthority ?? ""
|
||||||
tailnetLoginStatus = nil
|
loginStatus = nil
|
||||||
tailnetLoginError = "Enter a server URL first."
|
loginSessionID = nil
|
||||||
return
|
pollingTask?.cancel()
|
||||||
}
|
if provider == .tailscale {
|
||||||
|
draft.authMode = .web
|
||||||
isStartingTailnetLogin = true
|
draft.username = ""
|
||||||
tailnetLoginError = nil
|
draft.secret = ""
|
||||||
preserveTailnetLoginSession = false
|
} else {
|
||||||
|
if !availableTailnetAuthModes.contains(draft.authMode) {
|
||||||
Task { @MainActor in
|
draft.authMode = provider.supportsWebLogin ? .web : .none
|
||||||
defer { isStartingTailnetLogin = false }
|
}
|
||||||
do {
|
if draft.authMode == .web && !provider.supportsWebLogin {
|
||||||
let status = try await networkViewModel.startTailnetLogin(
|
draft.authMode = .none
|
||||||
accountName: normalized(draft.accountName, fallback: "default"),
|
|
||||||
identityName: normalized(draft.identityName, fallback: "apple"),
|
|
||||||
hostname: normalizedOptional(draft.hostname),
|
|
||||||
authority: authority
|
|
||||||
)
|
|
||||||
tailnetLoginSessionID = status.sessionID
|
|
||||||
updateTailnetLoginStatus(status)
|
|
||||||
beginTailnetLoginPolling(sessionID: status.sessionID)
|
|
||||||
} catch {
|
|
||||||
tailnetLoginError = error.localizedDescription
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func probeTailnetAuthority() {
|
private func probeTailnetAuthority() {
|
||||||
|
guard draft.tailnetProvider.requiresControlURL else { return }
|
||||||
guard let authority = normalizedOptional(draft.authority) else {
|
guard let authority = normalizedOptional(draft.authority) else {
|
||||||
authorityProbeStatus = nil
|
authorityProbeStatus = nil
|
||||||
authorityProbeError = "Enter a server URL first."
|
authorityProbeError = "Enter a server URL first."
|
||||||
|
|
@ -1168,7 +1266,10 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { isProbingAuthority = false }
|
defer { isProbingAuthority = false }
|
||||||
do {
|
do {
|
||||||
authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority)
|
authorityProbeStatus = try await TailnetAuthorityProbeClient.probe(
|
||||||
|
provider: draft.tailnetProvider,
|
||||||
|
authority: authority
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
authorityProbeError = error.localizedDescription
|
authorityProbeError = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
@ -1178,7 +1279,6 @@ private struct ConfigurationSheetView: View {
|
||||||
private func resetAuthorityProbe() {
|
private func resetAuthorityProbe() {
|
||||||
authorityProbeStatus = nil
|
authorityProbeStatus = nil
|
||||||
authorityProbeError = nil
|
authorityProbeError = nil
|
||||||
tailnetLoginError = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetTailnetDiscoveryFeedback() {
|
private func resetTailnetDiscoveryFeedback() {
|
||||||
|
|
@ -1200,9 +1300,15 @@ private struct ConfigurationSheetView: View {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { isDiscoveringTailnet = false }
|
defer { isDiscoveringTailnet = false }
|
||||||
do {
|
do {
|
||||||
let discovery = try await networkViewModel.discoverTailnet(email: email)
|
let discovery = try await TailnetDiscoveryClient.discover(email: email)
|
||||||
discoveryStatus = discovery
|
discoveryStatus = discovery
|
||||||
|
draft.tailnetProvider = discovery.provider
|
||||||
draft.authority = discovery.authority
|
draft.authority = discovery.authority
|
||||||
|
if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil {
|
||||||
|
draft.authMode = .web
|
||||||
|
draft.username = ""
|
||||||
|
draft.secret = ""
|
||||||
|
}
|
||||||
probeTailnetAuthority()
|
probeTailnetAuthority()
|
||||||
} catch {
|
} catch {
|
||||||
discoveryError = error.localizedDescription
|
discoveryError = error.localizedDescription
|
||||||
|
|
@ -1210,76 +1316,6 @@ private struct ConfigurationSheetView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beginTailnetLoginPolling(sessionID: String) {
|
|
||||||
tailnetLoginPollTask?.cancel()
|
|
||||||
tailnetLoginPollTask = Task { @MainActor in
|
|
||||||
while !Task.isCancelled {
|
|
||||||
do {
|
|
||||||
let status = try await networkViewModel.tailnetLoginStatus(sessionID: sessionID)
|
|
||||||
updateTailnetLoginStatus(status)
|
|
||||||
if status.running {
|
|
||||||
tailnetLoginPollTask = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
tailnetLoginError = error.localizedDescription
|
|
||||||
tailnetLoginPollTask = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try? await Task.sleep(for: .seconds(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateTailnetLoginStatus(_ status: TailnetLoginStatus) {
|
|
||||||
tailnetLoginStatus = status
|
|
||||||
tailnetLoginError = nil
|
|
||||||
tailnetLoginSessionID = status.sessionID
|
|
||||||
|
|
||||||
if status.running {
|
|
||||||
browserAuthenticator.cancel()
|
|
||||||
tailnetPresentedAuthURL = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let authURL = status.authURL else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tailnetPresentedAuthURL != authURL {
|
|
||||||
tailnetPresentedAuthURL = authURL
|
|
||||||
browserAuthenticator.start(url: authURL) { [sessionID = status.sessionID] in
|
|
||||||
Task { @MainActor in
|
|
||||||
if tailnetLoginStatus?.running != true {
|
|
||||||
tailnetLoginSessionID = sessionID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cancelTailnetLoginIfNeeded() async {
|
|
||||||
tailnetLoginPollTask?.cancel()
|
|
||||||
tailnetLoginPollTask = nil
|
|
||||||
browserAuthenticator.cancel()
|
|
||||||
tailnetPresentedAuthURL = nil
|
|
||||||
|
|
||||||
guard tailnetLoginStatus?.running != true,
|
|
||||||
let sessionID = tailnetLoginSessionID
|
|
||||||
else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await networkViewModel.cancelTailnetLogin(sessionID: sessionID)
|
|
||||||
} catch {
|
|
||||||
tailnetLoginError = error.localizedDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
tailnetLoginStatus = nil
|
|
||||||
tailnetLoginSessionID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pasteWireGuardConfiguration() {
|
private func pasteWireGuardConfiguration() {
|
||||||
guard let clipboardString else { return }
|
guard let clipboardString else { return }
|
||||||
draft.wireGuardConfig = clipboardString
|
draft.wireGuardConfig = clipboardString
|
||||||
|
|
@ -1325,40 +1361,19 @@ private struct ConfigurationSheetView: View {
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tailnetUsesWebLogin: Bool {
|
||||||
|
draft.authMode == .web && draft.tailnetProvider.supportsWebLogin
|
||||||
|
}
|
||||||
|
|
||||||
private var availableTailnetAuthModes: [AccountAuthMode] {
|
private var availableTailnetAuthModes: [AccountAuthMode] {
|
||||||
[.web, .none, .password, .preauthKey]
|
switch draft.tailnetProvider {
|
||||||
}
|
case .tailscale:
|
||||||
|
[.web]
|
||||||
private var tailnetSignInActionTitle: String {
|
case .headscale:
|
||||||
if tailnetLoginStatus?.running == true {
|
[.web, .none, .password, .preauthKey]
|
||||||
return "Signed In"
|
case .burrow:
|
||||||
|
[.none, .password, .preauthKey]
|
||||||
}
|
}
|
||||||
if tailnetLoginSessionID != nil {
|
|
||||||
return "Resume Sign-In"
|
|
||||||
}
|
|
||||||
return "Start Sign-In"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var tailnetAuthenticationFootnote: String {
|
|
||||||
switch draft.authMode {
|
|
||||||
case .web:
|
|
||||||
return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running."
|
|
||||||
case .none:
|
|
||||||
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
|
|
||||||
case .password, .preauthKey:
|
|
||||||
return "Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var inferredTailnetProvider: TailnetProvider {
|
|
||||||
TailnetProvider.inferred(
|
|
||||||
authority: normalizedOptional(draft.authority),
|
|
||||||
explicit: discoveryStatus?.provider
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isManagedTailnetAuthority: Bool {
|
|
||||||
TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -1454,65 +1469,8 @@ private extension View {
|
||||||
self
|
self
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func burrowEmailField() -> some View {
|
|
||||||
#if os(iOS)
|
|
||||||
textInputAutocapitalization(.never)
|
|
||||||
.keyboardType(.emailAddress)
|
|
||||||
#else
|
|
||||||
self
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(AuthenticationServices)
|
|
||||||
@MainActor
|
|
||||||
private final class TailnetBrowserAuthenticator: NSObject {
|
|
||||||
private var session: ASWebAuthenticationSession?
|
|
||||||
|
|
||||||
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
|
|
||||||
cancel()
|
|
||||||
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { _, _ in
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
session.presentationContextProvider = self
|
|
||||||
session.prefersEphemeralWebBrowserSession = false
|
|
||||||
self.session = session
|
|
||||||
_ = session.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {
|
|
||||||
session?.cancel()
|
|
||||||
session = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TailnetBrowserAuthenticator: ASWebAuthenticationPresentationContextProviding {
|
|
||||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
||||||
#if canImport(AppKit)
|
|
||||||
return NSApplication.shared.keyWindow
|
|
||||||
?? NSApplication.shared.windows.first
|
|
||||||
?? ASPresentationAnchor()
|
|
||||||
#elseif canImport(UIKit)
|
|
||||||
return ASPresentationAnchor()
|
|
||||||
#else
|
|
||||||
return ASPresentationAnchor()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
@MainActor
|
|
||||||
private final class TailnetBrowserAuthenticator {
|
|
||||||
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
|
|
||||||
_ = url
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private struct BurrowAutomationConfig {
|
private struct BurrowAutomationConfig {
|
||||||
enum Action: String {
|
enum Action: String {
|
||||||
case tailnetLogin = "tailnet-login"
|
case tailnetLogin = "tailnet-login"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ struct TailnetNetworkPayload: Codable, Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TailnetLoginStartRequest: Codable, Sendable {
|
||||||
|
var accountName: String
|
||||||
|
var identityName: String
|
||||||
|
var hostname: String?
|
||||||
|
var controlURL: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct TailnetDiscoveryResponse: Codable, Sendable {
|
struct TailnetDiscoveryResponse: Codable, Sendable {
|
||||||
var domain: String
|
var domain: String
|
||||||
var provider: TailnetProvider
|
var provider: TailnetProvider
|
||||||
|
|
@ -33,6 +40,23 @@ struct TailnetDiscoveryResponse: Codable, Sendable {
|
||||||
var oidcIssuer: String?
|
var oidcIssuer: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TailnetLoginStatus: Codable, Sendable {
|
||||||
|
var backendState: String
|
||||||
|
var authURL: String?
|
||||||
|
var running: Bool
|
||||||
|
var needsLogin: Bool
|
||||||
|
var tailnetName: String?
|
||||||
|
var magicDNSSuffix: String?
|
||||||
|
var selfDNSName: String?
|
||||||
|
var tailscaleIPs: [String]
|
||||||
|
var health: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TailnetLoginStartResponse: Codable, Sendable {
|
||||||
|
var sessionID: String
|
||||||
|
var status: TailnetLoginStatus
|
||||||
|
}
|
||||||
|
|
||||||
struct TailnetAuthorityProbeStatus: Sendable {
|
struct TailnetAuthorityProbeStatus: Sendable {
|
||||||
var authority: String
|
var authority: String
|
||||||
var statusCode: Int
|
var statusCode: Int
|
||||||
|
|
@ -40,102 +64,147 @@ struct TailnetAuthorityProbeStatus: Sendable {
|
||||||
var detail: String?
|
var detail: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TailnetLoginStatus: Sendable {
|
enum TailnetBridgeClient {
|
||||||
var sessionID: String
|
private static let baseURL = URL(string: "http://127.0.0.1:8080")!
|
||||||
var backendState: String
|
|
||||||
var authURL: URL?
|
static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse {
|
||||||
var running: Bool
|
var urlRequest = URLRequest(
|
||||||
var needsLogin: Bool
|
url: baseURL.appendingPathComponent("v1/tailscale/login/start")
|
||||||
var tailnetName: String?
|
)
|
||||||
var magicDNSSuffix: String?
|
urlRequest.httpMethod = "POST"
|
||||||
var selfDNSName: String?
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
var tailnetIPs: [String]
|
|
||||||
var health: [String]
|
let encoder = JSONEncoder()
|
||||||
|
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||||
|
urlRequest.httpBody = try encoder.encode(request)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||||
|
try validate(response: response, data: data)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
return try decoder.decode(TailnetLoginStartResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status(sessionID: String) async throws -> TailnetLoginStatus {
|
||||||
|
let url = baseURL
|
||||||
|
.appendingPathComponent("v1/tailscale/login")
|
||||||
|
.appendingPathComponent(sessionID)
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
try validate(response: response, data: data)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
return try decoder.decode(TailnetLoginStatus.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func validate(response: URLResponse, data: Data) throws {
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let message = String(data: data, encoding: .utf8)?.trimmingCharacters(
|
||||||
|
in: .whitespacesAndNewlines
|
||||||
|
)
|
||||||
|
throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TailnetDiscoveryClient {
|
enum TailnetDiscoveryClient {
|
||||||
static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse {
|
private static let baseURL = URL(string: "http://127.0.0.1:8080")!
|
||||||
var request = Burrow_TailnetDiscoverRequest()
|
|
||||||
request.email = email
|
|
||||||
|
|
||||||
let response = try await TailnetClient.unix(socketURL: socketURL).discover(request)
|
static func discover(email: String) async throws -> TailnetDiscoveryResponse {
|
||||||
return TailnetDiscoveryResponse(
|
guard var components = URLComponents(
|
||||||
domain: response.domain,
|
url: baseURL.appendingPathComponent("v1/tailnet/discover"),
|
||||||
provider: response.managed ? .tailscale : .headscale,
|
resolvingAgainstBaseURL: false
|
||||||
authority: response.authority,
|
) else {
|
||||||
oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
throw URLError(.badURL)
|
||||||
? nil
|
}
|
||||||
: response.oidcIssuer
|
components.queryItems = [
|
||||||
)
|
URLQueryItem(name: "email", value: email)
|
||||||
|
]
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
try TailnetBridgeClient.validate(response: response, data: data)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
return try decoder.decode(TailnetDiscoveryResponse.self, from: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TailnetAuthorityProbeClient {
|
enum TailnetAuthorityProbeClient {
|
||||||
static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus {
|
static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus {
|
||||||
var request = Burrow_TailnetProbeRequest()
|
let normalizedAuthority = normalizeAuthority(authority)
|
||||||
request.authority = authority
|
let baseURL = try validatedBaseURL(normalizedAuthority)
|
||||||
|
let probeURL = probeURL(for: provider, baseURL: baseURL)
|
||||||
|
|
||||||
|
var request = URLRequest(url: probeURL)
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let message = String(data: data, encoding: .utf8)?.trimmingCharacters(
|
||||||
|
in: .whitespacesAndNewlines
|
||||||
|
)
|
||||||
|
throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = String(data: data, encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let detail = body.flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
|
||||||
let response = try await TailnetClient.unix(socketURL: socketURL).probe(request)
|
|
||||||
return TailnetAuthorityProbeStatus(
|
return TailnetAuthorityProbeStatus(
|
||||||
authority: response.authority,
|
authority: normalizedAuthority,
|
||||||
statusCode: Int(response.statusCode),
|
statusCode: http.statusCode,
|
||||||
summary: response.summary,
|
summary: "\(provider.title) reachable",
|
||||||
detail: response.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
detail: detail
|
||||||
? nil
|
|
||||||
: response.detail
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func normalizeAuthority(_ authority: String) -> String {
|
||||||
|
let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.contains("://") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return "https://\(trimmed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func validatedBaseURL(_ authority: String) throws -> URL {
|
||||||
|
guard let url = URL(string: authority), url.host != nil else {
|
||||||
|
throw TailnetBridgeError.server("Invalid server URL")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func probeURL(for provider: TailnetProvider, baseURL: URL) -> URL {
|
||||||
|
switch provider {
|
||||||
|
case .headscale:
|
||||||
|
baseURL.appendingPathComponent("health")
|
||||||
|
case .burrow:
|
||||||
|
baseURL.appendingPathComponent("healthz")
|
||||||
|
case .tailscale:
|
||||||
|
baseURL
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TailnetLoginClient {
|
enum TailnetBridgeError: LocalizedError {
|
||||||
static func start(
|
case server(String)
|
||||||
accountName: String,
|
|
||||||
identityName: String,
|
|
||||||
hostname: String?,
|
|
||||||
authority: String,
|
|
||||||
socketURL: URL
|
|
||||||
) async throws -> TailnetLoginStatus {
|
|
||||||
var request = Burrow_TailnetLoginStartRequest()
|
|
||||||
request.accountName = accountName
|
|
||||||
request.identityName = identityName
|
|
||||||
request.hostname = hostname ?? ""
|
|
||||||
request.authority = authority
|
|
||||||
let response = try await TailnetClient.unix(socketURL: socketURL).loginStart(request)
|
|
||||||
return decode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func status(sessionID: String, socketURL: URL) async throws -> TailnetLoginStatus {
|
var errorDescription: String? {
|
||||||
var request = Burrow_TailnetLoginStatusRequest()
|
switch self {
|
||||||
request.sessionID = sessionID
|
case .server(let message):
|
||||||
let response = try await TailnetClient.unix(socketURL: socketURL).loginStatus(request)
|
message
|
||||||
return decode(response)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
static func cancel(sessionID: String, socketURL: URL) async throws {
|
|
||||||
var request = Burrow_TailnetLoginCancelRequest()
|
|
||||||
request.sessionID = sessionID
|
|
||||||
_ = try await TailnetClient.unix(socketURL: socketURL).loginCancel(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decode(_ response: Burrow_TailnetLoginStatusResponse) -> TailnetLoginStatus {
|
|
||||||
TailnetLoginStatus(
|
|
||||||
sessionID: response.sessionID,
|
|
||||||
backendState: response.backendState,
|
|
||||||
authURL: URL(string: response.authURL.trimmingCharacters(in: .whitespacesAndNewlines)),
|
|
||||||
running: response.running,
|
|
||||||
needsLogin: response.needsLogin,
|
|
||||||
tailnetName: response.tailnetName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
? nil
|
|
||||||
: response.tailnetName,
|
|
||||||
magicDNSSuffix: response.magicDNSSuffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
? nil
|
|
||||||
: response.magicDNSSuffix,
|
|
||||||
selfDNSName: response.selfDNSName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
? nil
|
|
||||||
: response.selfDNSName,
|
|
||||||
tailnetIPs: response.tailnetIPs,
|
|
||||||
health: response.health
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,7 +215,7 @@ final class NetworkViewModel: Sendable {
|
||||||
private(set) var connectionError: String?
|
private(set) var connectionError: String?
|
||||||
private let socketURLResult: Result<URL, Error>
|
private let socketURLResult: Result<URL, Error>
|
||||||
|
|
||||||
@ObservationIgnored private var task: Task<Void, Never>?
|
nonisolated(unsafe) private var task: Task<Void, Never>?
|
||||||
|
|
||||||
init(socketURLResult: Result<URL, Error>) {
|
init(socketURLResult: Result<URL, Error>) {
|
||||||
self.socketURLResult = socketURLResult
|
self.socketURLResult = socketURLResult
|
||||||
|
|
@ -173,42 +242,6 @@ final class NetworkViewModel: Sendable {
|
||||||
try await addNetwork(type: .tailnet, payload: payload.encoded())
|
try await addNetwork(type: .tailnet, payload: payload.encoded())
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse {
|
|
||||||
let socketURL = try socketURLResult.get()
|
|
||||||
return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus {
|
|
||||||
let socketURL = try socketURLResult.get()
|
|
||||||
return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startTailnetLogin(
|
|
||||||
accountName: String,
|
|
||||||
identityName: String,
|
|
||||||
hostname: String?,
|
|
||||||
authority: String
|
|
||||||
) async throws -> TailnetLoginStatus {
|
|
||||||
let socketURL = try socketURLResult.get()
|
|
||||||
return try await TailnetLoginClient.start(
|
|
||||||
accountName: accountName,
|
|
||||||
identityName: identityName,
|
|
||||||
hostname: hostname,
|
|
||||||
authority: authority,
|
|
||||||
socketURL: socketURL
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus {
|
|
||||||
let socketURL = try socketURLResult.get()
|
|
||||||
return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelTailnetLogin(sessionID: String) async throws {
|
|
||||||
let socketURL = try socketURLResult.get()
|
|
||||||
try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
|
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
|
||||||
let socketURL = try socketURLResult.get()
|
let socketURL = try socketURLResult.get()
|
||||||
let networkID = nextNetworkID
|
let networkID = nextNetworkID
|
||||||
|
|
@ -308,6 +341,19 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var supportsWebLogin: Bool {
|
||||||
|
switch self {
|
||||||
|
case .tailscale, .headscale:
|
||||||
|
true
|
||||||
|
case .burrow:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiresControlURL: Bool {
|
||||||
|
self != .tailscale
|
||||||
|
}
|
||||||
|
|
||||||
var defaultAuthority: String? {
|
var defaultAuthority: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .tailscale:
|
case .tailscale:
|
||||||
|
|
@ -322,44 +368,19 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
var subtitle: String {
|
var subtitle: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .tailscale:
|
case .tailscale:
|
||||||
"Managed Tailnet authority."
|
"Use Tailscale's real browser login flow."
|
||||||
case .headscale:
|
case .headscale:
|
||||||
"Custom Tailnet control server."
|
"Use your Headscale control plane with browser or key-based sign-in."
|
||||||
case .burrow:
|
case .burrow:
|
||||||
"Burrow-native Tailnet authority."
|
"Store Burrow control-plane credentials."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func inferred(authority: String?, explicit: TailnetProvider?) -> TailnetProvider {
|
|
||||||
if explicit == .burrow {
|
|
||||||
return .burrow
|
|
||||||
}
|
|
||||||
if isManagedTailscaleAuthority(authority) {
|
|
||||||
return .tailscale
|
|
||||||
}
|
|
||||||
return .headscale
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isManagedTailscaleAuthority(_ authority: String?) -> Bool {
|
|
||||||
guard let normalized = authority?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.lowercased()
|
|
||||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/")),
|
|
||||||
!normalized.isEmpty
|
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized == "https://controlplane.tailscale.com"
|
|
||||||
|| normalized == "http://controlplane.tailscale.com"
|
|
||||||
|| normalized == "controlplane.tailscale.com"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
case wireGuard
|
case wireGuard
|
||||||
case tor
|
case tor
|
||||||
case tailnet
|
case headscale
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
|
@ -367,7 +388,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "WireGuard"
|
case .wireGuard: "WireGuard"
|
||||||
case .tor: "Tor"
|
case .tor: "Tor"
|
||||||
case .tailnet: "Tailnet"
|
case .headscale: "Tailnet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,7 +396,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "Import a tunnel and optional account metadata."
|
case .wireGuard: "Import a tunnel and optional account metadata."
|
||||||
case .tor: "Store Arti account and identity preferences."
|
case .tor: "Store Arti account and identity preferences."
|
||||||
case .tailnet: "Save Tailnet authority, identity, and login material."
|
case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,7 +404,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: .init("WireGuard")
|
case .wireGuard: .init("WireGuard")
|
||||||
case .tor: .orange
|
case .tor: .orange
|
||||||
case .tailnet: .mint
|
case .headscale: .mint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,7 +412,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
switch self {
|
switch self {
|
||||||
case .wireGuard: "Add Network"
|
case .wireGuard: "Add Network"
|
||||||
case .tor: "Save Account"
|
case .tor: "Save Account"
|
||||||
case .tailnet: "Save Account"
|
case .headscale: "Save Account"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,15 +422,15 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
nil
|
nil
|
||||||
case .tor:
|
case .tor:
|
||||||
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
||||||
case .tailnet:
|
case .headscale:
|
||||||
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon."
|
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
|
enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
case web
|
|
||||||
case none
|
case none
|
||||||
|
case web
|
||||||
case password
|
case password
|
||||||
case preauthKey
|
case preauthKey
|
||||||
|
|
||||||
|
|
@ -417,8 +438,8 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .web: "Browser Sign-In"
|
|
||||||
case .none: "None"
|
case .none: "None"
|
||||||
|
case .web: "Web Login"
|
||||||
case .password: "Password"
|
case .password: "Password"
|
||||||
case .preauthKey: "Preauth Key"
|
case .preauthKey: "Preauth Key"
|
||||||
}
|
}
|
||||||
|
|
@ -444,15 +465,17 @@ struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
|
||||||
struct TailnetCard {
|
struct TailnetCard {
|
||||||
var id: Int32
|
var id: Int32
|
||||||
|
var provider: String
|
||||||
var title: String
|
var title: String
|
||||||
var detail: String
|
var detail: String
|
||||||
|
|
||||||
init(network: Burrow_Network) {
|
init(network: Burrow_Network) {
|
||||||
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
|
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
|
||||||
id = network.id
|
id = network.id
|
||||||
|
provider = payload?.provider.title ?? "Tailnet"
|
||||||
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
|
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
|
||||||
detail = [
|
detail = [
|
||||||
payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority,
|
payload?.provider.title,
|
||||||
payload?.authority,
|
payload?.authority,
|
||||||
payload.map { "Account: \($0.account)" },
|
payload.map { "Account: \($0.account)" },
|
||||||
]
|
]
|
||||||
|
|
@ -469,7 +492,7 @@ struct TailnetCard {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Tailnet")
|
Text(provider)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|
|
||||||
6
Makefile
6
Makefile
|
|
@ -10,12 +10,6 @@ check:
|
||||||
build:
|
build:
|
||||||
@cargo build
|
@cargo build
|
||||||
|
|
||||||
bep-check:
|
|
||||||
@python3 Scripts/check-bep-metadata.py
|
|
||||||
|
|
||||||
bep-list:
|
|
||||||
@Scripts/bep list
|
|
||||||
|
|
||||||
daemon-console:
|
daemon-console:
|
||||||
@$(sudo_cargo_console) daemon
|
@$(sudo_cargo_console) daemon
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ Routine verification now runs unprivileged with `cargo test --workspace --all-fe
|
||||||
The repository now carries its own design and deployment record:
|
The repository now carries its own design and deployment record:
|
||||||
|
|
||||||
- [Constitution](./CONSTITUTION.md)
|
- [Constitution](./CONSTITUTION.md)
|
||||||
- [Agent Instructions](./AGENTS.md)
|
|
||||||
- [Burrow Evolution](./evolution/README.md)
|
- [Burrow Evolution](./evolution/README.md)
|
||||||
- [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.md)
|
- [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.md)
|
||||||
- [Protocol Roadmap](./docs/PROTOCOL_ROADMAP.md)
|
- [Protocol Roadmap](./docs/PROTOCOL_ROADMAP.md)
|
||||||
|
|
@ -20,8 +19,6 @@ The repository now carries its own design and deployment record:
|
||||||
|
|
||||||
Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh).
|
Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh).
|
||||||
|
|
||||||
Agent and governance-sensitive work should start with [AGENTS.md](./AGENTS.md), [CONSTITUTION.md](./CONSTITUTION.md), and the relevant BEPs under [`evolution/proposals/`](./evolution/proposals/). Identity and bootstrap metadata now live in [`contributors.nix`](./contributors.nix).
|
|
||||||
|
|
||||||
The project structure is divided in the following folders:
|
The project structure is divided in the following folders:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ lookup_user_pk() {
|
||||||
|
|
||||||
ensure_user() {
|
ensure_user() {
|
||||||
local user_spec="$1"
|
local user_spec="$1"
|
||||||
local username name email is_admin groups_json password_file effective_groups_json group_name
|
local username name email is_admin groups_json effective_groups_json group_name
|
||||||
local group_pks_json payload user_pk
|
local group_pks_json payload user_pk
|
||||||
|
|
||||||
username="$(printf '%s\n' "$user_spec" | jq -r '.username')"
|
username="$(printf '%s\n' "$user_spec" | jq -r '.username')"
|
||||||
|
|
@ -124,7 +124,6 @@ ensure_user() {
|
||||||
email="$(printf '%s\n' "$user_spec" | jq -r '.email')"
|
email="$(printf '%s\n' "$user_spec" | jq -r '.email')"
|
||||||
is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')"
|
is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')"
|
||||||
groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')"
|
groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')"
|
||||||
password_file="$(printf '%s\n' "$user_spec" | jq -r '.passwordFile // empty')"
|
|
||||||
|
|
||||||
if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then
|
if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then
|
||||||
echo "error: each Burrow Authentik user requires username and email" >&2
|
echo "error: each Burrow Authentik user requires username and email" >&2
|
||||||
|
|
@ -179,19 +178,6 @@ ensure_user() {
|
||||||
echo "error: could not create Authentik user ${username}" >&2
|
echo "error: could not create Authentik user ${username}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$password_file" ]]; then
|
|
||||||
if [[ ! -s "$password_file" ]]; then
|
|
||||||
echo "error: password file for Authentik user ${username} is missing: ${password_file}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
api POST "/api/v3/core/users/${user_pk}/set_password/" "$(
|
|
||||||
jq -cn \
|
|
||||||
--arg password "$(tr -d '\r\n' < "$password_file")" \
|
|
||||||
'{password: $password}'
|
|
||||||
)" >/dev/null
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lookup_application_pk() {
|
lookup_application_pk() {
|
||||||
|
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}"
|
|
||||||
bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
|
|
||||||
provider_slug="${AUTHENTIK_TAILNET_PROVIDER_SLUG:-ts}"
|
|
||||||
provider_slugs_json="${AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON:-}"
|
|
||||||
authentication_flow_name="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME:-Burrow Tailnet Authentication}"
|
|
||||||
authentication_flow_slug="${AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG:-burrow-tailnet-authentication}"
|
|
||||||
identification_stage_name="${AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME:-burrow-tailnet-identification-stage}"
|
|
||||||
password_stage_name="${AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME:-burrow-tailnet-password-stage}"
|
|
||||||
user_login_stage_name="${AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME:-burrow-tailnet-user-login-stage}"
|
|
||||||
google_source_slug="${AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG:-google}"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: Scripts/authentik-sync-tailnet-auth-flow.sh
|
|
||||||
|
|
||||||
Required environment:
|
|
||||||
AUTHENTIK_BOOTSTRAP_TOKEN
|
|
||||||
|
|
||||||
Optional environment:
|
|
||||||
AUTHENTIK_URL
|
|
||||||
AUTHENTIK_TAILNET_PROVIDER_SLUG
|
|
||||||
AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON
|
|
||||||
AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME
|
|
||||||
AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG
|
|
||||||
AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME
|
|
||||||
AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME
|
|
||||||
AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME
|
|
||||||
AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$bootstrap_token" ]]; then
|
|
||||||
echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$provider_slugs_json" ]]; then
|
|
||||||
if ! printf '%s' "$provider_slugs_json" | jq -e 'type == "array" and length > 0 and all(.[]; type == "string" and length > 0)' >/dev/null; then
|
|
||||||
echo "error: AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON must be a non-empty JSON array of strings" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
provider_slugs_json="$(jq -cn --arg slug "$provider_slug" '[$slug]')"
|
|
||||||
fi
|
|
||||||
|
|
||||||
api() {
|
|
||||||
local method="$1"
|
|
||||||
local path="$2"
|
|
||||||
local data="${3:-}"
|
|
||||||
|
|
||||||
if [[ -n "$data" ]]; then
|
|
||||||
curl -fsS \
|
|
||||||
-X "$method" \
|
|
||||||
-H "Authorization: Bearer ${bootstrap_token}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$data" \
|
|
||||||
"${authentik_url}${path}"
|
|
||||||
else
|
|
||||||
curl -fsS \
|
|
||||||
-X "$method" \
|
|
||||||
-H "Authorization: Bearer ${bootstrap_token}" \
|
|
||||||
"${authentik_url}${path}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_authentik() {
|
|
||||||
for _ in $(seq 1 90); do
|
|
||||||
if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "error: Authentik did not become ready at ${authentik_url}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
lookup_stage_by_name() {
|
|
||||||
local path="$1"
|
|
||||||
local name="$2"
|
|
||||||
|
|
||||||
api GET "${path}?page_size=200" \
|
|
||||||
| jq -c --arg name "$name" '.results[]? | select(.name == $name)' \
|
|
||||||
| head -n1
|
|
||||||
}
|
|
||||||
|
|
||||||
lookup_flow_pk() {
|
|
||||||
local slug="$1"
|
|
||||||
|
|
||||||
api GET "/api/v3/flows/instances/?slug=${slug}" \
|
|
||||||
| jq -r '.results[]? | select(.slug != null) | .pk // empty' \
|
|
||||||
| head -n1
|
|
||||||
}
|
|
||||||
|
|
||||||
lookup_source_pk() {
|
|
||||||
local slug="$1"
|
|
||||||
|
|
||||||
api GET "/api/v3/sources/oauth/?page_size=200&slug=${slug}" \
|
|
||||||
| jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \
|
|
||||||
| head -n1
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_password_stage() {
|
|
||||||
local existing payload stage_pk
|
|
||||||
|
|
||||||
existing="$(lookup_stage_by_name "/api/v3/stages/password/" "$password_stage_name")"
|
|
||||||
payload="$(
|
|
||||||
jq -cn \
|
|
||||||
--arg name "$password_stage_name" \
|
|
||||||
'{
|
|
||||||
name: $name,
|
|
||||||
backends: [
|
|
||||||
"authentik.core.auth.InbuiltBackend",
|
|
||||||
"authentik.core.auth.TokenBackend"
|
|
||||||
],
|
|
||||||
allow_show_password: false,
|
|
||||||
failed_attempts_before_cancel: 5
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -n "$existing" ]]; then
|
|
||||||
stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')"
|
|
||||||
api PATCH "/api/v3/stages/password/${stage_pk}/" "$payload" >/dev/null
|
|
||||||
else
|
|
||||||
stage_pk="$(
|
|
||||||
api POST "/api/v3/stages/password/" "$payload" \
|
|
||||||
| jq -r '.pk // empty'
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$stage_pk"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_identification_stage() {
|
|
||||||
local password_stage_pk="$1"
|
|
||||||
local google_source_pk="$2"
|
|
||||||
local existing payload stage_pk sources_json
|
|
||||||
|
|
||||||
existing="$(lookup_stage_by_name "/api/v3/stages/identification/" "$identification_stage_name")"
|
|
||||||
if [[ -n "$google_source_pk" ]]; then
|
|
||||||
sources_json="$(jq -cn --arg source "$google_source_pk" '[$source]')"
|
|
||||||
else
|
|
||||||
sources_json='[]'
|
|
||||||
fi
|
|
||||||
|
|
||||||
payload="$(
|
|
||||||
jq -cn \
|
|
||||||
--arg name "$identification_stage_name" \
|
|
||||||
--arg password_stage "$password_stage_pk" \
|
|
||||||
--argjson sources "$sources_json" \
|
|
||||||
'{
|
|
||||||
name: $name,
|
|
||||||
user_fields: ["username", "email"],
|
|
||||||
password_stage: $password_stage,
|
|
||||||
case_insensitive_matching: true,
|
|
||||||
show_matched_user: true,
|
|
||||||
sources: $sources,
|
|
||||||
show_source_labels: true,
|
|
||||||
pretend_user_exists: false,
|
|
||||||
enable_remember_me: false
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -n "$existing" ]]; then
|
|
||||||
stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')"
|
|
||||||
api PATCH "/api/v3/stages/identification/${stage_pk}/" "$payload" >/dev/null
|
|
||||||
else
|
|
||||||
stage_pk="$(
|
|
||||||
api POST "/api/v3/stages/identification/" "$payload" \
|
|
||||||
| jq -r '.pk // empty'
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$stage_pk"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_user_login_stage() {
|
|
||||||
local existing payload stage_pk
|
|
||||||
|
|
||||||
existing="$(lookup_stage_by_name "/api/v3/stages/user_login/" "$user_login_stage_name")"
|
|
||||||
payload="$(
|
|
||||||
jq -cn \
|
|
||||||
--arg name "$user_login_stage_name" \
|
|
||||||
'{
|
|
||||||
name: $name,
|
|
||||||
session_duration: "hours=12",
|
|
||||||
terminate_other_sessions: false,
|
|
||||||
remember_me_offset: "seconds=0",
|
|
||||||
network_binding: "no_binding",
|
|
||||||
geoip_binding: "no_binding"
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -n "$existing" ]]; then
|
|
||||||
stage_pk="$(printf '%s\n' "$existing" | jq -r '.pk')"
|
|
||||||
api PATCH "/api/v3/stages/user_login/${stage_pk}/" "$payload" >/dev/null
|
|
||||||
else
|
|
||||||
stage_pk="$(
|
|
||||||
api POST "/api/v3/stages/user_login/" "$payload" \
|
|
||||||
| jq -r '.pk // empty'
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$stage_pk"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_authentication_flow() {
|
|
||||||
local existing_pk payload
|
|
||||||
|
|
||||||
existing_pk="$(lookup_flow_pk "$authentication_flow_slug")"
|
|
||||||
payload="$(
|
|
||||||
jq -cn \
|
|
||||||
--arg name "$authentication_flow_name" \
|
|
||||||
--arg slug "$authentication_flow_slug" \
|
|
||||||
'{
|
|
||||||
name: $name,
|
|
||||||
title: $name,
|
|
||||||
slug: $slug,
|
|
||||||
designation: "authentication",
|
|
||||||
policy_engine_mode: "any",
|
|
||||||
layout: "stacked"
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -n "$existing_pk" ]]; then
|
|
||||||
api PATCH "/api/v3/flows/instances/${authentication_flow_slug}/" "$payload" >/dev/null
|
|
||||||
printf '%s\n' "$existing_pk"
|
|
||||||
else
|
|
||||||
api POST "/api/v3/flows/instances/" "$payload" \
|
|
||||||
| jq -r '.pk // empty'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_flow_binding() {
|
|
||||||
local flow_pk="$1"
|
|
||||||
local stage_pk="$2"
|
|
||||||
local order="$3"
|
|
||||||
local existing payload binding_pk
|
|
||||||
|
|
||||||
existing="$(
|
|
||||||
api GET "/api/v3/flows/bindings/?target=${flow_pk}&stage=${stage_pk}&page_size=200" \
|
|
||||||
| jq -c '.results[]?' \
|
|
||||||
| head -n1
|
|
||||||
)"
|
|
||||||
|
|
||||||
payload="$(
|
|
||||||
jq -cn \
|
|
||||||
--arg target "$flow_pk" \
|
|
||||||
--arg stage "$stage_pk" \
|
|
||||||
--argjson order "$order" \
|
|
||||||
'{
|
|
||||||
target: $target,
|
|
||||||
stage: $stage,
|
|
||||||
order: $order,
|
|
||||||
policy_engine_mode: "any"
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -n "$existing" ]]; then
|
|
||||||
binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')"
|
|
||||||
api PATCH "/api/v3/flows/bindings/${binding_pk}/" "$payload" >/dev/null
|
|
||||||
else
|
|
||||||
api POST "/api/v3/flows/bindings/" "$payload" >/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_authentik
|
|
||||||
|
|
||||||
mapfile -t provider_pks < <(
|
|
||||||
api GET "/api/v3/providers/oauth2/?page_size=200" \
|
|
||||||
| jq -r --argjson provider_slugs "$provider_slugs_json" '
|
|
||||||
.results[]?
|
|
||||||
| select(
|
|
||||||
((.assigned_application_slug // empty) as $assigned | ($provider_slugs | index($assigned)) != null)
|
|
||||||
or ((.slug // empty) as $slug | ($provider_slugs | index($slug)) != null)
|
|
||||||
)
|
|
||||||
| .pk // empty
|
|
||||||
'
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "${#provider_pks[@]}" -eq 0 ]]; then
|
|
||||||
echo "error: could not resolve any Authentik Tailnet OAuth providers from ${provider_slugs_json}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
google_source_pk="$(lookup_source_pk "$google_source_slug" || true)"
|
|
||||||
password_stage_pk="$(ensure_password_stage)"
|
|
||||||
identification_stage_pk="$(ensure_identification_stage "$password_stage_pk" "$google_source_pk")"
|
|
||||||
user_login_stage_pk="$(ensure_user_login_stage)"
|
|
||||||
authentication_flow_pk="$(ensure_authentication_flow)"
|
|
||||||
|
|
||||||
ensure_flow_binding "$authentication_flow_pk" "$identification_stage_pk" 10
|
|
||||||
ensure_flow_binding "$authentication_flow_pk" "$user_login_stage_pk" 30
|
|
||||||
|
|
||||||
for provider_pk in "${provider_pks[@]}"; do
|
|
||||||
api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$(
|
|
||||||
jq -cn --arg flow "$authentication_flow_pk" '{authentication_flow: $flow}'
|
|
||||||
)" >/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Synced Burrow Tailnet authentication flow for providers ${provider_slugs_json}."
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}"
|
|
||||||
bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
|
|
||||||
application_slug="${AUTHENTIK_TAILSCALE_APPLICATION_SLUG:-tailscale}"
|
|
||||||
application_name="${AUTHENTIK_TAILSCALE_APPLICATION_NAME:-Tailscale}"
|
|
||||||
provider_name="${AUTHENTIK_TAILSCALE_PROVIDER_NAME:-Tailscale}"
|
|
||||||
template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}"
|
|
||||||
client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}"
|
|
||||||
client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}"
|
|
||||||
launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}"
|
|
||||||
redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[
|
|
||||||
\"https://login.tailscale.com/a/oauth_response\"
|
|
||||||
]}"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: Scripts/authentik-sync-tailscale-oidc.sh
|
|
||||||
|
|
||||||
Required environment:
|
|
||||||
AUTHENTIK_BOOTSTRAP_TOKEN
|
|
||||||
AUTHENTIK_TAILSCALE_CLIENT_SECRET
|
|
||||||
|
|
||||||
Optional environment:
|
|
||||||
AUTHENTIK_URL
|
|
||||||
AUTHENTIK_TAILSCALE_APPLICATION_SLUG
|
|
||||||
AUTHENTIK_TAILSCALE_APPLICATION_NAME
|
|
||||||
AUTHENTIK_TAILSCALE_PROVIDER_NAME
|
|
||||||
AUTHENTIK_TAILSCALE_TEMPLATE_SLUG
|
|
||||||
AUTHENTIK_TAILSCALE_CLIENT_ID
|
|
||||||
AUTHENTIK_TAILSCALE_LAUNCH_URL
|
|
||||||
AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$bootstrap_token" ]]; then
|
|
||||||
echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then
|
|
||||||
echo "Tailscale OIDC client secret is not configured; skipping Authentik Tailscale sync." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then
|
|
||||||
echo "error: AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
api() {
|
|
||||||
local method="$1"
|
|
||||||
local path="$2"
|
|
||||||
local data="${3:-}"
|
|
||||||
|
|
||||||
if [[ -n "$data" ]]; then
|
|
||||||
curl -fsS \
|
|
||||||
-X "$method" \
|
|
||||||
-H "Authorization: Bearer ${bootstrap_token}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$data" \
|
|
||||||
"${authentik_url}${path}"
|
|
||||||
else
|
|
||||||
curl -fsS \
|
|
||||||
-X "$method" \
|
|
||||||
-H "Authorization: Bearer ${bootstrap_token}" \
|
|
||||||
"${authentik_url}${path}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
api_with_status() {
|
|
||||||
local method="$1"
|
|
||||||
local path="$2"
|
|
||||||
local data="${3:-}"
|
|
||||||
local response_file status
|
|
||||||
|
|
||||||
response_file="$(mktemp)"
|
|
||||||
trap 'rm -f "$response_file"' RETURN
|
|
||||||
|
|
||||||
if [[ -n "$data" ]]; then
|
|
||||||
status="$(
|
|
||||||
curl -sS \
|
|
||||||
-o "$response_file" \
|
|
||||||
-w '%{http_code}' \
|
|
||||||
-X "$method" \
|
|
||||||
-H "Authorization: Bearer ${bootstrap_token}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$data" \
|
|
||||||
"${authentik_url}${path}"
|
|
||||||
)"
|
|
||||||
else
|
|
||||||
status="$(
|
|
||||||
curl -sS \
|
|
||||||
-o "$response_file" \
|
|
||||||
-w '%{http_code}' \
|
|
||||||
-X "$method" \
|
|
||||||
-H "Authorization: Bearer ${bootstrap_token}" \
|
|
||||||
"${authentik_url}${path}"
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$status"
|
|
||||||
cat "$response_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_authentik() {
|
|
||||||
for _ in $(seq 1 90); do
|
|
||||||
if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "error: Authentik did not become ready at ${authentik_url}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_authentik
|
|
||||||
|
|
||||||
template_provider="$(
|
|
||||||
api GET "/api/v3/providers/oauth2/?page_size=200" \
|
|
||||||
| jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \
|
|
||||||
| head -n1
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -z "$template_provider" ]]; then
|
|
||||||
echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')"
|
|
||||||
invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')"
|
|
||||||
property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')"
|
|
||||||
signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')"
|
|
||||||
|
|
||||||
provider_payload="$(
|
|
||||||
jq -n \
|
|
||||||
--arg name "$provider_name" \
|
|
||||||
--arg authorization_flow "$authorization_flow" \
|
|
||||||
--arg invalidation_flow "$invalidation_flow" \
|
|
||||||
--arg client_id "$client_id" \
|
|
||||||
--arg client_secret "$client_secret" \
|
|
||||||
--arg signing_key "$signing_key" \
|
|
||||||
--argjson property_mappings "$property_mappings" \
|
|
||||||
--argjson redirect_uris "$redirect_uris_json" \
|
|
||||||
'{
|
|
||||||
name: $name,
|
|
||||||
authorization_flow: $authorization_flow,
|
|
||||||
invalidation_flow: $invalidation_flow,
|
|
||||||
client_type: "confidential",
|
|
||||||
client_id: $client_id,
|
|
||||||
client_secret: $client_secret,
|
|
||||||
include_claims_in_id_token: true,
|
|
||||||
redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})),
|
|
||||||
property_mappings: $property_mappings,
|
|
||||||
signing_key: $signing_key,
|
|
||||||
issuer_mode: "per_provider",
|
|
||||||
sub_mode: "hashed_user_id"
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
existing_provider="$(
|
|
||||||
api GET "/api/v3/providers/oauth2/?page_size=200" \
|
|
||||||
| jq -c \
|
|
||||||
--arg application_slug "$application_slug" \
|
|
||||||
--arg provider_name "$provider_name" \
|
|
||||||
'.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \
|
|
||||||
| head -n1
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -n "$existing_provider" ]]; then
|
|
||||||
provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')"
|
|
||||||
api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null
|
|
||||||
else
|
|
||||||
provider_pk="$(
|
|
||||||
api POST "/api/v3/providers/oauth2/" "$provider_payload" \
|
|
||||||
| jq -r '.pk // empty'
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${provider_pk:-}" ]]; then
|
|
||||||
echo "error: Tailscale OIDC provider did not return a primary key" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
application_payload="$(
|
|
||||||
jq -n \
|
|
||||||
--arg name "$application_name" \
|
|
||||||
--arg slug "$application_slug" \
|
|
||||||
--arg provider "$provider_pk" \
|
|
||||||
--arg launch_url "$launch_url" \
|
|
||||||
'{
|
|
||||||
name: $name,
|
|
||||||
slug: $slug,
|
|
||||||
provider: ($provider | tonumber),
|
|
||||||
meta_launch_url: $launch_url,
|
|
||||||
open_in_new_tab: true,
|
|
||||||
policy_engine_mode: "any"
|
|
||||||
}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
existing_application="$(
|
|
||||||
api GET "/api/v3/core/applications/?page_size=200" \
|
|
||||||
| jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \
|
|
||||||
| head -n1
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -n "$existing_application" ]]; then
|
|
||||||
application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')"
|
|
||||||
else
|
|
||||||
create_application_result="$(
|
|
||||||
api_with_status POST "/api/v3/core/applications/" "$application_payload"
|
|
||||||
)"
|
|
||||||
create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')"
|
|
||||||
create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')"
|
|
||||||
|
|
||||||
if [[ "$create_application_status" =~ ^20[01]$ ]]; then
|
|
||||||
application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')"
|
|
||||||
elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e '
|
|
||||||
(.slug // [] | index("Application with this slug already exists.")) != null
|
|
||||||
or (.provider // [] | index("Application with this provider already exists.")) != null
|
|
||||||
' >/dev/null; then
|
|
||||||
application_pk="existing-duplicate"
|
|
||||||
else
|
|
||||||
printf '%s\n' "$create_application_body" >&2
|
|
||||||
echo "error: could not reconcile Authentik application ${application_slug}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${application_pk:-}" ]]; then
|
|
||||||
echo "error: Tailscale OIDC application did not return a primary key" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for _ in $(seq 1 30); do
|
|
||||||
if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then
|
|
||||||
echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "warning: Tailscale OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2
|
|
||||||
echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})."
|
|
||||||
133
Scripts/bep
133
Scripts/bep
|
|
@ -1,133 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repo_root=$(git rev-parse --show-toplevel)
|
|
||||||
proposals_dir="$repo_root/evolution/proposals"
|
|
||||||
|
|
||||||
auto_browse() {
|
|
||||||
if command -v wisu >/dev/null 2>&1; then
|
|
||||||
exec wisu -i -g --icons "$repo_root/evolution"
|
|
||||||
fi
|
|
||||||
exec ls -la "$repo_root/evolution"
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'USAGE'
|
|
||||||
Usage: bep [command]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
list [--status <Status>] List BEPs, optionally filtered by status.
|
|
||||||
open <BEP-XXXX|XXXX|X> Open a BEP in $EDITOR.
|
|
||||||
help Show this help.
|
|
||||||
|
|
||||||
If no command is provided, bep launches a simple browser for evolution/.
|
|
||||||
USAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
normalize_id() {
|
|
||||||
local raw="$1"
|
|
||||||
if [[ "$raw" =~ ^BEP-[0-9]+$ ]]; then
|
|
||||||
printf '%s' "$raw"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ "$raw" =~ ^[0-9]+$ ]]; then
|
|
||||||
printf 'BEP-%04d' "$raw"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
read_status() {
|
|
||||||
local file="$1"
|
|
||||||
awk -F ': ' '/^Status:/ {print $2; exit}' "$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
read_title() {
|
|
||||||
local file="$1"
|
|
||||||
local line
|
|
||||||
line=$(head -n 1 "$file" || true)
|
|
||||||
printf '%s' "$line" | sed -E 's/^# `[^`]+`[[:space:]]+//; s/^[^A-Za-z0-9]+//'
|
|
||||||
}
|
|
||||||
|
|
||||||
list_bep() {
|
|
||||||
local filter="${1:-}"
|
|
||||||
local filter_lower=""
|
|
||||||
if [[ -n "$filter" ]]; then
|
|
||||||
filter_lower=$(printf '%s' "$filter" | tr '[:upper:]' '[:lower:]')
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%-10s %-18s %s\n' "BEP" "Status" "Title"
|
|
||||||
local file
|
|
||||||
local entries=()
|
|
||||||
for file in "$proposals_dir"/BEP-*.md; do
|
|
||||||
[[ -e "$file" ]] || continue
|
|
||||||
local base
|
|
||||||
base=$(basename "$file")
|
|
||||||
local id
|
|
||||||
id=$(printf '%s' "$base" | cut -d- -f1-2)
|
|
||||||
local status
|
|
||||||
status=$(read_status "$file")
|
|
||||||
local status_lower
|
|
||||||
status_lower=$(printf '%s' "$status" | tr '[:upper:]' '[:lower:]')
|
|
||||||
if [[ -n "$filter_lower" && "$status_lower" != "$filter_lower" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
local title
|
|
||||||
title=$(read_title "$file")
|
|
||||||
entries+=("$(printf '%-10s %-18s %s' "$id" "$status" "$title")")
|
|
||||||
done
|
|
||||||
if [[ ${#entries[@]} -gt 0 ]]; then
|
|
||||||
printf '%s\n' "${entries[@]}" | sort
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
open_bep() {
|
|
||||||
local raw="$1"
|
|
||||||
local id
|
|
||||||
if ! id=$(normalize_id "$raw"); then
|
|
||||||
echo "Unknown BEP id: $raw" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
local matches
|
|
||||||
matches=("$proposals_dir"/"$id"-*.md)
|
|
||||||
if [[ ${#matches[@]} -eq 0 || ! -e "${matches[0]}" ]]; then
|
|
||||||
echo "No proposal found for $id" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ${#matches[@]} -gt 1 ]]; then
|
|
||||||
echo "Multiple proposals match $id:" >&2
|
|
||||||
printf ' %s\n' "${matches[@]}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
local editor="${EDITOR:-vi}"
|
|
||||||
exec "$editor" "${matches[0]}"
|
|
||||||
}
|
|
||||||
|
|
||||||
command=${1:-}
|
|
||||||
case "$command" in
|
|
||||||
"")
|
|
||||||
auto_browse
|
|
||||||
;;
|
|
||||||
list)
|
|
||||||
if [[ ${2:-} == "--status" && -n ${3:-} ]]; then
|
|
||||||
list_bep "$3"
|
|
||||||
else
|
|
||||||
list_bep
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
open)
|
|
||||||
if [[ -z ${2:-} ]]; then
|
|
||||||
echo "bep open requires an id" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
open_bep "$2"
|
|
||||||
;;
|
|
||||||
help|-h|--help)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown command: $command" >&2
|
|
||||||
usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
||||||
PROPOSALS_DIR = REPO_ROOT / "evolution" / "proposals"
|
|
||||||
ALLOWED_STATUSES = {
|
|
||||||
"Pitch",
|
|
||||||
"Draft",
|
|
||||||
"In Review",
|
|
||||||
"Accepted",
|
|
||||||
"Implemented",
|
|
||||||
"Rejected",
|
|
||||||
"Returned for Revision",
|
|
||||||
"Superseded",
|
|
||||||
"Archived",
|
|
||||||
}
|
|
||||||
REQUIRED_FIELDS = [
|
|
||||||
"Status",
|
|
||||||
"Proposal",
|
|
||||||
"Authors",
|
|
||||||
"Coordinator",
|
|
||||||
"Reviewers",
|
|
||||||
"Constitution Sections",
|
|
||||||
"Implementation PRs",
|
|
||||||
"Decision Date",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def text_block_lines(path: pathlib.Path) -> list[str]:
|
|
||||||
content = path.read_text(encoding="utf-8")
|
|
||||||
match = re.search(r"```text\n(.*?)\n```", content, re.DOTALL)
|
|
||||||
if not match:
|
|
||||||
raise ValueError("missing leading ```text metadata block")
|
|
||||||
return [line.rstrip() for line in match.group(1).splitlines() if line.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def validate(path: pathlib.Path) -> list[str]:
|
|
||||||
errors: list[str] = []
|
|
||||||
proposal_id = path.name.split("-", 2)[:2]
|
|
||||||
expected_id = "-".join(proposal_id).removesuffix(".md")
|
|
||||||
|
|
||||||
try:
|
|
||||||
lines = text_block_lines(path)
|
|
||||||
except ValueError as exc:
|
|
||||||
return [f"{path}: {exc}"]
|
|
||||||
|
|
||||||
field_names = [line.split(":", 1)[0] for line in lines]
|
|
||||||
if field_names != REQUIRED_FIELDS:
|
|
||||||
errors.append(
|
|
||||||
f"{path}: metadata fields must appear in order {', '.join(REQUIRED_FIELDS)}"
|
|
||||||
)
|
|
||||||
return errors
|
|
||||||
|
|
||||||
fields = dict(line.split(":", 1) for line in lines)
|
|
||||||
fields = {key.strip(): value.strip() for key, value in fields.items()}
|
|
||||||
|
|
||||||
if fields["Status"] not in ALLOWED_STATUSES:
|
|
||||||
errors.append(f"{path}: invalid Status {fields['Status']!r}")
|
|
||||||
|
|
||||||
if fields["Proposal"] != expected_id:
|
|
||||||
errors.append(
|
|
||||||
f"{path}: Proposal field {fields['Proposal']!r} does not match filename id {expected_id!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if fields["Status"] in {"Accepted", "Implemented", "Superseded", "Rejected", "Archived"} and fields["Decision Date"] == "Pending":
|
|
||||||
errors.append(
|
|
||||||
f"{path}: Decision Date must not be Pending once status is {fields['Status']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
errors: list[str] = []
|
|
||||||
for path in sorted(PROPOSALS_DIR.glob("BEP-*.md")):
|
|
||||||
errors.extend(validate(path))
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
for error in errors:
|
|
||||||
print(error, file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"checked {len(list(PROPOSALS_DIR.glob('BEP-*.md')))} BEPs")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
cd "${repo_root}"
|
|
||||||
|
|
||||||
release_ref="${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}"
|
|
||||||
target="x86_64-unknown-linux-gnu"
|
|
||||||
out_dir="${repo_root}/dist"
|
|
||||||
staging="${out_dir}/burrow-${release_ref}-${target}"
|
|
||||||
|
|
||||||
mkdir -p "${staging}"
|
|
||||||
|
|
||||||
cargo build --locked --release -p burrow --bin burrow
|
|
||||||
install -m 0755 target/release/burrow "${staging}/burrow"
|
|
||||||
cp README.md "${staging}/README.md"
|
|
||||||
|
|
||||||
tarball="${out_dir}/burrow-${release_ref}-${target}.tar.gz"
|
|
||||||
tar -C "${out_dir}" -czf "${tarball}" "$(basename "${staging}")"
|
|
||||||
shasum -a 256 "${tarball}" > "${tarball}.sha256"
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source_nix_profile() {
|
|
||||||
local candidate
|
|
||||||
for candidate in \
|
|
||||||
"/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh" \
|
|
||||||
"${HOME}/.nix-profile/etc/profile.d/nix.sh"
|
|
||||||
do
|
|
||||||
if [[ -f "${candidate}" ]]; then
|
|
||||||
# shellcheck disable=SC1090
|
|
||||||
. "${candidate}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
linux_cp_supports_preserve() {
|
|
||||||
cp --help 2>&1 | grep -q -- '--preserve'
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_root_owned_home() {
|
|
||||||
if [[ "$(id -u)" -ne 0 ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -d "${HOME}" ]] || [[ ! -O "${HOME}" ]]; then
|
|
||||||
export HOME="/root"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "${HOME}"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_linux_nixbld_accounts() {
|
|
||||||
if [[ "$(id -u)" -ne 0 ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v getent >/dev/null 2>&1 && getent group nixbld >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v addgroup >/dev/null 2>&1 && ! command -v groupadd >/dev/null 2>&1; then
|
|
||||||
addgroup -S nixbld >/dev/null 2>&1 || true
|
|
||||||
for i in $(seq 1 10); do
|
|
||||||
adduser -S -D -H -h /var/empty -s /sbin/nologin -G nixbld "nixbld${i}" >/dev/null 2>&1 || true
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v groupadd >/dev/null 2>&1; then
|
|
||||||
groupadd -r nixbld >/dev/null 2>&1 || true
|
|
||||||
for i in $(seq 1 10); do
|
|
||||||
useradd \
|
|
||||||
--system \
|
|
||||||
--no-create-home \
|
|
||||||
--home-dir /var/empty \
|
|
||||||
--shell /usr/sbin/nologin \
|
|
||||||
--gid nixbld \
|
|
||||||
"nixbld${i}" >/dev/null 2>&1 || true
|
|
||||||
done
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "linux nix bootstrap requires nixbld group creation support" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_linux_nix_bootstrap_prereqs() {
|
|
||||||
if linux_cp_supports_preserve; then
|
|
||||||
ensure_root_owned_home
|
|
||||||
ensure_linux_nixbld_accounts
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v apk >/dev/null 2>&1; then
|
|
||||||
apk add --no-cache coreutils xz >/dev/null
|
|
||||||
elif command -v apt-get >/dev/null 2>&1; then
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update -y >/dev/null
|
|
||||||
apt-get install -y coreutils xz-utils >/dev/null
|
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
|
||||||
dnf install -y coreutils xz >/dev/null
|
|
||||||
elif command -v yum >/dev/null 2>&1; then
|
|
||||||
yum install -y coreutils xz >/dev/null
|
|
||||||
else
|
|
||||||
echo "linux nix bootstrap requires GNU cp but no supported package manager was found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
linux_cp_supports_preserve || {
|
|
||||||
echo "linux nix bootstrap still lacks GNU cp after installing prerequisites" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_root_owned_home
|
|
||||||
ensure_linux_nixbld_accounts
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! command -v nix >/dev/null 2>&1; then
|
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
|
||||||
echo "curl is required to install nix" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Linux)
|
|
||||||
ensure_linux_nix_bootstrap_prereqs
|
|
||||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon
|
|
||||||
;;
|
|
||||||
Darwin)
|
|
||||||
installer="$(mktemp -t burrow-nix.XXXXXX)"
|
|
||||||
trap 'rm -f "${installer}"' EXIT
|
|
||||||
curl -fsSL -o "${installer}" https://install.determinate.systems/nix
|
|
||||||
chmod +x "${installer}"
|
|
||||||
if command -v sudo >/dev/null 2>&1; then
|
|
||||||
if sudo -n true 2>/dev/null; then
|
|
||||||
sudo -n sh "${installer}" install --no-confirm
|
|
||||||
else
|
|
||||||
sudo sh "${installer}" install --no-confirm
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
sh "${installer}" install --no-confirm
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "unsupported platform for nix bootstrap: $(uname -s)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
source_nix_profile || true
|
|
||||||
export PATH="${HOME}/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/nix/var/nix/profiles/default/sbin:${PATH}"
|
|
||||||
|
|
||||||
config_root="${XDG_CONFIG_HOME:-$HOME/.config}"
|
|
||||||
config_file="${config_root}/nix/nix.conf"
|
|
||||||
if [[ -e "${config_file}" && ! -w "${config_file}" ]]; then
|
|
||||||
config_root="$(mktemp -d -t burrow-nix-config.XXXXXX)"
|
|
||||||
export XDG_CONFIG_HOME="${config_root}"
|
|
||||||
config_file="${XDG_CONFIG_HOME}/nix/nix.conf"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$(dirname -- "${config_file}")"
|
|
||||||
cat > "${config_file}" <<'EOF'
|
|
||||||
experimental-features = nix-command flakes
|
|
||||||
sandbox = true
|
|
||||||
fallback = true
|
|
||||||
substituters = https://cache.nixos.org
|
|
||||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
|
|
||||||
EOF
|
|
||||||
|
|
||||||
command -v nix >/dev/null 2>&1 || {
|
|
||||||
echo "nix is still unavailable after bootstrap" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
: "${API_URL:?API_URL is required}"
|
|
||||||
: "${REPOSITORY:?REPOSITORY is required}"
|
|
||||||
: "${RELEASE_TAG:?RELEASE_TAG is required}"
|
|
||||||
: "${TOKEN:?TOKEN is required}"
|
|
||||||
|
|
||||||
release_api="${API_URL}/repos/${REPOSITORY}/releases"
|
|
||||||
tag_api="${release_api}/tags/${RELEASE_TAG}"
|
|
||||||
release_json="$(mktemp)"
|
|
||||||
create_json="$(mktemp)"
|
|
||||||
trap 'rm -f "${release_json}" "${create_json}"' EXIT
|
|
||||||
|
|
||||||
status="$(
|
|
||||||
curl -sS -o "${release_json}" -w '%{http_code}' \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
"${tag_api}"
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ "${status}" == "404" ]]; then
|
|
||||||
jq -n \
|
|
||||||
--arg tag "${RELEASE_TAG}" \
|
|
||||||
--arg name "Burrow ${RELEASE_TAG}" \
|
|
||||||
'{
|
|
||||||
tag_name: $tag,
|
|
||||||
target_commitish: $tag,
|
|
||||||
name: $name,
|
|
||||||
body: "Automated prerelease built on Forgejo Namespace runners.",
|
|
||||||
draft: false,
|
|
||||||
prerelease: true
|
|
||||||
}' > "${create_json}"
|
|
||||||
|
|
||||||
curl -fsS \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @"${create_json}" \
|
|
||||||
"${release_api}" > "${release_json}"
|
|
||||||
elif [[ "${status}" != "200" ]]; then
|
|
||||||
echo "failed to query Forgejo release for ${RELEASE_TAG} (HTTP ${status})" >&2
|
|
||||||
cat "${release_json}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
release_id="$(jq -r '.id' "${release_json}")"
|
|
||||||
if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then
|
|
||||||
echo "Forgejo release payload is missing an id" >&2
|
|
||||||
cat "${release_json}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for file in dist/*; do
|
|
||||||
name="$(basename "${file}")"
|
|
||||||
asset_id="$(jq -r --arg name "${name}" '.assets[]? | select(.name == $name) | .id' "${release_json}" | head -n1)"
|
|
||||||
if [[ -n "${asset_id}" ]]; then
|
|
||||||
curl -fsS -X DELETE \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
"${release_api}/${release_id}/assets/${asset_id}" >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -fsS \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-F "attachment=@${file}" \
|
|
||||||
"${release_api}/${release_id}/assets?name=${name}" >/dev/null
|
|
||||||
done
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
|
|
||||||
simulator_name="${BURROW_UI_TEST_SIMULATOR_NAME:-iPhone 17 Pro}"
|
|
||||||
simulator_os="${BURROW_UI_TEST_SIMULATOR_OS:-26.4}"
|
|
||||||
derived_data_path="${BURROW_UI_TEST_DERIVED_DATA_PATH:-/tmp/burrow-ui-tests-deriveddata}"
|
|
||||||
source_packages_path="${BURROW_UI_TEST_SOURCE_PACKAGES_PATH:-/tmp/burrow-ui-tests-sourcepackages}"
|
|
||||||
fallback_dir="${HOME}/Library/Application Support/${bundle_id}/SimulatorFallback"
|
|
||||||
socket_path="${fallback_dir}/burrow.sock"
|
|
||||||
daemon_log="${BURROW_UI_TEST_DAEMON_LOG:-/tmp/burrow-ui-test-daemon.log}"
|
|
||||||
ui_test_email="${BURROW_UI_TEST_EMAIL:-ui-test@burrow.net}"
|
|
||||||
ui_test_username="${BURROW_UI_TEST_USERNAME:-ui-test}"
|
|
||||||
password_secret="${repo_root}/secrets/infra/authentik-ui-test-password.age"
|
|
||||||
age_identity="${BURROW_UI_TEST_AGE_IDENTITY:-${HOME}/.ssh/id_ed25519}"
|
|
||||||
|
|
||||||
ui_test_password="${BURROW_UI_TEST_PASSWORD:-}"
|
|
||||||
if [[ -z "$ui_test_password" ]]; then
|
|
||||||
if [[ -f "$password_secret" && -f "$age_identity" ]]; then
|
|
||||||
ui_test_password="$(age -d -i "$age_identity" "$password_secret" | tr -d '\r\n')"
|
|
||||||
else
|
|
||||||
echo "error: BURROW_UI_TEST_PASSWORD is unset and ${password_secret} could not be decrypted" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$fallback_dir" "$derived_data_path" "$source_packages_path"
|
|
||||||
rm -f "$socket_path"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
if [[ -n "${daemon_pid:-}" ]]; then
|
|
||||||
kill "$daemon_pid" >/dev/null 2>&1 || true
|
|
||||||
wait "$daemon_pid" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
cargo build -p burrow --bin burrow
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$fallback_dir"
|
|
||||||
BURROW_SOCKET_PATH="burrow.sock" \
|
|
||||||
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
|
|
||||||
) &
|
|
||||||
daemon_pid=$!
|
|
||||||
|
|
||||||
for _ in $(seq 1 50); do
|
|
||||||
[[ -S "$socket_path" ]] && break
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ! -S "$socket_path" ]]; then
|
|
||||||
echo "error: Burrow daemon did not create ${socket_path}" >&2
|
|
||||||
[[ -f "$daemon_log" ]] && cat "$daemon_log" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BURROW_UI_TEST_EMAIL="$ui_test_email" \
|
|
||||||
BURROW_UI_TEST_USERNAME="$ui_test_username" \
|
|
||||||
BURROW_UI_TEST_PASSWORD="$ui_test_password" \
|
|
||||||
xcodebuild \
|
|
||||||
-quiet \
|
|
||||||
-skipPackagePluginValidation \
|
|
||||||
-project "${repo_root}/Apple/Burrow.xcodeproj" \
|
|
||||||
-scheme App \
|
|
||||||
-configuration Debug \
|
|
||||||
-destination "platform=iOS Simulator,name=${simulator_name},OS=${simulator_os}" \
|
|
||||||
-derivedDataPath "$derived_data_path" \
|
|
||||||
-clonedSourcePackagesDirPath "$source_packages_path" \
|
|
||||||
-only-testing:BurrowUITests \
|
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
|
||||||
test
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
bundle_id="${BURROW_UI_TEST_APP_BUNDLE_ID:-com.hackclub.burrow}"
|
|
||||||
smoke_root="${BURROW_TAILNET_SMOKE_ROOT:-/tmp/burrow-tailnet-connectivity}"
|
|
||||||
socket_path="${smoke_root}/burrow.sock"
|
|
||||||
db_path="${smoke_root}/burrow.db"
|
|
||||||
daemon_log="${BURROW_TAILNET_SMOKE_DAEMON_LOG:-${smoke_root}/daemon.log}"
|
|
||||||
payload_path="${smoke_root}/tailnet.json"
|
|
||||||
authority="${BURROW_TAILNET_SMOKE_AUTHORITY:-https://ts.burrow.net}"
|
|
||||||
account_name="${BURROW_TAILNET_SMOKE_ACCOUNT:-ui-test}"
|
|
||||||
identity_name="${BURROW_TAILNET_SMOKE_IDENTITY:-apple}"
|
|
||||||
hostname="${BURROW_TAILNET_SMOKE_HOSTNAME:-burrow-apple}"
|
|
||||||
message="${BURROW_TAILNET_SMOKE_MESSAGE:-burrow-tailnet-smoke}"
|
|
||||||
timeout_ms="${BURROW_TAILNET_SMOKE_TIMEOUT_MS:-8000}"
|
|
||||||
remote_ip="${BURROW_TAILNET_SMOKE_REMOTE_IP:-}"
|
|
||||||
remote_port="${BURROW_TAILNET_SMOKE_REMOTE_PORT:-18081}"
|
|
||||||
remote_hostname="${BURROW_TAILNET_SMOKE_REMOTE_HOSTNAME:-burrow-echo}"
|
|
||||||
remote_authkey="${BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY:-}"
|
|
||||||
helper_bin="${BURROW_TAILNET_SMOKE_HELPER_BIN:-${smoke_root}/tailscale-login-bridge}"
|
|
||||||
remote_state_root="${BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT:-${smoke_root}/remote-state}"
|
|
||||||
remote_stdout="${smoke_root}/remote-helper.stdout"
|
|
||||||
remote_stderr="${BURROW_TAILNET_SMOKE_REMOTE_LOG:-${smoke_root}/remote-helper.log}"
|
|
||||||
|
|
||||||
if [[ -n "${TS_AUTHKEY:-}" ]]; then
|
|
||||||
default_tailnet_state_root="${smoke_root}/local-state"
|
|
||||||
else
|
|
||||||
default_tailnet_state_root="/tmp/${bundle_id}/SimulatorTailnetState"
|
|
||||||
fi
|
|
||||||
tailnet_state_root="${BURROW_TAILNET_STATE_ROOT:-${default_tailnet_state_root}}"
|
|
||||||
|
|
||||||
need_login=0
|
|
||||||
if [[ -z "${TS_AUTHKEY:-}" ]] && { [[ ! -d "$tailnet_state_root" ]] || [[ -z "$(find "$tailnet_state_root" -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null)" ]]; }; then
|
|
||||||
need_login=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$need_login" -eq 1 ]]; then
|
|
||||||
echo "Tailnet state root is empty; running iOS login bootstrap first..."
|
|
||||||
"${repo_root}/Scripts/run-ios-tailnet-ui-tests.sh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf "$smoke_root"
|
|
||||||
mkdir -p "$smoke_root"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -f "$payload_path"
|
|
||||||
if [[ -n "${daemon_pid:-}" ]]; then
|
|
||||||
kill "$daemon_pid" >/dev/null 2>&1 || true
|
|
||||||
wait "$daemon_pid" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
if [[ -n "${remote_pid:-}" ]]; then
|
|
||||||
kill "$remote_pid" >/dev/null 2>&1 || true
|
|
||||||
wait "$remote_pid" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
wait_for_helper_listen() {
|
|
||||||
python3 - <<'PY' "$1"
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
path = pathlib.Path(sys.argv[1])
|
|
||||||
deadline = time.time() + 20
|
|
||||||
while time.time() < deadline:
|
|
||||||
if path.exists():
|
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
|
||||||
line = handle.readline().strip()
|
|
||||||
if line:
|
|
||||||
hello = json.loads(line)
|
|
||||||
print(hello["listen_addr"])
|
|
||||||
raise SystemExit(0)
|
|
||||||
time.sleep(0.1)
|
|
||||||
raise SystemExit("timed out waiting for helper startup line")
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_helper_ip() {
|
|
||||||
python3 - <<'PY' "$1"
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
url = sys.argv[1]
|
|
||||||
deadline = time.time() + 30
|
|
||||||
while time.time() < deadline:
|
|
||||||
with urllib.request.urlopen(url, timeout=5) as response:
|
|
||||||
status = json.load(response)
|
|
||||||
if status.get("running") and status.get("tailscale_ips"):
|
|
||||||
print(status["tailscale_ips"][0])
|
|
||||||
raise SystemExit(0)
|
|
||||||
time.sleep(0.25)
|
|
||||||
raise SystemExit("timed out waiting for helper to become ready")
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
python3 - <<'PY' "$payload_path" "$authority" "$account_name" "$identity_name" "$hostname"
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = pathlib.Path(sys.argv[1])
|
|
||||||
payload = {
|
|
||||||
"authority": sys.argv[2],
|
|
||||||
"account": sys.argv[3],
|
|
||||||
"identity": sys.argv[4],
|
|
||||||
"hostname": sys.argv[5],
|
|
||||||
}
|
|
||||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
||||||
PY
|
|
||||||
|
|
||||||
cargo build -p burrow --bin burrow
|
|
||||||
(
|
|
||||||
cd "${repo_root}/Tools/tailscale-login-bridge"
|
|
||||||
GOWORK=off go build -o "$helper_bin" .
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ -z "$remote_ip" ]]; then
|
|
||||||
if [[ -z "$remote_authkey" ]] && { [[ ! -d "$remote_state_root" ]] || [[ -z "$(find "$remote_state_root" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; }; then
|
|
||||||
echo "error: set BURROW_TAILNET_SMOKE_REMOTE_IP, BURROW_TAILNET_SMOKE_REMOTE_AUTHKEY, or BURROW_TAILNET_SMOKE_REMOTE_STATE_ROOT to an existing logged-in helper state" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$remote_authkey" ]]; then
|
|
||||||
rm -rf "$remote_state_root"
|
|
||||||
mkdir -p "$remote_state_root"
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$repo_root"
|
|
||||||
if [[ -n "$remote_authkey" ]]; then
|
|
||||||
export TS_AUTHKEY="$remote_authkey"
|
|
||||||
fi
|
|
||||||
"$helper_bin" \
|
|
||||||
--listen 127.0.0.1:0 \
|
|
||||||
--state-dir "$remote_state_root" \
|
|
||||||
--hostname "$remote_hostname" \
|
|
||||||
--control-url "$authority" \
|
|
||||||
--udp-echo-port "$remote_port" \
|
|
||||||
>"$remote_stdout" 2>"$remote_stderr"
|
|
||||||
) &
|
|
||||||
remote_pid=$!
|
|
||||||
|
|
||||||
remote_listen_addr="$(wait_for_helper_listen "$remote_stdout")"
|
|
||||||
remote_ip="$(wait_for_helper_ip "http://${remote_listen_addr}/status")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$smoke_root"
|
|
||||||
RUST_LOG="${BURROW_TAILNET_SMOKE_RUST_LOG:-info,burrow=debug}" \
|
|
||||||
BURROW_SOCKET_PATH="$socket_path" \
|
|
||||||
BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \
|
|
||||||
"${repo_root}/target/debug/burrow" daemon >"$daemon_log" 2>&1
|
|
||||||
) &
|
|
||||||
daemon_pid=$!
|
|
||||||
|
|
||||||
for _ in $(seq 1 50); do
|
|
||||||
[[ -S "$socket_path" ]] && break
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ! -S "$socket_path" ]]; then
|
|
||||||
echo "error: Burrow daemon did not create ${socket_path}" >&2
|
|
||||||
[[ -f "$daemon_log" ]] && cat "$daemon_log" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
run_burrow() {
|
|
||||||
BURROW_SOCKET_PATH="$socket_path" \
|
|
||||||
BURROW_TAILSCALE_STATE_ROOT="$tailnet_state_root" \
|
|
||||||
"${repo_root}/target/debug/burrow" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_burrow network-add 1 1 "$payload_path"
|
|
||||||
run_burrow start
|
|
||||||
run_burrow tunnel-config
|
|
||||||
run_burrow tailnet-udp-echo "${remote_ip}:${remote_port}" --message "$message" --timeout-ms "$timeout_ms"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Tailnet connectivity smoke passed."
|
|
||||||
echo "State root: $tailnet_state_root"
|
|
||||||
echo "Remote: ${remote_ip}:${remote_port}"
|
|
||||||
|
|
@ -2,26 +2,17 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tailscale/wireguard-go/tun"
|
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/tsnet"
|
"tailscale.com/tsnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,123 +26,13 @@ type statusResponse struct {
|
||||||
SelfDNSName string `json:"self_dns_name,omitempty"`
|
SelfDNSName string `json:"self_dns_name,omitempty"`
|
||||||
TailscaleIPs []string `json:"tailscale_ips,omitempty"`
|
TailscaleIPs []string `json:"tailscale_ips,omitempty"`
|
||||||
Health []string `json:"health,omitempty"`
|
Health []string `json:"health,omitempty"`
|
||||||
Peers []peerSummary `json:"peers,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type peerSummary struct {
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
DNSName string `json:"dns_name,omitempty"`
|
|
||||||
TailscaleIPs []string `json:"tailscale_ips,omitempty"`
|
|
||||||
Online bool `json:"online"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
Relay string `json:"relay,omitempty"`
|
|
||||||
CurAddr string `json:"cur_addr,omitempty"`
|
|
||||||
LastSeenUnix int64 `json:"last_seen_unix,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type pingResponse struct {
|
|
||||||
Result *ipnstate.PingResult `json:"result,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type helperHello struct {
|
|
||||||
ListenAddr string `json:"listen_addr"`
|
|
||||||
PacketSocket string `json:"packet_socket,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type helperState struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
authURL string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *helperState) authURLSnapshot() string {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
return s.authURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *helperState) setAuthURL(url string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.authURL = url
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *helperState) clearAuthURL() {
|
|
||||||
s.setAuthURL("")
|
|
||||||
}
|
|
||||||
|
|
||||||
// chanTUN is a tun.Device backed by channels so another process can feed and
|
|
||||||
// consume raw IP packets while tsnet handles the Tailnet control/data plane.
|
|
||||||
type chanTUN struct {
|
|
||||||
Inbound chan []byte
|
|
||||||
Outbound chan []byte
|
|
||||||
closed chan struct{}
|
|
||||||
events chan tun.Event
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChanTUN() *chanTUN {
|
|
||||||
t := &chanTUN{
|
|
||||||
Inbound: make(chan []byte, 1024),
|
|
||||||
Outbound: make(chan []byte, 1024),
|
|
||||||
closed: make(chan struct{}),
|
|
||||||
events: make(chan tun.Event, 1),
|
|
||||||
}
|
|
||||||
t.events <- tun.EventUp
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *chanTUN) File() *os.File { return nil }
|
|
||||||
|
|
||||||
func (t *chanTUN) Close() error {
|
|
||||||
select {
|
|
||||||
case <-t.closed:
|
|
||||||
default:
|
|
||||||
close(t.closed)
|
|
||||||
close(t.Inbound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *chanTUN) Read(bufs [][]byte, sizes []int, offset int) (int, error) {
|
|
||||||
select {
|
|
||||||
case <-t.closed:
|
|
||||||
return 0, io.EOF
|
|
||||||
case pkt, ok := <-t.Outbound:
|
|
||||||
if !ok {
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
sizes[0] = copy(bufs[0][offset:], pkt)
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *chanTUN) Write(bufs [][]byte, offset int) (int, error) {
|
|
||||||
for _, buf := range bufs {
|
|
||||||
pkt := buf[offset:]
|
|
||||||
if len(pkt) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-t.closed:
|
|
||||||
return 0, errors.New("closed")
|
|
||||||
case t.Inbound <- append([]byte(nil), pkt...):
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(bufs), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *chanTUN) MTU() (int, error) { return 1280, nil }
|
|
||||||
func (t *chanTUN) Name() (string, error) { return "burrow-tailnet", nil }
|
|
||||||
func (t *chanTUN) Events() <-chan tun.Event { return t.events }
|
|
||||||
func (t *chanTUN) BatchSize() int { return 1 }
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
listen := flag.String("listen", "127.0.0.1:0", "local listen address")
|
listen := flag.String("listen", "127.0.0.1:0", "local listen address")
|
||||||
stateDir := flag.String("state-dir", "", "persistent state directory")
|
stateDir := flag.String("state-dir", "", "persistent state directory")
|
||||||
hostname := flag.String("hostname", "burrow-apple", "tailnet hostname")
|
hostname := flag.String("hostname", "burrow-apple", "tailnet hostname")
|
||||||
controlURL := flag.String("control-url", "", "optional control URL")
|
controlURL := flag.String("control-url", "", "optional control URL")
|
||||||
packetSocket := flag.String("packet-socket", "", "optional unix socket path for raw packet bridging")
|
|
||||||
udpEchoPort := flag.Int("udp-echo-port", 0, "optional tailnet UDP echo port")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *stateDir == "" {
|
if *stateDir == "" {
|
||||||
|
|
@ -167,24 +48,6 @@ func main() {
|
||||||
Hostname: *hostname,
|
Hostname: *hostname,
|
||||||
UserLogf: log.Printf,
|
UserLogf: log.Printf,
|
||||||
}
|
}
|
||||||
|
|
||||||
var tunDevice *chanTUN
|
|
||||||
var packetListener net.Listener
|
|
||||||
if *packetSocket != "" {
|
|
||||||
_ = os.Remove(*packetSocket)
|
|
||||||
ln, err := net.Listen("unix", *packetSocket)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("packet listen: %v", err)
|
|
||||||
}
|
|
||||||
packetListener = ln
|
|
||||||
defer func() {
|
|
||||||
packetListener.Close()
|
|
||||||
_ = os.Remove(*packetSocket)
|
|
||||||
}()
|
|
||||||
|
|
||||||
tunDevice = newChanTUN()
|
|
||||||
server.Tun = tunDevice
|
|
||||||
}
|
|
||||||
if *controlURL != "" {
|
if *controlURL != "" {
|
||||||
server.ControlURL = *controlURL
|
server.ControlURL = *controlURL
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +61,6 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("local client: %v", err)
|
log.Fatalf("local client: %v", err)
|
||||||
}
|
}
|
||||||
state := &helperState{}
|
|
||||||
|
|
||||||
ln, err := net.Listen("tcp", *listen)
|
ln, err := net.Listen("tcp", *listen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -206,27 +68,12 @@ func main() {
|
||||||
}
|
}
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
|
|
||||||
if packetListener != nil {
|
fmt.Printf("{\"listen_addr\":%q}\n", ln.Addr().String())
|
||||||
go servePacketBridge(packetListener, tunDevice)
|
|
||||||
}
|
|
||||||
if *udpEchoPort > 0 {
|
|
||||||
go serveUDPEcho(context.Background(), server, localClient, *udpEchoPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
hello := helperHello{
|
|
||||||
ListenAddr: ln.Addr().String(),
|
|
||||||
}
|
|
||||||
if *packetSocket != "" {
|
|
||||||
hello.PacketSocket = *packetSocket
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(os.Stdout).Encode(hello); err != nil {
|
|
||||||
log.Fatalf("write hello: %v", err)
|
|
||||||
}
|
|
||||||
_ = os.Stdout.Sync()
|
_ = os.Stdout.Sync()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
|
||||||
status, err := snapshot(r.Context(), localClient, state)
|
status, err := snapshot(r.Context(), localClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|
@ -234,40 +81,6 @@ func main() {
|
||||||
w.Header().Set("content-type", "application/json")
|
w.Header().Set("content-type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(status)
|
_ = json.NewEncoder(w).Encode(status)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ip := r.URL.Query().Get("ip")
|
|
||||||
if ip == "" {
|
|
||||||
http.Error(w, "missing ip", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target, err := netip.ParseAddr(ip)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("invalid ip: %v", err), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pingType := tailcfg.PingTSMP
|
|
||||||
switch r.URL.Query().Get("type") {
|
|
||||||
case "", "tsmp", "TSMP":
|
|
||||||
pingType = tailcfg.PingTSMP
|
|
||||||
case "icmp", "ICMP":
|
|
||||||
pingType = tailcfg.PingICMP
|
|
||||||
case "peerapi":
|
|
||||||
pingType = tailcfg.PingPeerAPI
|
|
||||||
default:
|
|
||||||
http.Error(w, "unsupported ping type", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := localClient.Ping(r.Context(), target, pingType)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("content-type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(&pingResponse{Result: result})
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -283,110 +96,16 @@ func main() {
|
||||||
log.Fatal(httpServer.Serve(ln))
|
log.Fatal(httpServer.Serve(ln))
|
||||||
}
|
}
|
||||||
|
|
||||||
func servePacketBridge(listener net.Listener, device *chanTUN) {
|
func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, error) {
|
||||||
for {
|
status, err := localClient.StatusWithoutPeers(ctx)
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, net.ErrClosed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("packet accept: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("packet bridge connected")
|
|
||||||
if err := bridgePacketConn(conn, device); err != nil && !errors.Is(err, io.EOF) {
|
|
||||||
log.Printf("packet bridge error: %v", err)
|
|
||||||
}
|
|
||||||
_ = conn.Close()
|
|
||||||
log.Printf("packet bridge disconnected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bridgePacketConn(conn net.Conn, device *chanTUN) error {
|
|
||||||
errCh := make(chan error, 2)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
pkt, err := readFrame(conn)
|
|
||||||
if err != nil {
|
|
||||||
errCh <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-device.closed:
|
|
||||||
errCh <- io.EOF
|
|
||||||
return
|
|
||||||
case device.Outbound <- pkt:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-device.closed:
|
|
||||||
errCh <- io.EOF
|
|
||||||
return
|
|
||||||
case pkt, ok := <-device.Inbound:
|
|
||||||
if !ok {
|
|
||||||
errCh <- io.EOF
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := writeFrame(conn, pkt); err != nil {
|
|
||||||
errCh <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return <-errCh
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFrame(r io.Reader) ([]byte, error) {
|
|
||||||
var size [4]byte
|
|
||||||
if _, err := io.ReadFull(r, size[:]); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
length := binary.BigEndian.Uint32(size[:])
|
|
||||||
if length == 0 {
|
|
||||||
return []byte{}, nil
|
|
||||||
}
|
|
||||||
packet := make([]byte, length)
|
|
||||||
if _, err := io.ReadFull(r, packet); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return packet, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeFrame(w io.Writer, packet []byte) error {
|
|
||||||
var size [4]byte
|
|
||||||
binary.BigEndian.PutUint32(size[:], uint32(len(packet)))
|
|
||||||
if _, err := w.Write(size[:]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(packet) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := w.Write(packet)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot(ctx context.Context, localClient *local.Client, state *helperState) (*statusResponse, error) {
|
|
||||||
status, err := localClient.Status(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && status.AuthURL == "" {
|
||||||
authURL := status.AuthURL
|
if err := localClient.StartLoginInteractive(ctx); err != nil {
|
||||||
if authURL == "" {
|
return nil, err
|
||||||
authURL = state.authURLSnapshot()
|
}
|
||||||
}
|
status, err = localClient.StatusWithoutPeers(ctx)
|
||||||
if status.BackendState == ipn.Running.String() {
|
|
||||||
state.clearAuthURL()
|
|
||||||
authURL = ""
|
|
||||||
} else if (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && authURL == "" {
|
|
||||||
authURL, err = awaitAuthURL(ctx, localClient, state)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -394,7 +113,7 @@ func snapshot(ctx context.Context, localClient *local.Client, state *helperState
|
||||||
|
|
||||||
response := &statusResponse{
|
response := &statusResponse{
|
||||||
BackendState: status.BackendState,
|
BackendState: status.BackendState,
|
||||||
AuthURL: authURL,
|
AuthURL: status.AuthURL,
|
||||||
Running: status.BackendState == ipn.Running.String(),
|
Running: status.BackendState == ipn.Running.String(),
|
||||||
NeedsLogin: status.BackendState == ipn.NeedsLogin.String(),
|
NeedsLogin: status.BackendState == ipn.NeedsLogin.String(),
|
||||||
Health: append([]string(nil), status.Health...),
|
Health: append([]string(nil), status.Health...),
|
||||||
|
|
@ -410,114 +129,5 @@ func snapshot(ctx context.Context, localClient *local.Client, state *helperState
|
||||||
for _, ip := range status.TailscaleIPs {
|
for _, ip := range status.TailscaleIPs {
|
||||||
response.TailscaleIPs = append(response.TailscaleIPs, ip.String())
|
response.TailscaleIPs = append(response.TailscaleIPs, ip.String())
|
||||||
}
|
}
|
||||||
for _, key := range status.Peers() {
|
|
||||||
peer := status.Peer[key]
|
|
||||||
if peer == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
summary := peerSummary{
|
|
||||||
Name: peer.HostName,
|
|
||||||
DNSName: peer.DNSName,
|
|
||||||
Online: peer.Online,
|
|
||||||
Active: peer.Active,
|
|
||||||
Relay: peer.Relay,
|
|
||||||
CurAddr: peer.CurAddr,
|
|
||||||
LastSeenUnix: peer.LastSeen.Unix(),
|
|
||||||
}
|
|
||||||
for _, ip := range peer.TailscaleIPs {
|
|
||||||
summary.TailscaleIPs = append(summary.TailscaleIPs, ip.String())
|
|
||||||
}
|
|
||||||
response.Peers = append(response.Peers, summary)
|
|
||||||
}
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveUDPEcho(ctx context.Context, server *tsnet.Server, localClient *local.Client, port int) {
|
|
||||||
ip, err := awaitTailscaleIP(ctx, localClient)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("udp echo setup failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
listenAddr := net.JoinHostPort(ip.String(), strconv.Itoa(port))
|
|
||||||
pc, err := server.ListenPacket("udp", listenAddr)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("udp echo listen failed on %s: %v", listenAddr, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer pc.Close()
|
|
||||||
|
|
||||||
log.Printf("udp echo listening on %s", pc.LocalAddr())
|
|
||||||
buf := make([]byte, 64<<10)
|
|
||||||
for {
|
|
||||||
n, addr, err := pc.ReadFrom(buf)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("udp echo read failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := pc.WriteTo(buf[:n], addr); err != nil {
|
|
||||||
log.Printf("udp echo write failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func awaitTailscaleIP(ctx context.Context, localClient *local.Client) (netip.Addr, error) {
|
|
||||||
for range 60 {
|
|
||||||
status, err := localClient.StatusWithoutPeers(ctx)
|
|
||||||
if err == nil {
|
|
||||||
for _, ip := range status.TailscaleIPs {
|
|
||||||
if ip.Is4() {
|
|
||||||
return ip, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, ip := range status.TailscaleIPs {
|
|
||||||
if ip.Is6() {
|
|
||||||
return ip, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return netip.Addr{}, ctx.Err()
|
|
||||||
case <-time.After(250 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return netip.Addr{}, errors.New("timed out waiting for tailscale IP")
|
|
||||||
}
|
|
||||||
|
|
||||||
func awaitAuthURL(ctx context.Context, localClient *local.Client, state *helperState) (string, error) {
|
|
||||||
watchCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
watcher, err := localClient.WatchIPNBus(watchCtx, ipn.NotifyInitialState)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer watcher.Close()
|
|
||||||
|
|
||||||
if err := localClient.StartLoginInteractive(ctx); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
notify, err := watcher.Next()
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
||||||
return state.authURLSnapshot(), nil
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if notify.BrowseToURL != nil && *notify.BrowseToURL != "" {
|
|
||||||
state.setAuthURL(*notify.BrowseToURL)
|
|
||||||
return *notify.BrowseToURL, nil
|
|
||||||
}
|
|
||||||
if notify.State != nil && *notify.State == ipn.Running {
|
|
||||||
state.clearAuthURL()
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -82,22 +82,11 @@ impl TailscaleBridgeManager {
|
||||||
let key = session_key(&request.account_name, &request.identity_name);
|
let key = session_key(&request.account_name, &request.identity_name);
|
||||||
|
|
||||||
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
|
if let Some(existing) = self.sessions.lock().await.get(&key).cloned() {
|
||||||
match self.fetch_status(existing.as_ref()).await {
|
let status = self.fetch_status(existing.as_ref()).await?;
|
||||||
Ok(status) => {
|
return Ok(TailscaleLoginStartResponse {
|
||||||
return Ok(TailscaleLoginStartResponse {
|
session_id: existing.session_id.clone(),
|
||||||
session_id: existing.session_id.clone(),
|
status,
|
||||||
status,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!(
|
|
||||||
"tailscale login session {} is stale, restarting: {err}",
|
|
||||||
existing.session_id
|
|
||||||
);
|
|
||||||
self.sessions.lock().await.remove(&key);
|
|
||||||
let _ = self.shutdown_session(existing.as_ref()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let state_dir = state_root().join(session_dir_name(&request));
|
let state_dir = state_root().join(session_dir_name(&request));
|
||||||
|
|
@ -166,28 +155,11 @@ impl TailscaleBridgeManager {
|
||||||
};
|
};
|
||||||
|
|
||||||
match session {
|
match session {
|
||||||
Some(session) => match self.fetch_status(session.as_ref()).await {
|
Some(session) => self.fetch_status(session.as_ref()).await.map(Some),
|
||||||
Ok(status) => Ok(Some(status)),
|
|
||||||
Err(err) => {
|
|
||||||
self.remove_session_by_id(session_id).await;
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cancel(&self, session_id: &str) -> Result<bool> {
|
|
||||||
let session = self.remove_session_by_id(session_id).await;
|
|
||||||
match session {
|
|
||||||
Some(session) => {
|
|
||||||
self.shutdown_session(session.as_ref()).await?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
None => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
|
async fn wait_for_status(&self, session: &ManagedSession) -> Result<TailscaleLoginStatus> {
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
let mut last_status = None;
|
let mut last_status = None;
|
||||||
|
|
@ -229,38 +201,6 @@ impl TailscaleBridgeManager {
|
||||||
.await
|
.await
|
||||||
.context("invalid tailscale helper status response")
|
.context("invalid tailscale helper status response")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_session_by_id(&self, session_id: &str) -> Option<Arc<ManagedSession>> {
|
|
||||||
let mut sessions = self.sessions.lock().await;
|
|
||||||
let key = sessions
|
|
||||||
.iter()
|
|
||||||
.find_map(|(key, session)| (session.session_id == session_id).then(|| key.clone()))?;
|
|
||||||
sessions.remove(&key)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn shutdown_session(&self, session: &ManagedSession) -> Result<()> {
|
|
||||||
let _ = self
|
|
||||||
.client
|
|
||||||
.post(format!("{}/shutdown", session.listen_url))
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
for _ in 0..10 {
|
|
||||||
let mut child = session.child.lock().await;
|
|
||||||
if child.try_wait()?.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
drop(child);
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut child = session.child.lock().await;
|
|
||||||
child
|
|
||||||
.start_kill()
|
|
||||||
.context("failed to kill tailscale helper")?;
|
|
||||||
let _ = child.wait().await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
|
fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Result<Command> {
|
||||||
|
|
@ -309,10 +249,7 @@ fn state_root() -> PathBuf {
|
||||||
.join("Burrow")
|
.join("Burrow")
|
||||||
.join("tailscale");
|
.join("tailscale");
|
||||||
}
|
}
|
||||||
home.join(".local")
|
home.join(".local").join("share").join("burrow").join("tailscale")
|
||||||
.join("share")
|
|
||||||
.join("burrow")
|
|
||||||
.join("tailscale")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
fn session_dir_name(request: &TailscaleLoginStartRequest) -> String {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ use super::TailnetProvider;
|
||||||
pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server";
|
pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server";
|
||||||
const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet";
|
const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet";
|
||||||
const WEBFINGER_PATH: &str = "/.well-known/webfinger";
|
const WEBFINGER_PATH: &str = "/.well-known/webfinger";
|
||||||
const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com";
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct TailnetDiscovery {
|
pub struct TailnetDiscovery {
|
||||||
|
|
@ -18,15 +17,6 @@ pub struct TailnetDiscovery {
|
||||||
pub oidc_issuer: Option<String>,
|
pub oidc_issuer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct TailnetAuthorityProbe {
|
|
||||||
pub authority: String,
|
|
||||||
pub status_code: i32,
|
|
||||||
pub summary: String,
|
|
||||||
pub detail: String,
|
|
||||||
pub reachable: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
struct WebFingerDocument {
|
struct WebFingerDocument {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -53,63 +43,6 @@ pub async fn discover_tailnet(email: &str) -> Result<TailnetDiscovery> {
|
||||||
discover_tailnet_at(&client, email, &base_url).await
|
discover_tailnet_at(&client, email, &base_url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn normalize_authority(authority: &str) -> String {
|
|
||||||
let trimmed = authority.trim();
|
|
||||||
if trimmed.contains("://") {
|
|
||||||
trimmed.to_owned()
|
|
||||||
} else {
|
|
||||||
format!("https://{trimmed}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_managed_tailscale_authority(authority: &str) -> bool {
|
|
||||||
let normalized = normalize_authority(authority)
|
|
||||||
.trim_end_matches('/')
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
normalized == format!("https://{MANAGED_TAILSCALE_AUTHORITY}")
|
|
||||||
|| normalized == format!("http://{MANAGED_TAILSCALE_AUTHORITY}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn probe_tailnet_authority(authority: &str) -> Result<TailnetAuthorityProbe> {
|
|
||||||
let authority = normalize_authority(authority);
|
|
||||||
if is_managed_tailscale_authority(&authority) {
|
|
||||||
return Ok(TailnetAuthorityProbe {
|
|
||||||
authority,
|
|
||||||
status_code: 200,
|
|
||||||
summary: "Tailscale-managed control plane".to_owned(),
|
|
||||||
detail: "Using Tailscale's default login server.".to_owned(),
|
|
||||||
reachable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let base_url =
|
|
||||||
Url::parse(&authority).with_context(|| format!("invalid tailnet authority {authority}"))?;
|
|
||||||
let client = Client::builder()
|
|
||||||
.user_agent("burrow-tailnet-probe")
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.context("failed to build tailnet authority probe client")?;
|
|
||||||
|
|
||||||
if let Some(status) =
|
|
||||||
probe_url(&client, base_url.join("/health")?, &authority, "Tailnet server reachable").await?
|
|
||||||
{
|
|
||||||
return Ok(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(status) = probe_url(
|
|
||||||
&client,
|
|
||||||
base_url.clone(),
|
|
||||||
&authority,
|
|
||||||
"Tailnet server reachable",
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
return Ok(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow!("could not connect to the server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn discover_tailnet_at(
|
pub async fn discover_tailnet_at(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
email: &str,
|
email: &str,
|
||||||
|
|
@ -124,7 +57,7 @@ pub async fn discover_tailnet_at(
|
||||||
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
if let Some(authority) = discover_webfinger(client, email, base_url).await? {
|
||||||
return Ok(TailnetDiscovery {
|
return Ok(TailnetDiscovery {
|
||||||
domain,
|
domain,
|
||||||
provider: inferred_provider(Some(&authority), None),
|
provider: TailnetProvider::Headscale,
|
||||||
authority,
|
authority,
|
||||||
oidc_issuer: None,
|
oidc_issuer: None,
|
||||||
});
|
});
|
||||||
|
|
@ -145,19 +78,6 @@ pub fn email_domain(email: &str) -> Result<String> {
|
||||||
Ok(domain)
|
Ok(domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn inferred_provider(
|
|
||||||
authority: Option<&str>,
|
|
||||||
explicit: Option<&TailnetProvider>,
|
|
||||||
) -> TailnetProvider {
|
|
||||||
if matches!(explicit, Some(TailnetProvider::Burrow)) {
|
|
||||||
return TailnetProvider::Burrow;
|
|
||||||
}
|
|
||||||
if authority.is_some_and(is_managed_tailscale_authority) {
|
|
||||||
return TailnetProvider::Tailscale;
|
|
||||||
}
|
|
||||||
TailnetProvider::Headscale
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<TailnetDiscovery>> {
|
async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<TailnetDiscovery>> {
|
||||||
let url = base_url
|
let url = base_url
|
||||||
.join(TAILNET_DISCOVERY_PATH)
|
.join(TAILNET_DISCOVERY_PATH)
|
||||||
|
|
@ -213,37 +133,6 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn probe_url(
|
|
||||||
client: &Client,
|
|
||||||
url: Url,
|
|
||||||
authority: &str,
|
|
||||||
summary: &str,
|
|
||||||
) -> Result<Option<TailnetAuthorityProbe>> {
|
|
||||||
let response = match client
|
|
||||||
.get(url)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(response) => response,
|
|
||||||
Err(_) => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let detail = response.text().await.unwrap_or_default().trim().to_owned();
|
|
||||||
Ok(Some(TailnetAuthorityProbe {
|
|
||||||
authority: authority.to_owned(),
|
|
||||||
status_code: i32::from(status.as_u16()),
|
|
||||||
summary: summary.to_owned(),
|
|
||||||
detail,
|
|
||||||
reachable: true,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
|
|
@ -258,13 +147,6 @@ mod tests {
|
||||||
assert!(email_domain("contact").is_err());
|
assert!(email_domain("contact").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detects_managed_tailscale_authority() {
|
|
||||||
assert!(is_managed_tailscale_authority("controlplane.tailscale.com"));
|
|
||||||
assert!(is_managed_tailscale_authority("https://controlplane.tailscale.com/"));
|
|
||||||
assert!(!is_managed_tailscale_authority("https://ts.burrow.net"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn discovers_from_well_known_document() -> Result<()> {
|
async fn discovers_from_well_known_document() -> Result<()> {
|
||||||
let router = Router::new().route(
|
let router = Router::new().route(
|
||||||
|
|
@ -327,20 +209,4 @@ mod tests {
|
||||||
server.abort();
|
server.abort();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn probes_custom_authority() -> Result<()> {
|
|
||||||
let router = Router::new().route("/health", get(|| async { "ok" }));
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
||||||
let authority = format!("http://{}", listener.local_addr()?);
|
|
||||||
let server = tokio::spawn(async move { axum::serve(listener, router).await });
|
|
||||||
|
|
||||||
let status = probe_tailnet_authority(&authority).await?;
|
|
||||||
assert_eq!(status.authority, authority);
|
|
||||||
assert_eq!(status.status_code, 200);
|
|
||||||
assert!(status.reachable);
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,13 @@ use tun::tokio::TunInterface;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
rpc::grpc_defs::{
|
rpc::grpc_defs::{
|
||||||
networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
|
networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest,
|
||||||
Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
|
NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState,
|
||||||
State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
|
TunnelConfigurationResponse, TunnelStatusResponse,
|
||||||
TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse,
|
|
||||||
TunnelStatusResponse,
|
|
||||||
},
|
},
|
||||||
runtime::{ActiveTunnel, ResolvedTunnel},
|
runtime::{ActiveTunnel, ResolvedTunnel},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::server::tailscale::{
|
|
||||||
TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest,
|
|
||||||
TailscaleLoginStatus,
|
|
||||||
},
|
|
||||||
control::discovery,
|
|
||||||
daemon::rpc::ServerConfig,
|
daemon::rpc::ServerConfig,
|
||||||
database::{add_network, delete_network, get_connection, list_networks, reorder_network},
|
database::{add_network, delete_network, get_connection, list_networks, reorder_network},
|
||||||
};
|
};
|
||||||
|
|
@ -53,7 +46,6 @@ pub struct DaemonRPCServer {
|
||||||
wg_state_chan: (watch::Sender<RunState>, watch::Receiver<RunState>),
|
wg_state_chan: (watch::Sender<RunState>, watch::Receiver<RunState>),
|
||||||
network_update_chan: (watch::Sender<()>, watch::Receiver<()>),
|
network_update_chan: (watch::Sender<()>, watch::Receiver<()>),
|
||||||
active_tunnel: Arc<RwLock<Option<ActiveTunnel>>>,
|
active_tunnel: Arc<RwLock<Option<ActiveTunnel>>>,
|
||||||
tailnet_login: TailscaleBridgeManager,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonRPCServer {
|
impl DaemonRPCServer {
|
||||||
|
|
@ -64,7 +56,6 @@ impl DaemonRPCServer {
|
||||||
wg_state_chan: watch::channel(RunState::Idle),
|
wg_state_chan: watch::channel(RunState::Idle),
|
||||||
network_update_chan: watch::channel(()),
|
network_update_chan: watch::channel(()),
|
||||||
active_tunnel: Arc::new(RwLock::new(None)),
|
active_tunnel: Arc::new(RwLock::new(None)),
|
||||||
tailnet_login: TailscaleBridgeManager::default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,11 +127,6 @@ impl DaemonRPCServer {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tailnet_control_url(authority: &str) -> Option<String> {
|
|
||||||
let authority = discovery::normalize_authority(authority);
|
|
||||||
(!discovery::is_managed_tailscale_authority(&authority)).then_some(authority)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tonic::async_trait]
|
#[tonic::async_trait]
|
||||||
|
|
@ -280,101 +266,6 @@ impl Networks for DaemonRPCServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tonic::async_trait]
|
|
||||||
impl TailnetControl for DaemonRPCServer {
|
|
||||||
async fn discover(
|
|
||||||
&self,
|
|
||||||
request: Request<TailnetDiscoverRequest>,
|
|
||||||
) -> Result<Response<TailnetDiscoverResponse>, RspStatus> {
|
|
||||||
let request = request.into_inner();
|
|
||||||
let discovery = discovery::discover_tailnet(&request.email)
|
|
||||||
.await
|
|
||||||
.map_err(proc_err)?;
|
|
||||||
|
|
||||||
Ok(Response::new(TailnetDiscoverResponse {
|
|
||||||
domain: discovery.domain,
|
|
||||||
authority: discovery.authority.clone(),
|
|
||||||
oidc_issuer: discovery.oidc_issuer.unwrap_or_default(),
|
|
||||||
managed: matches!(
|
|
||||||
discovery::inferred_provider(Some(&discovery.authority), Some(&discovery.provider)),
|
|
||||||
crate::control::TailnetProvider::Tailscale
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn probe(
|
|
||||||
&self,
|
|
||||||
request: Request<TailnetProbeRequest>,
|
|
||||||
) -> Result<Response<TailnetProbeResponse>, RspStatus> {
|
|
||||||
let request = request.into_inner();
|
|
||||||
let status = discovery::probe_tailnet_authority(&request.authority)
|
|
||||||
.await
|
|
||||||
.map_err(proc_err)?;
|
|
||||||
|
|
||||||
Ok(Response::new(TailnetProbeResponse {
|
|
||||||
authority: status.authority,
|
|
||||||
status_code: status.status_code,
|
|
||||||
summary: status.summary,
|
|
||||||
detail: status.detail,
|
|
||||||
reachable: status.reachable,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn login_start(
|
|
||||||
&self,
|
|
||||||
request: Request<super::rpc::grpc_defs::TailnetLoginStartRequest>,
|
|
||||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
|
||||||
let request = request.into_inner();
|
|
||||||
let response = self
|
|
||||||
.tailnet_login
|
|
||||||
.start_login(BridgeLoginStartRequest {
|
|
||||||
account_name: request.account_name,
|
|
||||||
identity_name: request.identity_name,
|
|
||||||
hostname: (!request.hostname.trim().is_empty()).then_some(request.hostname),
|
|
||||||
control_url: Self::tailnet_control_url(&request.authority),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(proc_err)?;
|
|
||||||
|
|
||||||
Ok(Response::new(tailnet_login_rsp(
|
|
||||||
response.session_id,
|
|
||||||
response.status,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn login_status(
|
|
||||||
&self,
|
|
||||||
request: Request<super::rpc::grpc_defs::TailnetLoginStatusRequest>,
|
|
||||||
) -> Result<Response<super::rpc::grpc_defs::TailnetLoginStatusResponse>, RspStatus> {
|
|
||||||
let request = request.into_inner();
|
|
||||||
let status = self
|
|
||||||
.tailnet_login
|
|
||||||
.status(&request.session_id)
|
|
||||||
.await
|
|
||||||
.map_err(proc_err)?;
|
|
||||||
let Some(status) = status else {
|
|
||||||
return Err(RspStatus::not_found("tailnet login session not found"));
|
|
||||||
};
|
|
||||||
Ok(Response::new(tailnet_login_rsp(request.session_id, status)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn login_cancel(
|
|
||||||
&self,
|
|
||||||
request: Request<super::rpc::grpc_defs::TailnetLoginCancelRequest>,
|
|
||||||
) -> Result<Response<Empty>, RspStatus> {
|
|
||||||
let request = request.into_inner();
|
|
||||||
let canceled = self
|
|
||||||
.tailnet_login
|
|
||||||
.cancel(&request.session_id)
|
|
||||||
.await
|
|
||||||
.map_err(proc_err)?;
|
|
||||||
if !canceled {
|
|
||||||
return Err(RspStatus::not_found("tailnet login session not found"));
|
|
||||||
}
|
|
||||||
Ok(Response::new(Empty {}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn proc_err(err: impl ToString) -> RspStatus {
|
fn proc_err(err: impl ToString) -> RspStatus {
|
||||||
RspStatus::internal(err.to_string())
|
RspStatus::internal(err.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -392,21 +283,3 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse {
|
||||||
start: None, // TODO: Add timestamp
|
start: None, // TODO: Add timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tailnet_login_rsp(
|
|
||||||
session_id: String,
|
|
||||||
status: TailscaleLoginStatus,
|
|
||||||
) -> super::rpc::grpc_defs::TailnetLoginStatusResponse {
|
|
||||||
super::rpc::grpc_defs::TailnetLoginStatusResponse {
|
|
||||||
session_id,
|
|
||||||
backend_state: status.backend_state,
|
|
||||||
auth_url: status.auth_url.unwrap_or_default(),
|
|
||||||
running: status.running,
|
|
||||||
needs_login: status.needs_login,
|
|
||||||
tailnet_name: status.tailnet_name.unwrap_or_default(),
|
|
||||||
magic_dns_suffix: status.magic_dns_suffix.unwrap_or_default(),
|
|
||||||
self_dns_name: status.self_dns_name.unwrap_or_default(),
|
|
||||||
tailnet_ips: status.tailscale_ips,
|
|
||||||
health: status.health,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,7 @@ use tonic::transport::Server;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
daemon::rpc::grpc_defs::{
|
daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer},
|
||||||
networks_server::NetworksServer, tailnet_control_server::TailnetControlServer,
|
|
||||||
tunnel_server::TunnelServer,
|
|
||||||
},
|
|
||||||
database::get_connection,
|
database::get_connection,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -39,11 +36,9 @@ pub async fn daemon_main(
|
||||||
let uds = UnixListener::bind(sock_path)?;
|
let uds = UnixListener::bind(sock_path)?;
|
||||||
let serve_job = tokio::spawn(async move {
|
let serve_job = tokio::spawn(async move {
|
||||||
let uds_stream = UnixListenerStream::new(uds);
|
let uds_stream = UnixListenerStream::new(uds);
|
||||||
let tailnet_server = burrow_server.clone();
|
|
||||||
let _srv = Server::builder()
|
let _srv = Server::builder()
|
||||||
.add_service(TunnelServer::new(burrow_server.clone()))
|
.add_service(TunnelServer::new(burrow_server.clone()))
|
||||||
.add_service(NetworksServer::new(burrow_server))
|
.add_service(NetworksServer::new(burrow_server))
|
||||||
.add_service(TailnetControlServer::new(tailnet_server))
|
|
||||||
.serve_with_incoming(uds_stream)
|
.serve_with_incoming(uds_stream)
|
||||||
.await?;
|
.await?;
|
||||||
Ok::<(), AhError>(())
|
Ok::<(), AhError>(())
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,11 @@ use tokio::net::UnixStream;
|
||||||
use tonic::transport::{Endpoint, Uri};
|
use tonic::transport::{Endpoint, Uri};
|
||||||
use tower::service_fn;
|
use tower::service_fn;
|
||||||
|
|
||||||
use super::grpc_defs::{
|
use super::grpc_defs::{networks_client::NetworksClient, tunnel_client::TunnelClient};
|
||||||
networks_client::NetworksClient, tailnet_control_client::TailnetControlClient,
|
|
||||||
tunnel_client::TunnelClient,
|
|
||||||
};
|
|
||||||
use crate::daemon::get_socket_path;
|
use crate::daemon::get_socket_path;
|
||||||
|
|
||||||
pub struct BurrowClient<T> {
|
pub struct BurrowClient<T> {
|
||||||
pub networks_client: NetworksClient<T>,
|
pub networks_client: NetworksClient<T>,
|
||||||
pub tailnet_client: TailnetControlClient<T>,
|
|
||||||
pub tunnel_client: TunnelClient<T>,
|
pub tunnel_client: TunnelClient<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,11 +31,9 @@ impl BurrowClient<tonic::transport::Channel> {
|
||||||
}))
|
}))
|
||||||
.await?;
|
.await?;
|
||||||
let nw_client = NetworksClient::new(channel.clone());
|
let nw_client = NetworksClient::new(channel.clone());
|
||||||
let tailnet_client = TailnetControlClient::new(channel.clone());
|
|
||||||
let tun_client = TunnelClient::new(channel.clone());
|
let tun_client = TunnelClient::new(channel.clone());
|
||||||
Ok(BurrowClient {
|
Ok(BurrowClient {
|
||||||
networks_client: nw_client,
|
networks_client: nw_client,
|
||||||
tailnet_client,
|
|
||||||
tunnel_client: tun_client,
|
tunnel_client: tun_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,6 @@ enum Commands {
|
||||||
NetworkReorder(NetworkReorderArgs),
|
NetworkReorder(NetworkReorderArgs),
|
||||||
/// Delete Network
|
/// Delete Network
|
||||||
NetworkDelete(NetworkDeleteArgs),
|
NetworkDelete(NetworkDeleteArgs),
|
||||||
/// Discover a Tailnet authority through the daemon
|
|
||||||
TailnetDiscover(TailnetDiscoverArgs),
|
|
||||||
/// Probe a Tailnet authority through the daemon
|
|
||||||
TailnetProbe(TailnetProbeArgs),
|
|
||||||
/// Send an ICMP echo probe through the active Tailnet tunnel over daemon packet streaming
|
|
||||||
TailnetPing(TailnetPingArgs),
|
|
||||||
/// Send a UDP echo probe through the active Tailnet tunnel over daemon packet streaming
|
|
||||||
TailnetUdpEcho(TailnetUdpEchoArgs),
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
/// Run a command in an unshared Linux namespace using a Burrow backend
|
/// Run a command in an unshared Linux namespace using a Burrow backend
|
||||||
Exec(ExecArgs),
|
Exec(ExecArgs),
|
||||||
|
|
@ -118,36 +110,6 @@ struct NetworkDeleteArgs {
|
||||||
id: i32,
|
id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
struct TailnetDiscoverArgs {
|
|
||||||
email: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
struct TailnetProbeArgs {
|
|
||||||
authority: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
#[derive(Args)]
|
|
||||||
struct TailnetPingArgs {
|
|
||||||
remote: String,
|
|
||||||
#[arg(long, default_value = "burrow-tailnet-smoke")]
|
|
||||||
payload: String,
|
|
||||||
#[arg(long, default_value_t = 5000)]
|
|
||||||
timeout_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
#[derive(Args)]
|
|
||||||
struct TailnetUdpEchoArgs {
|
|
||||||
remote: String,
|
|
||||||
#[arg(long, default_value = "burrow-tailnet-smoke")]
|
|
||||||
message: String,
|
|
||||||
#[arg(long, default_value_t = 5000)]
|
|
||||||
timeout_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
struct TorExecArgs {
|
struct TorExecArgs {
|
||||||
|
|
@ -278,393 +240,6 @@ async fn try_network_delete(id: i32) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
async fn try_tailnet_discover(email: &str) -> Result<()> {
|
|
||||||
let mut client = BurrowClient::from_uds().await?;
|
|
||||||
let response = client
|
|
||||||
.tailnet_client
|
|
||||||
.discover(crate::daemon::rpc::grpc_defs::TailnetDiscoverRequest {
|
|
||||||
email: email.to_owned(),
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
.into_inner();
|
|
||||||
println!("Tailnet Discover Response: {:?}", response);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
async fn try_tailnet_probe(authority: &str) -> Result<()> {
|
|
||||||
let mut client = BurrowClient::from_uds().await?;
|
|
||||||
let response = client
|
|
||||||
.tailnet_client
|
|
||||||
.probe(crate::daemon::rpc::grpc_defs::TailnetProbeRequest {
|
|
||||||
authority: authority.to_owned(),
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
.into_inner();
|
|
||||||
println!("Tailnet Probe Response: {:?}", response);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Result<()> {
|
|
||||||
use std::net::IpAddr;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use rand::Rng;
|
|
||||||
use tokio::{
|
|
||||||
sync::mpsc,
|
|
||||||
time::{timeout, Duration},
|
|
||||||
};
|
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
|
||||||
|
|
||||||
use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket};
|
|
||||||
|
|
||||||
let remote_ip: IpAddr = remote
|
|
||||||
.parse()
|
|
||||||
.with_context(|| format!("invalid remote IP address {remote}"))?;
|
|
||||||
let message = payload.as_bytes().to_vec();
|
|
||||||
|
|
||||||
let mut client = BurrowClient::from_uds().await?;
|
|
||||||
client.tunnel_client.tunnel_start(Empty {}).await?;
|
|
||||||
|
|
||||||
let mut config_stream = client
|
|
||||||
.tunnel_client
|
|
||||||
.tunnel_configuration(Empty {})
|
|
||||||
.await?
|
|
||||||
.into_inner();
|
|
||||||
let config = config_stream
|
|
||||||
.message()
|
|
||||||
.await?
|
|
||||||
.context("tunnel configuration stream ended before yielding a config")?;
|
|
||||||
let local_ip = select_tailnet_local_ip(&config.addresses, remote_ip)?;
|
|
||||||
|
|
||||||
let identifier = rand::thread_rng().gen::<u16>();
|
|
||||||
let sequence = 1_u16;
|
|
||||||
let packet = build_icmp_echo_request(local_ip, remote_ip, identifier, sequence, &message)?;
|
|
||||||
|
|
||||||
let (outbound_tx, outbound_rx) = mpsc::channel::<TunnelPacket>(128);
|
|
||||||
let mut tunnel_packets = client
|
|
||||||
.tunnel_client
|
|
||||||
.tunnel_packets(ReceiverStream::new(outbound_rx))
|
|
||||||
.await?
|
|
||||||
.into_inner();
|
|
||||||
|
|
||||||
outbound_tx
|
|
||||||
.send(TunnelPacket { payload: packet })
|
|
||||||
.await
|
|
||||||
.context("failed to send ICMP echo probe into daemon packet stream")?;
|
|
||||||
log::debug!(
|
|
||||||
"tailnet ping probe queued from {local_ip} to {remote_ip} identifier={identifier} sequence={sequence}"
|
|
||||||
);
|
|
||||||
drop(outbound_tx);
|
|
||||||
|
|
||||||
let reply = timeout(Duration::from_millis(timeout_ms), async {
|
|
||||||
loop {
|
|
||||||
let packet = tunnel_packets
|
|
||||||
.message()
|
|
||||||
.await
|
|
||||||
.context("failed to read packet from daemon packet stream")?
|
|
||||||
.context("daemon packet stream ended before returning a reply")?;
|
|
||||||
log::debug!(
|
|
||||||
"tailnet ping received {} bytes from daemon packet stream",
|
|
||||||
packet.payload.len()
|
|
||||||
);
|
|
||||||
if let Some(reply) = parse_icmp_echo_reply(
|
|
||||||
&packet.payload,
|
|
||||||
local_ip,
|
|
||||||
remote_ip,
|
|
||||||
identifier,
|
|
||||||
sequence,
|
|
||||||
)? {
|
|
||||||
break Ok::<_, anyhow::Error>(reply);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("timed out waiting for ICMP echo reply from {remote_ip}"))??;
|
|
||||||
|
|
||||||
println!("Tailnet Ping Source: {}", reply.source);
|
|
||||||
println!("Tailnet Ping Destination: {}", reply.destination);
|
|
||||||
println!(
|
|
||||||
"Tailnet Ping Payload: {}",
|
|
||||||
String::from_utf8_lossy(&reply.payload)
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> {
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use netstack_smoltcp::StackBuilder;
|
|
||||||
use tokio::{
|
|
||||||
sync::mpsc,
|
|
||||||
time::{timeout, Duration},
|
|
||||||
};
|
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
|
||||||
|
|
||||||
use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket};
|
|
||||||
|
|
||||||
let remote_addr: SocketAddr = remote
|
|
||||||
.parse()
|
|
||||||
.with_context(|| format!("invalid remote socket address {remote}"))?;
|
|
||||||
|
|
||||||
let mut client = BurrowClient::from_uds().await?;
|
|
||||||
client.tunnel_client.tunnel_start(Empty {}).await?;
|
|
||||||
|
|
||||||
let mut config_stream = client
|
|
||||||
.tunnel_client
|
|
||||||
.tunnel_configuration(Empty {})
|
|
||||||
.await?
|
|
||||||
.into_inner();
|
|
||||||
let config = config_stream
|
|
||||||
.message()
|
|
||||||
.await?
|
|
||||||
.context("tunnel configuration stream ended before yielding a config")?;
|
|
||||||
let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?;
|
|
||||||
|
|
||||||
let (stack, runner, udp_socket, _) = StackBuilder::default()
|
|
||||||
.enable_udp(true)
|
|
||||||
.enable_tcp(true)
|
|
||||||
.build()
|
|
||||||
.context("failed to build userspace UDP stack")?;
|
|
||||||
let runner = runner.context("userspace UDP stack runner unavailable")?;
|
|
||||||
let udp_socket = udp_socket.context("userspace UDP stack socket unavailable")?;
|
|
||||||
let (mut stack_sink, mut stack_stream) = stack.split();
|
|
||||||
let (mut udp_reader, mut udp_writer) = udp_socket.split();
|
|
||||||
|
|
||||||
let (outbound_tx, outbound_rx) = mpsc::channel::<TunnelPacket>(128);
|
|
||||||
let mut tunnel_packets = client
|
|
||||||
.tunnel_client
|
|
||||||
.tunnel_packets(ReceiverStream::new(outbound_rx))
|
|
||||||
.await?
|
|
||||||
.into_inner();
|
|
||||||
|
|
||||||
let ingress_task = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match tunnel_packets.message().await? {
|
|
||||||
Some(packet) => {
|
|
||||||
log::debug!(
|
|
||||||
"tailnet udp echo received {} bytes from daemon packet stream",
|
|
||||||
packet.payload.len()
|
|
||||||
);
|
|
||||||
stack_sink
|
|
||||||
.send(packet.payload)
|
|
||||||
.await
|
|
||||||
.context("failed to feed inbound tailnet packet into userspace stack")?;
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Result::<()>::Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let egress_task = tokio::spawn(async move {
|
|
||||||
while let Some(packet) = stack_stream.next().await {
|
|
||||||
let payload =
|
|
||||||
packet.context("failed to read outbound packet from userspace stack")?;
|
|
||||||
log::debug!(
|
|
||||||
"tailnet udp echo sending {} bytes into daemon packet stream",
|
|
||||||
payload.len()
|
|
||||||
);
|
|
||||||
outbound_tx
|
|
||||||
.send(TunnelPacket { payload })
|
|
||||||
.await
|
|
||||||
.context("failed to forward outbound tailnet packet to daemon")?;
|
|
||||||
}
|
|
||||||
Result::<()>::Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
let runner_task = tokio::spawn(async move { runner.await.map_err(anyhow::Error::from) });
|
|
||||||
|
|
||||||
udp_writer
|
|
||||||
.send((message.as_bytes().to_vec(), local_addr, remote_addr))
|
|
||||||
.await
|
|
||||||
.context("failed to send UDP echo probe into userspace stack")?;
|
|
||||||
log::debug!(
|
|
||||||
"tailnet udp echo probe queued from {local_addr} to {remote_addr}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next())
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("timed out waiting for UDP echo from {remote_addr}"))?
|
|
||||||
.context("userspace UDP stack ended before returning a reply")?;
|
|
||||||
let (payload, reply_source, reply_destination) = response;
|
|
||||||
let response_text = String::from_utf8_lossy(&payload);
|
|
||||||
|
|
||||||
ingress_task.abort();
|
|
||||||
egress_task.abort();
|
|
||||||
runner_task.abort();
|
|
||||||
|
|
||||||
if reply_source != remote_addr {
|
|
||||||
bail!("received UDP reply from unexpected source {reply_source}");
|
|
||||||
}
|
|
||||||
if reply_destination != local_addr {
|
|
||||||
bail!("received UDP reply for unexpected local socket {reply_destination}");
|
|
||||||
}
|
|
||||||
if payload != message.as_bytes() {
|
|
||||||
bail!("UDP echo payload mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Tailnet UDP Echo Source: {reply_source}");
|
|
||||||
println!("Tailnet UDP Echo Destination: {reply_destination}");
|
|
||||||
println!("Tailnet UDP Echo Payload: {response_text}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
fn select_tailnet_local_ip(addresses: &[String], remote_ip: std::net::IpAddr) -> Result<std::net::IpAddr> {
|
|
||||||
use anyhow::Context;
|
|
||||||
|
|
||||||
let family_is_v4 = remote_ip.is_ipv4();
|
|
||||||
addresses
|
|
||||||
.iter()
|
|
||||||
.filter_map(|cidr| cidr.split('/').next())
|
|
||||||
.filter_map(|ip| ip.parse::<std::net::IpAddr>().ok())
|
|
||||||
.find(|ip| ip.is_ipv4() == family_is_v4)
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"no local {} tailnet address found in daemon config {:?}",
|
|
||||||
if family_is_v4 { "IPv4" } else { "IPv6" },
|
|
||||||
addresses
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
fn select_tailnet_local_socket(
|
|
||||||
addresses: &[String],
|
|
||||||
remote_ip: std::net::IpAddr,
|
|
||||||
) -> Result<std::net::SocketAddr> {
|
|
||||||
use rand::Rng;
|
|
||||||
|
|
||||||
let local_ip = select_tailnet_local_ip(addresses, remote_ip)?;
|
|
||||||
let port = rand::thread_rng().gen_range(40000..50000);
|
|
||||||
Ok(std::net::SocketAddr::new(local_ip, port))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
struct IcmpEchoReply {
|
|
||||||
source: std::net::IpAddr,
|
|
||||||
destination: std::net::IpAddr,
|
|
||||||
payload: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
fn build_icmp_echo_request(
|
|
||||||
source: std::net::IpAddr,
|
|
||||||
destination: std::net::IpAddr,
|
|
||||||
identifier: u16,
|
|
||||||
sequence: u16,
|
|
||||||
payload: &[u8],
|
|
||||||
) -> Result<Vec<u8>> {
|
|
||||||
use anyhow::bail;
|
|
||||||
|
|
||||||
let (source, destination) = match (source, destination) {
|
|
||||||
(std::net::IpAddr::V4(source), std::net::IpAddr::V4(destination)) => (source, destination),
|
|
||||||
_ => bail!("tailnet ping currently supports IPv4 only"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut icmp = Vec::with_capacity(8 + payload.len());
|
|
||||||
icmp.push(8);
|
|
||||||
icmp.push(0);
|
|
||||||
icmp.extend_from_slice(&[0, 0]);
|
|
||||||
icmp.extend_from_slice(&identifier.to_be_bytes());
|
|
||||||
icmp.extend_from_slice(&sequence.to_be_bytes());
|
|
||||||
icmp.extend_from_slice(payload);
|
|
||||||
let icmp_checksum = internet_checksum(&icmp);
|
|
||||||
icmp[2..4].copy_from_slice(&icmp_checksum.to_be_bytes());
|
|
||||||
|
|
||||||
let total_len = 20 + icmp.len();
|
|
||||||
let mut packet = Vec::with_capacity(total_len);
|
|
||||||
packet.push(0x45);
|
|
||||||
packet.push(0);
|
|
||||||
packet.extend_from_slice(&(total_len as u16).to_be_bytes());
|
|
||||||
packet.extend_from_slice(&0u16.to_be_bytes());
|
|
||||||
packet.extend_from_slice(&0u16.to_be_bytes());
|
|
||||||
packet.push(64);
|
|
||||||
packet.push(1);
|
|
||||||
packet.extend_from_slice(&[0, 0]);
|
|
||||||
packet.extend_from_slice(&source.octets());
|
|
||||||
packet.extend_from_slice(&destination.octets());
|
|
||||||
let header_checksum = internet_checksum(&packet);
|
|
||||||
packet[10..12].copy_from_slice(&header_checksum.to_be_bytes());
|
|
||||||
packet.extend_from_slice(&icmp);
|
|
||||||
Ok(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
fn parse_icmp_echo_reply(
|
|
||||||
packet: &[u8],
|
|
||||||
local_ip: std::net::IpAddr,
|
|
||||||
remote_ip: std::net::IpAddr,
|
|
||||||
identifier: u16,
|
|
||||||
sequence: u16,
|
|
||||||
) -> Result<Option<IcmpEchoReply>> {
|
|
||||||
use anyhow::bail;
|
|
||||||
|
|
||||||
let (local_ip, remote_ip) = match (local_ip, remote_ip) {
|
|
||||||
(std::net::IpAddr::V4(local_ip), std::net::IpAddr::V4(remote_ip)) => (local_ip, remote_ip),
|
|
||||||
_ => bail!("tailnet ping currently supports IPv4 only"),
|
|
||||||
};
|
|
||||||
|
|
||||||
if packet.len() < 20 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let version = packet[0] >> 4;
|
|
||||||
if version != 4 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let ihl = (packet[0] & 0x0f) as usize * 4;
|
|
||||||
if packet.len() < ihl + 8 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if packet[9] != 1 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = std::net::Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]);
|
|
||||||
let destination = std::net::Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
|
|
||||||
if source != remote_ip || destination != local_ip {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let icmp = &packet[ihl..];
|
|
||||||
if icmp[0] != 0 || icmp[1] != 0 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let reply_identifier = u16::from_be_bytes([icmp[4], icmp[5]]);
|
|
||||||
let reply_sequence = u16::from_be_bytes([icmp[6], icmp[7]]);
|
|
||||||
if reply_identifier != identifier || reply_sequence != sequence {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(IcmpEchoReply {
|
|
||||||
source: std::net::IpAddr::V4(source),
|
|
||||||
destination: std::net::IpAddr::V4(destination),
|
|
||||||
payload: icmp[8..].to_vec(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
|
|
||||||
fn internet_checksum(bytes: &[u8]) -> u16 {
|
|
||||||
let mut sum = 0u32;
|
|
||||||
let mut chunks = bytes.chunks_exact(2);
|
|
||||||
for chunk in &mut chunks {
|
|
||||||
sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
|
|
||||||
}
|
|
||||||
if let Some(&last) = chunks.remainder().first() {
|
|
||||||
sum += (last as u32) << 8;
|
|
||||||
}
|
|
||||||
while (sum >> 16) != 0 {
|
|
||||||
sum = (sum & 0xffff) + (sum >> 16);
|
|
||||||
}
|
|
||||||
!(sum as u16)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
async fn try_tor_exec(payload_path: &str, command: Vec<String>) -> Result<()> {
|
async fn try_tor_exec(payload_path: &str, command: Vec<String>) -> Result<()> {
|
||||||
let exit_code = usernet::run_exec(usernet::ExecInvocation {
|
let exit_code = usernet::run_exec(usernet::ExecInvocation {
|
||||||
|
|
@ -773,14 +348,6 @@ async fn main() -> Result<()> {
|
||||||
Commands::NetworkList => try_network_list().await?,
|
Commands::NetworkList => try_network_list().await?,
|
||||||
Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?,
|
Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).await?,
|
||||||
Commands::NetworkDelete(args) => try_network_delete(args.id).await?,
|
Commands::NetworkDelete(args) => try_network_delete(args.id).await?,
|
||||||
Commands::TailnetDiscover(args) => try_tailnet_discover(&args.email).await?,
|
|
||||||
Commands::TailnetProbe(args) => try_tailnet_probe(&args.authority).await?,
|
|
||||||
Commands::TailnetPing(args) => {
|
|
||||||
try_tailnet_ping(&args.remote, &args.payload, args.timeout_ms).await?
|
|
||||||
}
|
|
||||||
Commands::TailnetUdpEcho(args) => {
|
|
||||||
try_tailnet_udp_echo(&args.remote, &args.message, args.timeout_ms).await?
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Commands::Exec(args) => {
|
Commands::Exec(args) => {
|
||||||
try_exec(
|
try_exec(
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
{
|
|
||||||
groups = {
|
|
||||||
users = "burrow-users";
|
|
||||||
admins = "burrow-admins";
|
|
||||||
};
|
|
||||||
|
|
||||||
identities = {
|
|
||||||
contact = {
|
|
||||||
displayName = "Burrow";
|
|
||||||
canonicalEmail = "contact@burrow.net";
|
|
||||||
sourceEmail = "net.burrow@gmail.com";
|
|
||||||
isAdmin = true;
|
|
||||||
forgeAuthorized = true;
|
|
||||||
bootstrapAuthentik = true;
|
|
||||||
sshPublicKeyPath = ./nixos/keys/contact_at_burrow_net.pub;
|
|
||||||
roles = [
|
|
||||||
"operator"
|
|
||||||
"forge-admin"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
conrad = {
|
|
||||||
displayName = "Conrad Kramer";
|
|
||||||
canonicalEmail = "conrad@burrow.net";
|
|
||||||
sourceEmail = "ckrames1234@gmail.com";
|
|
||||||
isAdmin = true;
|
|
||||||
forgeAuthorized = false;
|
|
||||||
bootstrapAuthentik = true;
|
|
||||||
roles = [
|
|
||||||
"operator"
|
|
||||||
"founder"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
agent = {
|
|
||||||
displayName = "Burrow Agent";
|
|
||||||
canonicalEmail = "agent@burrow.net";
|
|
||||||
isAdmin = false;
|
|
||||||
forgeAuthorized = true;
|
|
||||||
bootstrapAuthentik = false;
|
|
||||||
sshPublicKeyPath = ./nixos/keys/agent_at_burrow_net.pub;
|
|
||||||
roles = [
|
|
||||||
"automation"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
ui-test = {
|
|
||||||
displayName = "Burrow UI Test";
|
|
||||||
canonicalEmail = "ui-test@burrow.net";
|
|
||||||
isAdmin = false;
|
|
||||||
forgeAuthorized = false;
|
|
||||||
bootstrapAuthentik = true;
|
|
||||||
authentikPasswordSecret = "burrowAuthentikUiTestPassword";
|
|
||||||
roles = [
|
|
||||||
"testing"
|
|
||||||
"apple-ui"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -58,17 +58,3 @@ evolution/
|
||||||
```
|
```
|
||||||
|
|
||||||
Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly.
|
Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly.
|
||||||
|
|
||||||
## BEP Helper
|
|
||||||
|
|
||||||
Use the `bep` helper under `Scripts/` to browse or list proposals:
|
|
||||||
|
|
||||||
- `Scripts/bep` opens a quick browser for `evolution/`.
|
|
||||||
- `Scripts/bep list --status Draft` lists proposals by status.
|
|
||||||
- `Scripts/bep open BEP-0005` opens a proposal in `$EDITOR`.
|
|
||||||
|
|
||||||
Validate proposal metadata with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 Scripts/check-bep-metadata.py
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
# `BEP-0005` - Daemon IPC and Apple Boundary
|
|
||||||
|
|
||||||
```text
|
|
||||||
Status: Draft
|
|
||||||
Proposal: BEP-0005
|
|
||||||
Authors: gpt-5.4
|
|
||||||
Coordinator: gpt-5.4
|
|
||||||
Reviewers: Pending
|
|
||||||
Constitution Sections: II, III, IV, V
|
|
||||||
Implementation PRs: Pending
|
|
||||||
Decision Date: Pending
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Burrow should formalize one Apple/runtime boundary: Apple clients speak only to the daemon over gRPC on the app-group Unix socket, and the daemon owns all external control-plane, helper-process, and runtime coordination work. This prevents UI code from accreting side HTTP paths or ad hoc control-plane integrations that bypass the system Burrow is supposed to own.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
- The current Tailnet work already showed the failure mode: Swift UI code started reaching around the daemon boundary to talk to helper HTTP endpoints directly.
|
|
||||||
- Apple-specific process ownership is easy to blur between the app, the network extension, and helper daemons unless the contract is explicit.
|
|
||||||
- If Burrow wants a durable multi-runtime architecture, the daemon must remain the only orchestration boundary between clients and control/data-plane behavior.
|
|
||||||
|
|
||||||
## Detailed Design
|
|
||||||
|
|
||||||
- Apple UI and Apple support libraries may call only daemon gRPC methods over the declared Burrow Unix socket.
|
|
||||||
- Direct Swift calls to external control-plane HTTP APIs, localhost helper HTTP servers, or runtime-specific subprocesses are forbidden.
|
|
||||||
- The daemon is responsible for:
|
|
||||||
- discovery of Tailnet authorities and related metadata
|
|
||||||
- control-plane session setup and tracking
|
|
||||||
- login/session lifecycle brokering
|
|
||||||
- runtime start/stop/reconcile
|
|
||||||
- translating helper or bridge processes into stable daemon RPCs
|
|
||||||
- `burrow/src/control/` owns transport-neutral control-plane semantics such as discovery, authority normalization, and request/response shaping.
|
|
||||||
- Apple UI owns presentation only:
|
|
||||||
- forms
|
|
||||||
- local state
|
|
||||||
- presenting returned auth URLs or statuses
|
|
||||||
- surfacing daemon availability and errors
|
|
||||||
- Any new Apple-facing runtime capability requires a daemon RPC first.
|
|
||||||
|
|
||||||
## Security and Operational Considerations
|
|
||||||
|
|
||||||
- Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code.
|
|
||||||
- The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized.
|
|
||||||
- Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly.
|
|
||||||
|
|
||||||
## Contributor Playbook
|
|
||||||
|
|
||||||
- Before adding a new Apple-side workflow, identify the daemon RPC that should own it.
|
|
||||||
- If the RPC does not exist, add the protocol shape in `proto/burrow.proto`, implement it in the daemon, and only then wire Swift UI.
|
|
||||||
- Verify that no Swift UI or support code calls external control-plane HTTP endpoints directly.
|
|
||||||
- For Tailnet and similar flows, test:
|
|
||||||
- daemon unavailable behavior
|
|
||||||
- successful RPC path
|
|
||||||
- error propagation through the UI
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
- Let Apple UI call control-plane endpoints directly for convenience. Rejected because it creates parallel orchestration paths and breaks the daemon contract.
|
|
||||||
- Allow one-off exceptions for login helpers. Rejected because those exceptions become the architecture.
|
|
||||||
|
|
||||||
## Impact on Other Work
|
|
||||||
|
|
||||||
- Governs the Tailnet refactor and future Apple runtime work.
|
|
||||||
- Interacts with BEP-0002 control-plane bootstrap and BEP-0003 transport refactoring.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Pending.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `Apple/UI/`
|
|
||||||
- `Apple/Core/`
|
|
||||||
- `Apple/NetworkExtension/`
|
|
||||||
- `burrow/src/daemon/`
|
|
||||||
- `burrow/src/control/`
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# `BEP-0006` - Tailnet Authority-First Control Plane
|
|
||||||
|
|
||||||
```text
|
|
||||||
Status: Draft
|
|
||||||
Proposal: BEP-0006
|
|
||||||
Authors: gpt-5.4
|
|
||||||
Coordinator: gpt-5.4
|
|
||||||
Reviewers: Pending
|
|
||||||
Constitution Sections: I, II, IV, V
|
|
||||||
Implementation PRs: Pending
|
|
||||||
Decision Date: Pending
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Burrow should treat Tailnet as one protocol family. Tailscale-managed and self-hosted Headscale-style deployments differ by authority, policy, and auth details, not by a distinct user-facing protocol. Burrow’s config and UI should therefore be authority-first rather than provider-first.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
- Splitting Tailscale and Headscale into separate user-facing providers causes fake architectural divergence.
|
|
||||||
- Discovery already naturally returns an authority and optional issuer; that is the stable contract users actually need.
|
|
||||||
- Future managed or enterprise deployments should fit the same model without requiring another protocol picker.
|
|
||||||
|
|
||||||
## Detailed Design
|
|
||||||
|
|
||||||
- Tailnet configuration is centered on:
|
|
||||||
- account
|
|
||||||
- identity
|
|
||||||
- authority/login server URL
|
|
||||||
- optional tailnet name
|
|
||||||
- optional hostname
|
|
||||||
- auth method/material
|
|
||||||
- User-facing surfaces should not force a protocol choice between Tailscale and Headscale.
|
|
||||||
- Provider inference may remain internal metadata for compatibility and diagnostics:
|
|
||||||
- default managed Tailscale authority
|
|
||||||
- custom self-hosted authority
|
|
||||||
- Burrow-owned authority when explicitly applicable
|
|
||||||
- Discovery returns authority and related metadata; editing the authority is the mechanism that moves a configuration from managed default to custom control server.
|
|
||||||
- The daemon and control layer own provider inference; the UI should primarily present “Tailnet” plus the selected authority.
|
|
||||||
|
|
||||||
## Security and Operational Considerations
|
|
||||||
|
|
||||||
- Authority-first config reduces UI complexity and makes misconfiguration easier to reason about.
|
|
||||||
- Provider-specific assumptions must not leak into packet or control-plane semantics unless the authority actually requires them.
|
|
||||||
- Auth material must remain authority-scoped and identity-scoped in daemon storage.
|
|
||||||
|
|
||||||
## Contributor Playbook
|
|
||||||
|
|
||||||
- Remove provider pickers from Tailnet UI unless a concrete protocol difference requires one.
|
|
||||||
- Store the authority explicitly in payloads and infer provider internally only when needed.
|
|
||||||
- Prefer tests that validate authority normalization and discovery behavior over UI-provider branching.
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
- Keep separate user-facing providers for Tailscale and Headscale. Rejected because it models deployment shape as protocol shape.
|
|
||||||
- Collapse all control planes into one opaque Burrow provider. Rejected because the authority still matters operationally and diagnostically.
|
|
||||||
|
|
||||||
## Impact on Other Work
|
|
||||||
|
|
||||||
- Refines BEP-0002’s Tailscale-shaped control-plane work.
|
|
||||||
- Constrains the Tailnet Apple refactor and future daemon control-plane storage.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Pending.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `burrow/src/control/`
|
|
||||||
- `Apple/UI/Networks/`
|
|
||||||
- `proto/burrow.proto`
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# `BEP-0007` - Identity Registry and Operator Bootstrap
|
|
||||||
|
|
||||||
```text
|
|
||||||
Status: Draft
|
|
||||||
Proposal: BEP-0007
|
|
||||||
Authors: gpt-5.4
|
|
||||||
Coordinator: gpt-5.4
|
|
||||||
Reviewers: Pending
|
|
||||||
Constitution Sections: II, III, IV, V
|
|
||||||
Implementation PRs: Pending
|
|
||||||
Decision Date: Pending
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Burrow should maintain one canonical registry for project identities, aliases, bootstrap users, SSH keys, and admin-group mappings. Forgejo, Authentik, and related bootstrap configuration should derive from that registry instead of hardcoding overlapping identity facts in multiple modules.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
- Burrow currently hardcodes operator and admin/bootstrap user facts directly in host configuration.
|
|
||||||
- Multi-account and self-hosted identity are becoming core architecture, not incidental infra details.
|
|
||||||
- A single registry reduces drift across Forgejo, Authentik, Headscale, SSH authorization, and future control-plane bootstrap.
|
|
||||||
|
|
||||||
## Detailed Design
|
|
||||||
|
|
||||||
- Add a root-level identity registry (`contributors.nix`) as the canonical source of truth for:
|
|
||||||
- usernames
|
|
||||||
- display names
|
|
||||||
- canonical emails
|
|
||||||
- external source emails or aliases
|
|
||||||
- admin scope
|
|
||||||
- bootstrap eligibility
|
|
||||||
- forge authorized SSH keys
|
|
||||||
- named roles
|
|
||||||
- Consume that registry from host configuration for:
|
|
||||||
- Forgejo authorized keys
|
|
||||||
- Forgejo bootstrap admin defaults
|
|
||||||
- Authentik bootstrap users
|
|
||||||
- Burrow user/admin group names
|
|
||||||
- Future work may derive contributor docs, OIDC bootstrap, and additional runtime configuration from the same registry.
|
|
||||||
|
|
||||||
## Security and Operational Considerations
|
|
||||||
|
|
||||||
- Identity drift is a security bug when it affects admin groups, bootstrap accounts, or SSH authorization.
|
|
||||||
- The registry stores metadata only; secrets remain in agenix or other declared secret paths.
|
|
||||||
- Changes to the registry should receive explicit review because they affect access and governance.
|
|
||||||
|
|
||||||
## Contributor Playbook
|
|
||||||
|
|
||||||
- Edit `contributors.nix` first when changing operator, admin, alias, or bootstrap identity state.
|
|
||||||
- Derive runtime configuration from the registry instead of duplicating the same facts elsewhere.
|
|
||||||
- Keep secret references separate from identity metadata.
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
- Continue hardcoding users in module options. Rejected because drift is inevitable once Forgejo, Authentik, and Headscale all depend on the same identities.
|
|
||||||
- Create separate per-service user lists. Rejected because it duplicates governance facts and weakens review.
|
|
||||||
|
|
||||||
## Impact on Other Work
|
|
||||||
|
|
||||||
- Supports forge auth, Authentik group sync, and future multi-account Burrow control-plane work.
|
|
||||||
- Creates the basis for stronger contributor and operator provenance later.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Pending.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `contributors.nix`
|
|
||||||
- `nixos/hosts/burrow-forge/default.nix`
|
|
||||||
- `nixos/modules/burrow-authentik.nix`
|
|
||||||
- `nixos/modules/burrow-forge.nix`
|
|
||||||
26
flake.lock
generated
26
flake.lock
generated
|
|
@ -123,37 +123,13 @@
|
||||||
"url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable"
|
"url": "https://codeload.github.com/NixOS/nixpkgs/tar.gz/nixos-unstable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nsc-autoscaler": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": [
|
|
||||||
"flake-utils"
|
|
||||||
],
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1775221037,
|
|
||||||
"narHash": "sha256-tv6Y3cqn76PEyZpSMMItVW96KKIboovBWTOv5Lt7PXg=",
|
|
||||||
"ref": "refs/heads/main",
|
|
||||||
"rev": "2c485752fde28ec3be2f228b571d1906f4bcf917",
|
|
||||||
"revCount": 10,
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://compatible.systems/conrad/nsc-autoscaler.git"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://compatible.systems/conrad/nsc-autoscaler.git"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"agenix": "agenix",
|
"agenix": "agenix",
|
||||||
"disko": "disko",
|
"disko": "disko",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"hcloud-upload-image-src": "hcloud-upload-image-src",
|
"hcloud-upload-image-src": "hcloud-upload-image-src",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs"
|
||||||
"nsc-autoscaler": "nsc-autoscaler"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,13 @@
|
||||||
url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master";
|
url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
nsc-autoscaler = {
|
|
||||||
url = "git+https://compatible.systems/conrad/nsc-autoscaler.git";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
inputs.flake-utils.follows = "flake-utils";
|
|
||||||
};
|
|
||||||
hcloud-upload-image-src = {
|
hcloud-upload-image-src = {
|
||||||
url = "tarball+https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0";
|
url = "tarball+https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0";
|
||||||
flake = false;
|
flake = false;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, agenix, disko, nsc-autoscaler, hcloud-upload-image-src }:
|
outputs = { self, nixpkgs, flake-utils, agenix, disko, hcloud-upload-image-src }:
|
||||||
let
|
let
|
||||||
supportedSystems = [
|
supportedSystems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
|
|
@ -180,7 +175,7 @@
|
||||||
// {
|
// {
|
||||||
nixosModules.burrow-forge = import ./nixos/modules/burrow-forge.nix;
|
nixosModules.burrow-forge = import ./nixos/modules/burrow-forge.nix;
|
||||||
nixosModules.burrow-forge-runner = import ./nixos/modules/burrow-forge-runner.nix;
|
nixosModules.burrow-forge-runner = import ./nixos/modules/burrow-forge-runner.nix;
|
||||||
nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default;
|
nixosModules.burrow-forgejo-nsc = import ./nixos/modules/burrow-forgejo-nsc.nix;
|
||||||
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
|
nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix;
|
||||||
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix;
|
nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B
|
||||||
- `hosts/burrow-forge/default.nix`: host entrypoint
|
- `hosts/burrow-forge/default.nix`: host entrypoint
|
||||||
- `modules/burrow-forge.nix`: Forgejo, Caddy, PostgreSQL, and admin bootstrap module
|
- `modules/burrow-forge.nix`: Forgejo, Caddy, PostgreSQL, and admin bootstrap module
|
||||||
- `modules/burrow-forge-runner.nix`: Forgejo Actions runner and agent identity bootstrap
|
- `modules/burrow-forge-runner.nix`: Forgejo Actions runner and agent identity bootstrap
|
||||||
- upstream `compatible.systems/conrad/nsc-autoscaler`: Namespace-backed ephemeral Forgejo runner module consumed via the Burrow flake input
|
- `modules/burrow-forgejo-nsc.nix`: Namespace-backed ephemeral Forgejo runner services
|
||||||
- `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes
|
- `modules/burrow-authentik.nix`: minimal Authentik IdP for Burrow control planes
|
||||||
- `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC
|
- `modules/burrow-headscale.nix`: Headscale control plane rooted in Authentik OIDC
|
||||||
- `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets
|
- `../secrets.nix`: agenix recipient map for tracked Burrow forge secrets
|
||||||
|
|
@ -32,7 +32,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B
|
||||||
3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`.
|
3. Run `Scripts/bootstrap-forge-intake.sh` to place the Forgejo bootstrap password file and automation SSH key under `/var/lib/burrow/intake/`.
|
||||||
4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account.
|
4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account.
|
||||||
5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent <agent@burrow.net>`.
|
5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent <agent@burrow.net>`.
|
||||||
6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the raw Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/` for the upstream `services.forgejo-nsc` module.
|
6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`.
|
||||||
7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`.
|
7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, `secrets/infra/forgejo-oidc-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`.
|
||||||
8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME.
|
8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME.
|
||||||
9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace.
|
9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace.
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,4 @@
|
||||||
{ config, lib, self, ... }:
|
{ config, self, ... }:
|
||||||
|
|
||||||
let
|
|
||||||
contributors = import ../../../contributors.nix;
|
|
||||||
identities = contributors.identities;
|
|
||||||
authentikPasswordSecretPath = identity:
|
|
||||||
if identity ? authentikPasswordSecret
|
|
||||||
then config.age.secrets.${identity.authentikPasswordSecret}.path
|
|
||||||
else null;
|
|
||||||
bootstrapUsers = lib.mapAttrsToList
|
|
||||||
(
|
|
||||||
username: identity: {
|
|
||||||
inherit username;
|
|
||||||
name = identity.displayName;
|
|
||||||
email = identity.canonicalEmail;
|
|
||||||
sourceEmail = identity.sourceEmail or null;
|
|
||||||
isAdmin = identity.isAdmin or false;
|
|
||||||
passwordFile = authentikPasswordSecretPath identity;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
(lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities);
|
|
||||||
forgeAuthorizedKeys = map
|
|
||||||
(username: builtins.readFile identities.${username}.sshPublicKeyPath)
|
|
||||||
(builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeAuthorized or false) identities));
|
|
||||||
in
|
|
||||||
|
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
|
|
@ -63,12 +39,6 @@ in
|
||||||
group = "forgejo";
|
group = "forgejo";
|
||||||
mode = "0440";
|
mode = "0440";
|
||||||
};
|
};
|
||||||
age.secrets.burrowTailscaleOidcClientSecret = {
|
|
||||||
file = ../../../secrets/infra/tailscale-oidc-client-secret.age;
|
|
||||||
owner = "root";
|
|
||||||
group = "root";
|
|
||||||
mode = "0400";
|
|
||||||
};
|
|
||||||
age.secrets.burrowAuthentikGoogleClientId = {
|
age.secrets.burrowAuthentikGoogleClientId = {
|
||||||
file = ../../../secrets/infra/authentik-google-client-id.age;
|
file = ../../../secrets/infra/authentik-google-client-id.age;
|
||||||
owner = "root";
|
owner = "root";
|
||||||
|
|
@ -81,12 +51,6 @@ in
|
||||||
group = "root";
|
group = "root";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
};
|
};
|
||||||
age.secrets.burrowAuthentikUiTestPassword = {
|
|
||||||
file = ../../../secrets/infra/authentik-ui-test-password.age;
|
|
||||||
owner = "root";
|
|
||||||
group = "root";
|
|
||||||
mode = "0400";
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.extraHosts = ''
|
networking.extraHosts = ''
|
||||||
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net
|
||||||
|
|
@ -95,14 +59,12 @@ in
|
||||||
|
|
||||||
services.burrow.forge = {
|
services.burrow.forge = {
|
||||||
enable = true;
|
enable = true;
|
||||||
contactEmail = identities.contact.canonicalEmail;
|
|
||||||
adminUsername = "contact";
|
|
||||||
adminEmail = identities.contact.canonicalEmail;
|
|
||||||
adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt";
|
adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt";
|
||||||
oidcAdminGroup = contributors.groups.admins;
|
|
||||||
oidcRestrictedGroup = contributors.groups.users;
|
|
||||||
oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
|
oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
|
||||||
authorizedKeys = forgeAuthorizedKeys;
|
authorizedKeys = [
|
||||||
|
(builtins.readFile ../../keys/contact_at_burrow_net.pub)
|
||||||
|
(builtins.readFile ../../keys/agent_at_burrow_net.pub)
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
services.burrow.forgeRunner = {
|
services.burrow.forgeRunner = {
|
||||||
|
|
@ -110,7 +72,7 @@ in
|
||||||
sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519";
|
sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519";
|
||||||
};
|
};
|
||||||
|
|
||||||
services.forgejo-nsc = {
|
services.burrow.forgejoNsc = {
|
||||||
enable = true;
|
enable = true;
|
||||||
nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt";
|
nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt";
|
||||||
dispatcher = {
|
dispatcher = {
|
||||||
|
|
@ -127,13 +89,25 @@ in
|
||||||
envFile = config.age.secrets.burrowAuthentikEnv.path;
|
envFile = config.age.secrets.burrowAuthentikEnv.path;
|
||||||
forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
|
forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
|
||||||
headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
|
headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path;
|
||||||
tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path;
|
|
||||||
googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path;
|
googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path;
|
||||||
googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path;
|
googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path;
|
||||||
googleLoginMode = "redirect";
|
googleLoginMode = "redirect";
|
||||||
userGroupName = contributors.groups.users;
|
bootstrapUsers = [
|
||||||
adminGroupName = contributors.groups.admins;
|
{
|
||||||
bootstrapUsers = bootstrapUsers;
|
username = "contact";
|
||||||
|
name = "Burrow";
|
||||||
|
email = "contact@burrow.net";
|
||||||
|
sourceEmail = "net.burrow@gmail.com";
|
||||||
|
isAdmin = true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
username = "conrad";
|
||||||
|
name = "Conrad Kramer";
|
||||||
|
email = "conrad@burrow.net";
|
||||||
|
sourceEmail = "ckrames1234@gmail.com";
|
||||||
|
isAdmin = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
services.burrow.headscale = {
|
services.burrow.headscale = {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ let
|
||||||
dataVolume = "burrow-authentik-data:/data";
|
dataVolume = "burrow-authentik-data:/data";
|
||||||
directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh;
|
directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh;
|
||||||
forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh;
|
forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh;
|
||||||
tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh;
|
|
||||||
googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh;
|
googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh;
|
||||||
tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh;
|
|
||||||
authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" ''
|
authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" ''
|
||||||
version: 1
|
version: 1
|
||||||
metadata:
|
metadata:
|
||||||
|
|
@ -132,24 +130,6 @@ in
|
||||||
description = "Authentik application slug for Forgejo.";
|
description = "Authentik application slug for Forgejo.";
|
||||||
};
|
};
|
||||||
|
|
||||||
tailscaleProviderSlug = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "tailscale";
|
|
||||||
description = "Authentik application slug for Tailscale custom OIDC sign-in.";
|
|
||||||
};
|
|
||||||
|
|
||||||
tailscaleClientId = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "tailscale.burrow.net";
|
|
||||||
description = "Client ID Authentik should present to Tailscale.";
|
|
||||||
};
|
|
||||||
|
|
||||||
tailscaleClientSecretFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
description = "Host-local file containing the Authentik Tailscale OIDC client secret.";
|
|
||||||
};
|
|
||||||
|
|
||||||
forgejoClientId = lib.mkOption {
|
forgejoClientId = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "git.burrow.net";
|
default = "git.burrow.net";
|
||||||
|
|
@ -195,36 +175,6 @@ in
|
||||||
description = "Identification-stage behavior for the Google Authentik source.";
|
description = "Identification-stage behavior for the Google Authentik source.";
|
||||||
};
|
};
|
||||||
|
|
||||||
headscaleAuthenticationFlowSlug = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "burrow-tailnet-authentication";
|
|
||||||
description = "Authentik authentication flow slug used for Burrow Tailnet sign-in.";
|
|
||||||
};
|
|
||||||
|
|
||||||
headscaleAuthenticationFlowName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "Burrow Tailnet Authentication";
|
|
||||||
description = "Authentik authentication flow name used for Burrow Tailnet sign-in.";
|
|
||||||
};
|
|
||||||
|
|
||||||
headscaleIdentificationStageName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "burrow-tailnet-identification-stage";
|
|
||||||
description = "Authentik identification stage used for Burrow Tailnet sign-in.";
|
|
||||||
};
|
|
||||||
|
|
||||||
headscalePasswordStageName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "burrow-tailnet-password-stage";
|
|
||||||
description = "Authentik password stage used for Burrow Tailnet sign-in.";
|
|
||||||
};
|
|
||||||
|
|
||||||
headscaleUserLoginStageName = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "burrow-tailnet-user-login-stage";
|
|
||||||
description = "Authentik user-login stage used for Burrow Tailnet sign-in.";
|
|
||||||
};
|
|
||||||
|
|
||||||
userGroupName = lib.mkOption {
|
userGroupName = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "burrow-users";
|
default = "burrow-users";
|
||||||
|
|
@ -267,11 +217,6 @@ in
|
||||||
default = false;
|
default = false;
|
||||||
description = "Whether this user should be in the Burrow admin group.";
|
description = "Whether this user should be in the Burrow admin group.";
|
||||||
};
|
};
|
||||||
passwordFile = lib.mkOption {
|
|
||||||
type = nullOr str;
|
|
||||||
default = null;
|
|
||||||
description = "Optional host-local file containing a bootstrap password for this user.";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
default = [ ];
|
default = [ ];
|
||||||
|
|
@ -332,13 +277,6 @@ in
|
||||||
fi
|
fi
|
||||||
''}
|
''}
|
||||||
|
|
||||||
${lib.optionalString (cfg.tailscaleClientSecretFile != null) ''
|
|
||||||
if [ ! -s ${lib.escapeShellArg cfg.tailscaleClientSecretFile} ]; then
|
|
||||||
echo "Tailscale client secret missing: ${cfg.tailscaleClientSecretFile}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
''}
|
|
||||||
|
|
||||||
install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir}
|
install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir}
|
||||||
install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile}
|
install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile}
|
||||||
|
|
||||||
|
|
@ -530,7 +468,7 @@ EOF
|
||||||
restartTriggers = [
|
restartTriggers = [
|
||||||
directorySyncScript
|
directorySyncScript
|
||||||
cfg.envFile
|
cfg.envFile
|
||||||
] ++ lib.concatMap (user: lib.optional (user.passwordFile != null) user.passwordFile) cfg.bootstrapUsers;
|
];
|
||||||
path = [
|
path = [
|
||||||
pkgs.bash
|
pkgs.bash
|
||||||
pkgs.coreutils
|
pkgs.coreutils
|
||||||
|
|
@ -553,7 +491,7 @@ EOF
|
||||||
export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName}
|
export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName}
|
||||||
export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug}
|
export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug}
|
||||||
export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: {
|
export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: {
|
||||||
inherit (user) username name email isAdmin passwordFile;
|
inherit (user) username name email isAdmin;
|
||||||
groups = user.groups;
|
groups = user.groups;
|
||||||
}) cfg.bootstrapUsers)}'
|
}) cfg.bootstrapUsers)}'
|
||||||
|
|
||||||
|
|
@ -561,60 +499,6 @@ EOF
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.burrow-authentik-tailnet-auth-flow = {
|
|
||||||
description = "Reconcile the Burrow Tailnet authentication flow";
|
|
||||||
after =
|
|
||||||
[
|
|
||||||
"burrow-authentik-ready.service"
|
|
||||||
"network-online.target"
|
|
||||||
]
|
|
||||||
++ lib.optionals (
|
|
||||||
cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null
|
|
||||||
) [ "burrow-authentik-google-source.service" ];
|
|
||||||
wants =
|
|
||||||
[
|
|
||||||
"burrow-authentik-ready.service"
|
|
||||||
"network-online.target"
|
|
||||||
]
|
|
||||||
++ lib.optionals (
|
|
||||||
cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null
|
|
||||||
) [ "burrow-authentik-google-source.service" ];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
restartTriggers = [
|
|
||||||
tailnetAuthFlowSyncScript
|
|
||||||
cfg.envFile
|
|
||||||
];
|
|
||||||
path = [
|
|
||||||
pkgs.bash
|
|
||||||
pkgs.coreutils
|
|
||||||
pkgs.curl
|
|
||||||
pkgs.jq
|
|
||||||
];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
User = "root";
|
|
||||||
Group = "root";
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
set -euo pipefail
|
|
||||||
set -a
|
|
||||||
source ${lib.escapeShellArg cfg.envFile}
|
|
||||||
set +a
|
|
||||||
|
|
||||||
export AUTHENTIK_URL=https://${cfg.domain}
|
|
||||||
export AUTHENTIK_TAILNET_PROVIDER_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug}
|
|
||||||
export AUTHENTIK_TAILNET_PROVIDER_SLUGS_JSON='["${cfg.headscaleProviderSlug}","${cfg.tailscaleProviderSlug}"]'
|
|
||||||
export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_NAME=${lib.escapeShellArg cfg.headscaleAuthenticationFlowName}
|
|
||||||
export AUTHENTIK_TAILNET_AUTHENTICATION_FLOW_SLUG=${lib.escapeShellArg cfg.headscaleAuthenticationFlowSlug}
|
|
||||||
export AUTHENTIK_TAILNET_IDENTIFICATION_STAGE_NAME=${lib.escapeShellArg cfg.headscaleIdentificationStageName}
|
|
||||||
export AUTHENTIK_TAILNET_PASSWORD_STAGE_NAME=${lib.escapeShellArg cfg.headscalePasswordStageName}
|
|
||||||
export AUTHENTIK_TAILNET_USER_LOGIN_STAGE_NAME=${lib.escapeShellArg cfg.headscaleUserLoginStageName}
|
|
||||||
export AUTHENTIK_TAILNET_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug}
|
|
||||||
|
|
||||||
${pkgs.bash}/bin/bash ${tailnetAuthFlowSyncScript}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) {
|
systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) {
|
||||||
description = "Reconcile the Burrow Authentik Forgejo OIDC application";
|
description = "Reconcile the Burrow Authentik Forgejo OIDC application";
|
||||||
after = [
|
after = [
|
||||||
|
|
@ -661,53 +545,6 @@ EOF
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.burrow-authentik-tailscale-oidc = lib.mkIf (cfg.tailscaleClientSecretFile != null) {
|
|
||||||
description = "Reconcile the Burrow Authentik Tailscale OIDC application";
|
|
||||||
after = [
|
|
||||||
"burrow-authentik-ready.service"
|
|
||||||
"network-online.target"
|
|
||||||
];
|
|
||||||
wants = [
|
|
||||||
"burrow-authentik-ready.service"
|
|
||||||
"network-online.target"
|
|
||||||
];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
restartTriggers = [
|
|
||||||
tailscaleOidcSyncScript
|
|
||||||
cfg.envFile
|
|
||||||
cfg.tailscaleClientSecretFile
|
|
||||||
];
|
|
||||||
path = [
|
|
||||||
pkgs.bash
|
|
||||||
pkgs.coreutils
|
|
||||||
pkgs.curl
|
|
||||||
pkgs.jq
|
|
||||||
];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
User = "root";
|
|
||||||
Group = "root";
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
set -euo pipefail
|
|
||||||
set -a
|
|
||||||
source ${lib.escapeShellArg cfg.envFile}
|
|
||||||
set +a
|
|
||||||
|
|
||||||
export AUTHENTIK_URL=https://${cfg.domain}
|
|
||||||
export AUTHENTIK_TAILSCALE_APPLICATION_SLUG=${lib.escapeShellArg cfg.tailscaleProviderSlug}
|
|
||||||
export AUTHENTIK_TAILSCALE_APPLICATION_NAME=Tailscale
|
|
||||||
export AUTHENTIK_TAILSCALE_PROVIDER_NAME=Tailscale
|
|
||||||
export AUTHENTIK_TAILSCALE_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug}
|
|
||||||
export AUTHENTIK_TAILSCALE_CLIENT_ID=${lib.escapeShellArg cfg.tailscaleClientId}
|
|
||||||
export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})"
|
|
||||||
export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc
|
|
||||||
export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]'
|
|
||||||
|
|
||||||
${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
|
services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
reverse_proxy 127.0.0.1:${toString cfg.port}
|
reverse_proxy 127.0.0.1:${toString cfg.port}
|
||||||
|
|
|
||||||
|
|
@ -258,20 +258,20 @@ in
|
||||||
"${cfg.siteDomain}".extraConfig = ''
|
"${cfg.siteDomain}".extraConfig = ''
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
@oidcConfig path /.well-known/openid-configuration
|
@oidcConfig path /.well-known/openid-configuration
|
||||||
redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/.well-known/openid-configuration 308
|
redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/.well-known/openid-configuration 308
|
||||||
@tailnetConfig path /.well-known/burrow-tailnet
|
@tailnetConfig path /.well-known/burrow-tailnet
|
||||||
header @tailnetConfig Content-Type application/json
|
header @tailnetConfig Content-Type application/json
|
||||||
respond @tailnetConfig "{\"domain\":\"${cfg.siteDomain}\",\"provider\":\"headscale\",\"authority\":\"https://${config.services.burrow.headscale.domain}\",\"oidc_issuer\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/\"}" 200
|
respond @tailnetConfig "{\"domain\":\"${cfg.siteDomain}\",\"provider\":\"headscale\",\"authority\":\"https://${config.services.burrow.headscale.domain}\",\"oidc_issuer\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/\"}" 200
|
||||||
@webfinger path /.well-known/webfinger
|
@webfinger path /.well-known/webfinger
|
||||||
header @webfinger Content-Type application/jrd+json
|
header @webfinger Content-Type application/jrd+json
|
||||||
respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200
|
respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200
|
||||||
@root path /
|
@root path /
|
||||||
redir @root ${homeRepoUrl} 308
|
redir @root ${homeRepoUrl} 308
|
||||||
respond 404
|
respond 404
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (
|
// lib.optionalAttrs (
|
||||||
config.services.forgejo-nsc.enable && config.services.forgejo-nsc.autoscaler.enable
|
config.services.burrow.forgejoNsc.enable && config.services.burrow.forgejoNsc.autoscaler.enable
|
||||||
) {
|
) {
|
||||||
"${cfg.nscAutoscalerDomain}".extraConfig = ''
|
"${cfg.nscAutoscalerDomain}".extraConfig = ''
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
|
||||||
234
nixos/modules/burrow-forgejo-nsc.nix
Normal file
234
nixos/modules/burrow-forgejo-nsc.nix
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
{ config, lib, pkgs, self, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
inherit (lib)
|
||||||
|
mkEnableOption
|
||||||
|
mkIf
|
||||||
|
mkOption
|
||||||
|
types
|
||||||
|
mkAfter
|
||||||
|
mkDefault
|
||||||
|
optional
|
||||||
|
optionalAttrs
|
||||||
|
optionalString
|
||||||
|
;
|
||||||
|
|
||||||
|
cfg = config.services.burrow.forgejoNsc;
|
||||||
|
dispatcherRuntimeConfig = "${cfg.stateDir}/dispatcher.yaml";
|
||||||
|
autoscalerRuntimeConfig = "${cfg.stateDir}/autoscaler.yaml";
|
||||||
|
|
||||||
|
pendingCheck = configPath: pkgs.writeShellScript "forgejo-nsc-check-pending" ''
|
||||||
|
set -euo pipefail
|
||||||
|
if ${pkgs.gnugrep}/bin/grep -q 'PENDING-' '${configPath}'; then
|
||||||
|
echo "forgejo-nsc config still contains placeholder values (PENDING-); update ${configPath} before starting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
|
nscTokenPath = "${cfg.stateDir}/nsc.token";
|
||||||
|
tokenSync = optionalString (cfg.nscTokenFile != null) ''
|
||||||
|
install -m 600 ${lib.escapeShellArg cfg.nscTokenFile} ${lib.escapeShellArg nscTokenPath}
|
||||||
|
chown ${cfg.user}:${cfg.group} ${nscTokenPath}
|
||||||
|
chmod 600 ${nscTokenPath}
|
||||||
|
'';
|
||||||
|
dispatcherConfigSync = optionalString (cfg.dispatcher.configFile != null) ''
|
||||||
|
install -m 400 ${lib.escapeShellArg cfg.dispatcher.configFile} ${lib.escapeShellArg dispatcherRuntimeConfig}
|
||||||
|
chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg dispatcherRuntimeConfig}
|
||||||
|
chmod 400 ${lib.escapeShellArg dispatcherRuntimeConfig}
|
||||||
|
'';
|
||||||
|
autoscalerConfigSync = optionalString (cfg.autoscaler.configFile != null) ''
|
||||||
|
install -m 400 ${lib.escapeShellArg cfg.autoscaler.configFile} ${lib.escapeShellArg autoscalerRuntimeConfig}
|
||||||
|
chown ${cfg.user}:${cfg.group} ${lib.escapeShellArg autoscalerRuntimeConfig}
|
||||||
|
chmod 400 ${lib.escapeShellArg autoscalerRuntimeConfig}
|
||||||
|
'';
|
||||||
|
|
||||||
|
dispatcherEnv =
|
||||||
|
cfg.extraEnv
|
||||||
|
// optionalAttrs (cfg.nscTokenFile != null) { NSC_TOKEN_FILE = nscTokenPath; }
|
||||||
|
// optionalAttrs (cfg.nscTokenSpecFile != null) { NSC_TOKEN_SPEC_FILE = cfg.nscTokenSpecFile; }
|
||||||
|
// optionalAttrs (cfg.nscEndpoint != null) { NSC_ENDPOINT = cfg.nscEndpoint; };
|
||||||
|
in {
|
||||||
|
options.services.burrow.forgejoNsc = {
|
||||||
|
enable = mkEnableOption "Forgejo Namespace Cloud runner dispatcher";
|
||||||
|
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "forgejo-nsc";
|
||||||
|
description = "System user that runs the forgejo-nsc services.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "forgejo-nsc";
|
||||||
|
description = "System group for the forgejo-nsc services.";
|
||||||
|
};
|
||||||
|
|
||||||
|
stateDir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/var/lib/forgejo-nsc";
|
||||||
|
description = "State directory for the dispatcher/autoscaler.";
|
||||||
|
};
|
||||||
|
|
||||||
|
nscTokenFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional NSC token file (exported as NSC_TOKEN_FILE).";
|
||||||
|
};
|
||||||
|
|
||||||
|
nscTokenSpecFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional NSC token spec file (exported as NSC_TOKEN_SPEC_FILE).";
|
||||||
|
};
|
||||||
|
|
||||||
|
nscEndpoint = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Optional NSC endpoint override (exported as NSC_ENDPOINT).";
|
||||||
|
};
|
||||||
|
|
||||||
|
extraEnv = mkOption {
|
||||||
|
type = types.attrsOf types.str;
|
||||||
|
default = { };
|
||||||
|
description = "Extra environment variables injected into the services.";
|
||||||
|
};
|
||||||
|
|
||||||
|
nscPackage = mkOption {
|
||||||
|
type = types.nullOr types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.nsc or null;
|
||||||
|
description = "Optional nsc CLI package added to the service PATH.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatcher = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Enable the forgejo-nsc dispatcher service.";
|
||||||
|
};
|
||||||
|
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-dispatcher;
|
||||||
|
description = "Package providing the forgejo-nsc dispatcher binary.";
|
||||||
|
};
|
||||||
|
|
||||||
|
configFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Host-local YAML config file for the dispatcher.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowPending = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Allow placeholder values (PENDING-) in the dispatcher config.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
autoscaler = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable the forgejo-nsc autoscaler service.";
|
||||||
|
};
|
||||||
|
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.forgejo-nsc-autoscaler;
|
||||||
|
description = "Package providing the forgejo-nsc autoscaler binary.";
|
||||||
|
};
|
||||||
|
|
||||||
|
configFile = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Host-local YAML config file for the autoscaler.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowPending = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Allow placeholder values (PENDING-) in the autoscaler config.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = (!cfg.dispatcher.enable) || cfg.dispatcher.configFile != null;
|
||||||
|
message = "services.burrow.forgejoNsc.dispatcher.configFile must be set when the dispatcher is enabled.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = (!cfg.autoscaler.enable) || cfg.autoscaler.configFile != null;
|
||||||
|
message = "services.burrow.forgejoNsc.autoscaler.configFile must be set when the autoscaler is enabled.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = { };
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
uid = mkDefault 2011;
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
description = "Forgejo Namespace Cloud runner services";
|
||||||
|
home = cfg.stateDir;
|
||||||
|
createHome = true;
|
||||||
|
shell = pkgs.bashInteractive;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = mkAfter [
|
||||||
|
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.forgejo-nsc-dispatcher = mkIf cfg.dispatcher.enable {
|
||||||
|
description = "Forgejo Namespace Cloud dispatcher";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
unitConfig.ConditionPathExists =
|
||||||
|
optional (cfg.dispatcher.configFile != null) cfg.dispatcher.configFile
|
||||||
|
++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile;
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
WorkingDirectory = cfg.stateDir;
|
||||||
|
ExecStart = "${cfg.dispatcher.package}/bin/forgejo-nsc-dispatcher --config ${dispatcherRuntimeConfig}";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
};
|
||||||
|
path = lib.optional (cfg.nscPackage != null) cfg.nscPackage;
|
||||||
|
environment = dispatcherEnv;
|
||||||
|
preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [
|
||||||
|
(optionalString (!cfg.dispatcher.allowPending) (pendingCheck cfg.dispatcher.configFile))
|
||||||
|
dispatcherConfigSync
|
||||||
|
tokenSync
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.forgejo-nsc-autoscaler = mkIf cfg.autoscaler.enable {
|
||||||
|
description = "Forgejo Namespace Cloud autoscaler";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network-online.target" "forgejo-nsc-dispatcher.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
unitConfig.ConditionPathExists =
|
||||||
|
optional (cfg.autoscaler.configFile != null) cfg.autoscaler.configFile
|
||||||
|
++ optional (cfg.nscTokenFile != null) cfg.nscTokenFile;
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
WorkingDirectory = cfg.stateDir;
|
||||||
|
ExecStart = "${cfg.autoscaler.package}/bin/forgejo-nsc-autoscaler --config ${autoscalerRuntimeConfig}";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
};
|
||||||
|
path = lib.optional (cfg.nscPackage != null) cfg.nscPackage;
|
||||||
|
environment = dispatcherEnv;
|
||||||
|
preStart = lib.concatStringsSep "\n" (lib.filter (s: s != "") [
|
||||||
|
(optionalString (!cfg.autoscaler.allowPending) (pendingCheck cfg.autoscaler.configFile))
|
||||||
|
autoscalerConfigSync
|
||||||
|
tokenSync
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -17,14 +17,6 @@ service Networks {
|
||||||
rpc NetworkDelete (NetworkDeleteRequest) returns (Empty);
|
rpc NetworkDelete (NetworkDeleteRequest) returns (Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
service TailnetControl {
|
|
||||||
rpc Discover (TailnetDiscoverRequest) returns (TailnetDiscoverResponse);
|
|
||||||
rpc Probe (TailnetProbeRequest) returns (TailnetProbeResponse);
|
|
||||||
rpc LoginStart (TailnetLoginStartRequest) returns (TailnetLoginStatusResponse);
|
|
||||||
rpc LoginStatus (TailnetLoginStatusRequest) returns (TailnetLoginStatusResponse);
|
|
||||||
rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
message NetworkReorderRequest {
|
message NetworkReorderRequest {
|
||||||
int32 id = 1;
|
int32 id = 1;
|
||||||
int32 index = 2;
|
int32 index = 2;
|
||||||
|
|
@ -64,57 +56,6 @@ message Empty {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message TailnetDiscoverRequest {
|
|
||||||
string email = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TailnetDiscoverResponse {
|
|
||||||
string domain = 1;
|
|
||||||
string authority = 2;
|
|
||||||
string oidc_issuer = 3;
|
|
||||||
bool managed = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TailnetProbeRequest {
|
|
||||||
string authority = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TailnetProbeResponse {
|
|
||||||
string authority = 1;
|
|
||||||
int32 status_code = 2;
|
|
||||||
string summary = 3;
|
|
||||||
string detail = 4;
|
|
||||||
bool reachable = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TailnetLoginStartRequest {
|
|
||||||
string account_name = 1;
|
|
||||||
string identity_name = 2;
|
|
||||||
string hostname = 3;
|
|
||||||
string authority = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TailnetLoginStatusRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TailnetLoginCancelRequest {
|
|
||||||
string session_id = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TailnetLoginStatusResponse {
|
|
||||||
string session_id = 1;
|
|
||||||
string backend_state = 2;
|
|
||||||
string auth_url = 3;
|
|
||||||
bool running = 4;
|
|
||||||
bool needs_login = 5;
|
|
||||||
string tailnet_name = 6;
|
|
||||||
string magic_dns_suffix = 7;
|
|
||||||
string self_dns_name = 8;
|
|
||||||
repeated string tailnet_ips = 9;
|
|
||||||
repeated string health = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum State {
|
enum State {
|
||||||
Stopped = 0;
|
Stopped = 0;
|
||||||
Running = 1;
|
Running = 1;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
let
|
let
|
||||||
conradev = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBueQxNbP2246pxr/m7au4zNVm+ShC96xuOcfEcpIjWZ";
|
|
||||||
contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa";
|
contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa";
|
||||||
agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net";
|
agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net";
|
||||||
burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l";
|
burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l";
|
||||||
|
|
@ -8,14 +7,11 @@ let
|
||||||
agent
|
agent
|
||||||
burrowForgeHost
|
burrowForgeHost
|
||||||
];
|
];
|
||||||
uiTestRecipients = burrowForgeRecipients ++ [ conradev ];
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
"secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients;
|
|
||||||
"secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
"secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
||||||
"secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IHRibTVDdyBSVUdQ
|
|
||||||
Tko1VEZzRDZReWt0T0dxaW56Y1Vaa2xlYjF2YjNJbVA5S1R0bUNjCjV4S0FOOE0z
|
|
||||||
UUcxRHhGN0orRXhLWFZEMnduK0dJQ1VGNnEybXkzVzgvZlEKLT4gc3NoLWVkMjU1
|
|
||||||
MTkgdXg0TjhRIHFBR1B1alJMek1iOVhnVFNOZ2M0bktITU1FWTBEY1JVQUFDRmNO
|
|
||||||
b1NmekEKUjM1VDEyaUlIYUsvUXFnVTlDWDNvMW50Z2R6blZEM3IzdmtOblRXZTAw
|
|
||||||
UQotPiBzc2gtZWQyNTUxOSBJclptQWcgMEZoSW9ZdDZpeS8zVHdyK3BsNTloaFUy
|
|
||||||
aExDVGkxMFEzOFNKYURVQXB3NApTY1cxTlVGb2tXRWhySXZFYnBMdzJraCsweHQv
|
|
||||||
ZUlRTkQ3VlZiL0NqOTJzCi0+IFgyNTUxOSBHckgvbjN4NkFqN2tDREliU2RYRlRn
|
|
||||||
Yk5aWUtialplcWZ2b2ZCTDc1VUZZCkh1cFF4TkZmUjJmTTlvWjBsbHZiYVlmVE5K
|
|
||||||
Q29FOHhUd0NuK1dRTXhEWUkKLS0tIG5rWkdSa2VhT3c0Y2dwTW45c3o5YTBmR3Fx
|
|
||||||
cHNkeVB6NU5ScGd5bC9hcncKkd74xLid/7HzY7vVQmcrAO7Y/PRMjE6Tvd1VCf4W
|
|
||||||
T0nt+3kJHshawMTa2Lnjwp1bG2j6THJWhXp5vKxde1s1mg==
|
|
||||||
-----END AGE ENCRYPTED FILE-----
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
age-encryption.org/v1
|
|
||||||
-> ssh-ed25519 ux4N8Q KfvLMiH7JHE6v74Pp//SqzBP8WU1MNy1/EcqsONTTQQ
|
|
||||||
Y6SFXWe/5Pru6+3vU6e67bRZDWDkukdfgEX7uQjB4Uw
|
|
||||||
-> ssh-ed25519 IrZmAg AFn7BP4FktUYH9QvNJPVDdNcEpJjYqmOrisvX9XGV08
|
|
||||||
Zho+KNtk1vUQZ55j1xUHdswAj0T0Soji/HC6p1tsVcA
|
|
||||||
-> X25519 sv50iZjBijWKfp6I+LfRlEJ2sqnj5/2m0hRWz5NqLTk
|
|
||||||
Hdfvo+87zemSCFWDSlzkpmvHLuvc0tjxEt0ociTPrCg
|
|
||||||
--- BkQd4O2m/i98rlBcNhczU6Wj0htoiNLQDn0W6yKn1/c
|
|
||||||
aºªîº¿"ÁWÓLØï€§\š#ŸzDæö“ÿðRq6.¹ç«‚Òæ}#8²kâoÜyq>ÂLǸ–ñ<E28093>\`wÆ”õ>f/ïñƒÈ®·Ñ´ý^,#
|
|
||||||
hD<>]C
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue