diff --git a/.forgejo/workflows/lint-governance.yml b/.forgejo/workflows/lint-governance.yml deleted file mode 100644 index 490702e..0000000 --- a/.forgejo/workflows/lint-governance.yml +++ /dev/null @@ -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 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index 3d1e92a..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/lint-governance.yml b/.github/workflows/lint-governance.yml deleted file mode 100644 index 08b665c..0000000 --- a/.github/workflows/lint-governance.yml +++ /dev/null @@ -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 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 0ca7ced..0000000 --- a/AGENTS.md +++ /dev/null @@ -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. diff --git a/Apple/AppUITests/BurrowUITests.swift b/Apple/AppUITests/BurrowUITests.swift deleted file mode 100644 index f9dbeae..0000000 --- a/Apple/AppUITests/BurrowUITests.swift +++ /dev/null @@ -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) - } -} diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 83d32e0..9897f79 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 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 */; }; 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 */; }; @@ -50,13 +49,6 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - D11000022F70000100112233 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; - remoteInfo = App; - }; D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; @@ -138,9 +130,6 @@ /* Begin PBXFileReference section */ D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 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 = ""; }; - D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -193,13 +182,6 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - D11000062F70000100112233 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F65029E4A697002790F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -261,7 +243,6 @@ D0D4E4F72C8D941D007F820A /* Framework.xcconfig */, D020F64029E4A1FF002790F6 /* Compiler.xcconfig */, D0D4E4F62C8D932D007F820A /* Debug.xcconfig */, - D11000052F70000100112233 /* UITests.xcconfig */, D04A3E1D2BAF465F0043EC85 /* Version.xcconfig */, D020F64229E4A1FF002790F6 /* Info.plist */, D0D4E5912C8D9D0A007F820A /* Constants */, @@ -287,7 +268,6 @@ isa = PBXGroup; children = ( D05B9F7429E39EEC008CB1F9 /* App */, - D11000072F70000100112233 /* AppUITests */, D020F65629E4A697002790F6 /* NetworkExtension */, D0D4E49C2C8D921A007F820A /* Core */, D0D4E4AD2C8D921A007F820A /* UI */, @@ -301,7 +281,6 @@ isa = PBXGroup; children = ( D05B9F7229E39EEC008CB1F9 /* Burrow.app */, - D11000032F70000100112233 /* BurrowUITests.xctest */, D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */, D0BCC6032A09535900AD070D /* libburrow.a */, D0D4E5312C8D996F007F820A /* BurrowCore.framework */, @@ -324,14 +303,6 @@ path = App; sourceTree = ""; }; - D11000072F70000100112233 /* AppUITests */ = { - isa = PBXGroup; - children = ( - D11000042F70000100112233 /* BurrowUITests.swift */, - ); - path = AppUITests; - sourceTree = ""; - }; D0B98FD729FDDB57004E7149 /* libburrow */ = { isa = PBXGroup; children = ( @@ -404,24 +375,6 @@ /* End PBXGroup 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 */ = { isa = PBXNativeTarget; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; @@ -537,10 +490,6 @@ LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { - D11000082F70000100112233 = { - CreatedOnToolsVersion = 16.0; - TestTargetID = D05B9F7129E39EEC008CB1F9; - }; D020F65229E4A697002790F6 = { CreatedOnToolsVersion = 14.3; }; @@ -573,7 +522,6 @@ projectRoot = ""; targets = ( D05B9F7129E39EEC008CB1F9 /* App */, - D11000082F70000100112233 /* BurrowUITests */, D020F65229E4A697002790F6 /* NetworkExtension */, D0D4E5502C8D9BF2007F820A /* UI */, D0D4E5302C8D996F007F820A /* Core */, @@ -583,13 +531,6 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - D11000092F70000100112233 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D05B9F7029E39EEC008CB1F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -653,14 +594,6 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - D110000A2F70000100112233 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D11000012F70000100112233 /* BurrowUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F64F29E4A697002790F6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -719,11 +652,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - D110000B2F70000100112233 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D05B9F7129E39EEC008CB1F9 /* App */; - targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; - }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; @@ -766,20 +694,6 @@ /* End PBXTargetDependency 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 */ = { isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; @@ -867,15 +781,6 @@ /* End XCBuildConfiguration 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" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme index f580ea7..a524e87 100644 --- a/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/Apple/Burrow.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -28,20 +28,7 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "NO"> - - - - - - + shouldAutocreateTestPlan = "YES"> (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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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: [] - ) - } -} diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index 2128ec3..b4fa7d8 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,9 +1,7 @@ +import AuthenticationServices import BurrowConfiguration import Foundation import SwiftUI -#if canImport(AuthenticationServices) -import AuthenticationServices -#endif #if canImport(UIKit) import UIKit #elseif canImport(AppKit) @@ -206,7 +204,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: .wireGuard case .tor: .tor - case .tailnet: .tailnet + case .tailnet: .headscale } } @@ -276,7 +274,6 @@ private struct QuickAddButton: View { } .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) } - .accessibilityIdentifier("quick-add-\(sheet.rawValue)") .buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18)) } } @@ -288,12 +285,13 @@ private struct AccountDraft { var wireGuardConfig = "" var discoveryEmail = "" + var tailnetProvider: TailnetProvider = .tailscale var authority = "" var tailnet = "" var hostname = ProcessInfo.processInfo.hostName var username = "" var secret = "" - var authMode: AccountAuthMode = .none + var authMode: AccountAuthMode = .web var torAddresses = "100.64.0.2/32" var torDNS = "1.1.1.1, 1.0.0.1" @@ -313,13 +311,13 @@ private struct AccountDraft { accountName = "default" identityName = "apple" authority = TailnetProvider.tailscale.defaultAuthority ?? "" - authMode = .web } } } private struct ConfigurationSheetView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.webAuthenticationSession) private var webAuthenticationSession let sheet: ConfigurationSheet let networkViewModel: NetworkViewModel @@ -328,21 +326,17 @@ private struct ConfigurationSheetView: View { @State private var draft: AccountDraft @State private var isSubmitting = false @State private var errorMessage: String? + @State private var loginSessionID: String? + @State private var loginStatus: TailnetLoginStatus? @State private var discoveryStatus: TailnetDiscoveryResponse? @State private var discoveryError: String? @State private var isDiscoveringTailnet = false @State private var authorityProbeStatus: TailnetAuthorityProbeStatus? @State private var authorityProbeError: String? @State private var isProbingAuthority = false - @State private var tailnetLoginStatus: TailnetLoginStatus? - @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? + @State private var pollingTask: Task? @State private var didRunAutomation = false + @State private var webAuthenticationTask: Task? init( sheet: ConfigurationSheet, @@ -410,10 +404,7 @@ private struct ConfigurationSheetView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - Task { @MainActor in - await cancelTailnetLoginIfNeeded() - dismiss() - } + dismiss() } } #if os(iOS) @@ -456,26 +447,19 @@ private struct ConfigurationSheetView: View { .onAppear { runAutomationIfNeeded() } + .onChange(of: draft.tailnetProvider) { _, _ in + resetAuthorityProbe() + } .onChange(of: draft.authority) { _, _ in resetAuthorityProbe() } .onChange(of: draft.discoveryEmail) { _, _ in resetTailnetDiscoveryFeedback() } - .onChange(of: draft.authMode) { _, newMode in - guard newMode != .web else { return } - Task { @MainActor in - await cancelTailnetLoginIfNeeded() - } - } .onDisappear { - tailnetLoginPollTask?.cancel() - browserAuthenticator.cancel() - if !preserveTailnetLoginSession { - Task { @MainActor in - await cancelTailnetLoginIfNeeded() - } - } + pollingTask?.cancel() + webAuthenticationTask?.cancel() + webAuthenticationTask = nil } } @@ -483,10 +467,10 @@ private struct ConfigurationSheetView: View { private var tailnetSections: some View { Section("Connection") { TextField("Email address", text: $draft.discoveryEmail) - .burrowEmailField() + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) .burrowLoginField() .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-discovery-email") Button { discoverTailnetAuthority() @@ -499,7 +483,6 @@ private struct ConfigurationSheetView: View { } .buttonStyle(.borderless) .disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil) - .accessibilityIdentifier("tailnet-find-server") if let discoveryStatus { tailnetDiscoveryCard(status: discoveryStatus, failure: nil) @@ -507,82 +490,78 @@ private struct ConfigurationSheetView: View { tailnetDiscoveryCard(status: nil, failure: discoveryError) } - TextField("Authority URL", text: $draft.authority) - .burrowLoginField() - .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-authority") - - Text("Use the managed Tailnet authority or enter a custom Tailnet control server.") - .font(.footnote) - .foregroundStyle(.secondary) - - Button { - probeTailnetAuthority() - } label: { - Label { - Text(isProbingAuthority ? "Checking Connection" : "Check Connection") - } icon: { - Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle") + Picker( + "Provider", + selection: Binding( + get: { draft.tailnetProvider }, + set: { applyTailnetProvider($0) } + ) + ) { + ForEach(TailnetProvider.allCases) { provider in + Text(provider.title).tag(provider) } } - .buttonStyle(.borderless) - .disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil) - .accessibilityIdentifier("tailnet-check-connection") + .pickerStyle(.menu) - if let authorityProbeStatus { - tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil) - } else if let authorityProbeError { - tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError) + tailnetProviderCard + + if draft.tailnetProvider.requiresControlURL { + 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) .burrowLoginField() .autocorrectionDisabled() - .accessibilityIdentifier("tailnet-name") } Section("Authentication") { - Picker("Authentication", selection: $draft.authMode) { - ForEach(availableTailnetAuthModes) { mode in - 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) - } + if tailnetUsesWebLogin { + tailnetWebLoginCard } else { TextField("Username", text: $draft.username) .burrowLoginField() .autocorrectionDisabled() + Picker("Authentication", selection: $draft.authMode) { + ForEach(availableTailnetAuthModes) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.menu) if draft.authMode != .none { SecureField( draft.authMode == .password ? "Password" : "Preauth Key", 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 { HStack(spacing: 8) { - summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom") - summaryBadge(draft.authMode.title) - if tailnetLoginStatus?.running == true { - summaryBadge("Signed In") - } + summaryBadge(draft.tailnetProvider.title) + summaryBadge( + tailnetUsesWebLogin ? "Web Sign-In" : draft.authMode.title + ) } } } @@ -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( status: TailnetAuthorityProbeStatus?, failure: String? @@ -680,7 +731,6 @@ private struct ConfigurationSheetView: View { RoundedRectangle(cornerRadius: 16) .fill(.thinMaterial) ) - .accessibilityIdentifier("tailnet-authority-probe-card") } private func tailnetDiscoveryCard( @@ -689,15 +739,12 @@ private struct ConfigurationSheetView: View { ) -> some View { VStack(alignment: .leading, spacing: 6) { if let status { - Text("Discovered Tailnet Server") + Text("Discovered \(status.provider.title)") .font(.subheadline.weight(.medium)) Text(status.authority) .font(.footnote.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) - Text(status.provider == .tailscale ? "Managed authority" : "Custom authority") - .font(.footnote) - .foregroundStyle(.secondary) if let oidcIssuer = status.oidcIssuer { Text("OIDC: \(oidcIssuer)") .font(.footnote) @@ -719,54 +766,6 @@ private struct ConfigurationSheetView: View { RoundedRectangle(cornerRadius: 16) .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 { @@ -827,8 +826,12 @@ private struct ConfigurationSheetView: View { } case .tailnet: - Button("Use Tailscale Managed Server") { - applyTailnetDefaults(for: .tailscale) + Menu("Provider") { + ForEach(TailnetProvider.allCases) { provider in + Button(provider.title) { + applyTailnetProvider(provider) + } + } } if availableTailnetAuthModes.count > 1 { @@ -836,7 +839,7 @@ private struct ConfigurationSheetView: View { ForEach(availableTailnetAuthModes) { mode in Button(mode.title) { draft.authMode = mode - if mode == .none { + if mode == .none || mode == .web { draft.secret = "" } } @@ -844,8 +847,8 @@ private struct ConfigurationSheetView: View { } } - Button("Clear Discovery Result") { - resetTailnetDiscoveryFeedback() + Button("Restore Provider Defaults") { + 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 { #if os(iOS) true @@ -906,6 +920,9 @@ private struct ConfigurationSheetView: View { case .tor: return "Save Account" case .tailnet: + if tailnetUsesWebLogin { + return loginStatus?.running == true ? "Save Account" : "Start Sign-In" + } return "Save Account" } } @@ -920,11 +937,11 @@ private struct ConfigurationSheetView: View { if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil { return true } - if normalizedOptional(draft.authority) == nil { + if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil { return true } - if draft.authMode == .web { - return tailnetLoginStatus?.running != true + if tailnetUsesWebLogin { + return false } if draft.authMode != .none && normalizedOptional(draft.secret) == nil { return true @@ -1010,13 +1027,41 @@ private struct ConfigurationSheetView: View { } 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) - preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true try await saveTailnetAccount(secret: secret, username: username) 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() { guard !didRunAutomation, sheet == .tailnet, @@ -1035,19 +1080,79 @@ private struct ConfigurationSheetView: View { Task { @MainActor in switch automation.action { case .tailnetLogin: - applyTailnetDefaults(for: .tailscale) - startTailnetLogin() + draft.tailnetProvider = .tailscale + do { + try await startTailnetLogin() + } catch { + errorMessage = error.localizedDescription + } case .headscaleProbe: + applyTailnetProvider(.headscale) draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority 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 { - let provider = inferredTailnetProvider + let provider = draft.tailnetProvider let title = titleOrFallback( - hostnameFallback(from: draft.authority, fallback: "Tailnet") + hostnameFallback( + from: tailnetUsesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority, + fallback: provider.title + ) ) let payload = TailnetNetworkPayload( @@ -1055,17 +1160,21 @@ private struct ConfigurationSheetView: View { authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""), account: normalized(draft.accountName, fallback: "default"), identity: normalized(draft.identityName, fallback: "apple"), - tailnet: normalizedOptional(draft.tailnet), + tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet), hostname: normalizedOptional(draft.hostname) ) var noteParts: [String] = [ - isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet", - "Auth: \(draft.authMode.title)", + provider.title, + tailnetUsesWebLogin + ? "State: \(loginStatus?.backendState ?? "NeedsLogin")" + : "Auth: \(draft.authMode.title)", ] - - if draft.authMode == .web, tailnetLoginStatus?.running == true { - noteParts.append("Browser sign-in complete") + if let dnsName = loginStatus?.selfDNSName { + noteParts.append("Device: \(dnsName)") + } + if let magicDNSSuffix = loginStatus?.magicDNSSuffix { + noteParts.append("MagicDNS: \(magicDNSSuffix)") } do { @@ -1077,7 +1186,7 @@ private struct ConfigurationSheetView: View { let record = NetworkAccountRecord( id: UUID(), - kind: .tailnet, + kind: .headscale, title: title, authority: payload.authority, provider: provider, @@ -1086,7 +1195,7 @@ private struct ConfigurationSheetView: View { hostname: payload.hostname, username: username, tailnet: payload.tailnet, - authMode: draft.authMode, + authMode: tailnetUsesWebLogin ? .web : draft.authMode, note: noteParts.joined(separator: " • "), createdAt: .now, updatedAt: .now @@ -1117,44 +1226,33 @@ private struct ConfigurationSheetView: View { draft.torListen = defaults.torListen } - private func applyTailnetDefaults(for provider: TailnetProvider) { + private func applyTailnetProvider(_ provider: TailnetProvider) { resetTailnetDiscoveryFeedback() - draft.authority = provider.defaultAuthority ?? "" - if !availableTailnetAuthModes.contains(draft.authMode) { - draft.authMode = .web - } + draft.tailnetProvider = provider + applyTailnetDefaults(for: provider) } - private func startTailnetLogin() { - guard let authority = normalizedOptional(draft.authority) else { - tailnetLoginStatus = nil - tailnetLoginError = "Enter a server URL first." - return - } - - isStartingTailnetLogin = true - tailnetLoginError = nil - preserveTailnetLoginSession = false - - Task { @MainActor in - defer { isStartingTailnetLogin = false } - do { - let status = try await networkViewModel.startTailnetLogin( - 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 applyTailnetDefaults(for provider: TailnetProvider) { + draft.authority = provider.defaultAuthority ?? "" + loginStatus = nil + loginSessionID = nil + pollingTask?.cancel() + if provider == .tailscale { + draft.authMode = .web + draft.username = "" + draft.secret = "" + } else { + if !availableTailnetAuthModes.contains(draft.authMode) { + draft.authMode = provider.supportsWebLogin ? .web : .none + } + if draft.authMode == .web && !provider.supportsWebLogin { + draft.authMode = .none } } } private func probeTailnetAuthority() { + guard draft.tailnetProvider.requiresControlURL else { return } guard let authority = normalizedOptional(draft.authority) else { authorityProbeStatus = nil authorityProbeError = "Enter a server URL first." @@ -1168,7 +1266,10 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isProbingAuthority = false } do { - authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority) + authorityProbeStatus = try await TailnetAuthorityProbeClient.probe( + provider: draft.tailnetProvider, + authority: authority + ) } catch { authorityProbeError = error.localizedDescription } @@ -1178,7 +1279,6 @@ private struct ConfigurationSheetView: View { private func resetAuthorityProbe() { authorityProbeStatus = nil authorityProbeError = nil - tailnetLoginError = nil } private func resetTailnetDiscoveryFeedback() { @@ -1200,9 +1300,15 @@ private struct ConfigurationSheetView: View { Task { @MainActor in defer { isDiscoveringTailnet = false } do { - let discovery = try await networkViewModel.discoverTailnet(email: email) + let discovery = try await TailnetDiscoveryClient.discover(email: email) discoveryStatus = discovery + draft.tailnetProvider = discovery.provider draft.authority = discovery.authority + if discovery.provider.supportsWebLogin, discovery.oidcIssuer != nil { + draft.authMode = .web + draft.username = "" + draft.secret = "" + } probeTailnetAuthority() } catch { 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() { guard let clipboardString else { return } draft.wireGuardConfig = clipboardString @@ -1325,40 +1361,19 @@ private struct ConfigurationSheetView: View { return host } + private var tailnetUsesWebLogin: Bool { + draft.authMode == .web && draft.tailnetProvider.supportsWebLogin + } + private var availableTailnetAuthModes: [AccountAuthMode] { - [.web, .none, .password, .preauthKey] - } - - private var tailnetSignInActionTitle: String { - if tailnetLoginStatus?.running == true { - return "Signed In" + switch draft.tailnetProvider { + case .tailscale: + [.web] + case .headscale: + [.web, .none, .password, .preauthKey] + 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 @@ -1454,65 +1469,8 @@ private extension View { self #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 { enum Action: String { case tailnetLogin = "tailnet-login" diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 32f0b8c..9a534ce 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -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 { var domain: String var provider: TailnetProvider @@ -33,6 +40,23 @@ struct TailnetDiscoveryResponse: Codable, Sendable { 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 { var authority: String var statusCode: Int @@ -40,102 +64,147 @@ struct TailnetAuthorityProbeStatus: Sendable { var detail: String? } -struct TailnetLoginStatus: Sendable { - var sessionID: String - var backendState: String - var authURL: URL? - var running: Bool - var needsLogin: Bool - var tailnetName: String? - var magicDNSSuffix: String? - var selfDNSName: String? - var tailnetIPs: [String] - var health: [String] +enum TailnetBridgeClient { + private static let baseURL = URL(string: "http://127.0.0.1:8080")! + + static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse { + var urlRequest = URLRequest( + url: baseURL.appendingPathComponent("v1/tailscale/login/start") + ) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + 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 { - static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { - var request = Burrow_TailnetDiscoverRequest() - request.email = email + private static let baseURL = URL(string: "http://127.0.0.1:8080")! - let response = try await TailnetClient.unix(socketURL: socketURL).discover(request) - return TailnetDiscoveryResponse( - domain: response.domain, - provider: response.managed ? .tailscale : .headscale, - authority: response.authority, - oidcIssuer: response.oidcIssuer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil - : response.oidcIssuer - ) + static func discover(email: String) async throws -> TailnetDiscoveryResponse { + guard var components = URLComponents( + url: baseURL.appendingPathComponent("v1/tailnet/discover"), + resolvingAgainstBaseURL: false + ) else { + throw URLError(.badURL) + } + 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 { - static func probe(authority: String, socketURL: URL) async throws -> TailnetAuthorityProbeStatus { - var request = Burrow_TailnetProbeRequest() - request.authority = authority + static func probe(provider: TailnetProvider, authority: String) async throws -> TailnetAuthorityProbeStatus { + let normalizedAuthority = normalizeAuthority(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( - authority: response.authority, - statusCode: Int(response.statusCode), - summary: response.summary, - detail: response.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil - : response.detail + authority: normalizedAuthority, + statusCode: http.statusCode, + summary: "\(provider.title) reachable", + detail: 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 { - static func start( - 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) - } +enum TailnetBridgeError: LocalizedError { + case server(String) - static func status(sessionID: String, socketURL: URL) async throws -> TailnetLoginStatus { - var request = Burrow_TailnetLoginStatusRequest() - request.sessionID = sessionID - let response = try await TailnetClient.unix(socketURL: socketURL).loginStatus(request) - 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 - ) + var errorDescription: String? { + switch self { + case .server(let message): + message + } } } @@ -146,7 +215,7 @@ final class NetworkViewModel: Sendable { private(set) var connectionError: String? private let socketURLResult: Result - @ObservationIgnored private var task: Task? + nonisolated(unsafe) private var task: Task? init(socketURLResult: Result) { self.socketURLResult = socketURLResult @@ -173,42 +242,6 @@ final class NetworkViewModel: Sendable { 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 { let socketURL = try socketURLResult.get() 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? { switch self { case .tailscale: @@ -322,44 +368,19 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { var subtitle: String { switch self { case .tailscale: - "Managed Tailnet authority." + "Use Tailscale's real browser login flow." case .headscale: - "Custom Tailnet control server." + "Use your Headscale control plane with browser or key-based sign-in." 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 { case wireGuard case tor - case tailnet + case headscale var id: String { rawValue } @@ -367,7 +388,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "WireGuard" case .tor: "Tor" - case .tailnet: "Tailnet" + case .headscale: "Tailnet" } } @@ -375,7 +396,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Import a tunnel and optional account metadata." 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 { case .wireGuard: .init("WireGuard") case .tor: .orange - case .tailnet: .mint + case .headscale: .mint } } @@ -391,7 +412,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { switch self { case .wireGuard: "Add Network" case .tor: "Save Account" - case .tailnet: "Save Account" + case .headscale: "Save Account" } } @@ -401,15 +422,15 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { nil case .tor: "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." } } } enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { - case web case none + case web case password case preauthKey @@ -417,8 +438,8 @@ enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { - case .web: "Browser Sign-In" case .none: "None" + case .web: "Web Login" case .password: "Password" case .preauthKey: "Preauth Key" } @@ -444,15 +465,17 @@ struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable { struct TailnetCard { var id: Int32 + var provider: String var title: String var detail: String init(network: Burrow_Network) { let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload)) id = network.id + provider = payload?.provider.title ?? "Tailnet" title = payload?.tailnet ?? payload?.hostname ?? "Tailnet" detail = [ - payload?.authority.flatMap { URL(string: $0)?.host } ?? payload?.authority, + payload?.provider.title, payload?.authority, payload.map { "Account: \($0.account)" }, ] @@ -469,7 +492,7 @@ struct TailnetCard { VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { - Text("Tailnet") + Text(provider) .font(.headline) .foregroundStyle(.white.opacity(0.85)) Text(title) diff --git a/Makefile b/Makefile index 1a0488c..f927f5f 100644 --- a/Makefile +++ b/Makefile @@ -10,12 +10,6 @@ check: build: @cargo build -bep-check: - @python3 Scripts/check-bep-metadata.py - -bep-list: - @Scripts/bep list - daemon-console: @$(sudo_cargo_console) daemon diff --git a/README.md b/README.md index ba4f50c..b8684c3 100644 --- a/README.md +++ b/README.md @@ -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: - [Constitution](./CONSTITUTION.md) -- [Agent Instructions](./AGENTS.md) - [Burrow Evolution](./evolution/README.md) - [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.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). -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: ``` diff --git a/Scripts/authentik-sync-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh index 277c5f4..656b738 100644 --- a/Scripts/authentik-sync-burrow-directory.sh +++ b/Scripts/authentik-sync-burrow-directory.sh @@ -116,7 +116,7 @@ lookup_user_pk() { ensure_user() { 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 username="$(printf '%s\n' "$user_spec" | jq -r '.username')" @@ -124,7 +124,6 @@ ensure_user() { email="$(printf '%s\n' "$user_spec" | jq -r '.email')" is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')" 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 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 exit 1 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() { diff --git a/Scripts/authentik-sync-tailnet-auth-flow.sh b/Scripts/authentik-sync-tailnet-auth-flow.sh deleted file mode 100755 index 1c715cc..0000000 --- a/Scripts/authentik-sync-tailnet-auth-flow.sh +++ /dev/null @@ -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}." diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh deleted file mode 100755 index 54564ad..0000000 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ /dev/null @@ -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})." diff --git a/Scripts/bep b/Scripts/bep deleted file mode 100755 index 1c6bd64..0000000 --- a/Scripts/bep +++ /dev/null @@ -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 ] List BEPs, optionally filtered by status. - open 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 diff --git a/Scripts/check-bep-metadata.py b/Scripts/check-bep-metadata.py deleted file mode 100755 index d054934..0000000 --- a/Scripts/check-bep-metadata.py +++ /dev/null @@ -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()) diff --git a/Scripts/ci/build-release-artifacts.sh b/Scripts/ci/build-release-artifacts.sh deleted file mode 100755 index 20b4c06..0000000 --- a/Scripts/ci/build-release-artifacts.sh +++ /dev/null @@ -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" diff --git a/Scripts/ci/ensure-nix.sh b/Scripts/ci/ensure-nix.sh deleted file mode 100755 index 14be895..0000000 --- a/Scripts/ci/ensure-nix.sh +++ /dev/null @@ -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 -} diff --git a/Scripts/ci/publish-forgejo-release.sh b/Scripts/ci/publish-forgejo-release.sh deleted file mode 100755 index 338f71b..0000000 --- a/Scripts/ci/publish-forgejo-release.sh +++ /dev/null @@ -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 diff --git a/Scripts/run-ios-tailnet-ui-tests.sh b/Scripts/run-ios-tailnet-ui-tests.sh deleted file mode 100755 index 5086bd1..0000000 --- a/Scripts/run-ios-tailnet-ui-tests.sh +++ /dev/null @@ -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 diff --git a/Scripts/run-tailnet-connectivity-smoke.sh b/Scripts/run-tailnet-connectivity-smoke.sh deleted file mode 100755 index f3053d3..0000000 --- a/Scripts/run-tailnet-connectivity-smoke.sh +++ /dev/null @@ -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}" diff --git a/Tools/tailscale-login-bridge/main.go b/Tools/tailscale-login-bridge/main.go index 877d0e4..82ca9b0 100644 --- a/Tools/tailscale-login-bridge/main.go +++ b/Tools/tailscale-login-bridge/main.go @@ -2,26 +2,17 @@ package main import ( "context" - "encoding/binary" "encoding/json" - "errors" "flag" "fmt" - "io" "log" "net" - "net/netip" "net/http" "os" - "strconv" - "sync" "time" - "github.com/tailscale/wireguard-go/tun" "tailscale.com/client/local" "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tailcfg" "tailscale.com/tsnet" ) @@ -35,123 +26,13 @@ type statusResponse struct { SelfDNSName string `json:"self_dns_name,omitempty"` TailscaleIPs []string `json:"tailscale_ips,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() { listen := flag.String("listen", "127.0.0.1:0", "local listen address") stateDir := flag.String("state-dir", "", "persistent state directory") hostname := flag.String("hostname", "burrow-apple", "tailnet hostname") 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() if *stateDir == "" { @@ -167,24 +48,6 @@ func main() { Hostname: *hostname, 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 != "" { server.ControlURL = *controlURL } @@ -198,7 +61,6 @@ func main() { if err != nil { log.Fatalf("local client: %v", err) } - state := &helperState{} ln, err := net.Listen("tcp", *listen) if err != nil { @@ -206,27 +68,12 @@ func main() { } defer ln.Close() - if packetListener != nil { - 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) - } + fmt.Printf("{\"listen_addr\":%q}\n", ln.Addr().String()) _ = os.Stdout.Sync() mux := http.NewServeMux() 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 { http.Error(w, err.Error(), http.StatusBadGateway) return @@ -234,40 +81,6 @@ func main() { w.Header().Set("content-type", "application/json") _ = 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) { w.WriteHeader(http.StatusNoContent) go func() { @@ -283,110 +96,16 @@ func main() { log.Fatal(httpServer.Serve(ln)) } -func servePacketBridge(listener net.Listener, device *chanTUN) { - for { - 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) +func snapshot(ctx context.Context, localClient *local.Client) (*statusResponse, error) { + status, err := localClient.StatusWithoutPeers(ctx) if err != nil { return nil, err } - - authURL := status.AuthURL - if authURL == "" { - authURL = state.authURLSnapshot() - } - 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 (status.BackendState == ipn.NeedsLogin.String() || status.BackendState == ipn.NoState.String()) && status.AuthURL == "" { + if err := localClient.StartLoginInteractive(ctx); err != nil { + return nil, err + } + status, err = localClient.StatusWithoutPeers(ctx) if err != nil { return nil, err } @@ -394,7 +113,7 @@ func snapshot(ctx context.Context, localClient *local.Client, state *helperState response := &statusResponse{ BackendState: status.BackendState, - AuthURL: authURL, + AuthURL: status.AuthURL, Running: status.BackendState == ipn.Running.String(), NeedsLogin: status.BackendState == ipn.NeedsLogin.String(), 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 { 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 } - -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 - } - } -} diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index 55516e1..fbe1980 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -82,22 +82,11 @@ impl TailscaleBridgeManager { let key = session_key(&request.account_name, &request.identity_name); if let Some(existing) = self.sessions.lock().await.get(&key).cloned() { - match self.fetch_status(existing.as_ref()).await { - Ok(status) => { - return Ok(TailscaleLoginStartResponse { - session_id: existing.session_id.clone(), - 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 status = self.fetch_status(existing.as_ref()).await?; + return Ok(TailscaleLoginStartResponse { + session_id: existing.session_id.clone(), + status, + }); } let state_dir = state_root().join(session_dir_name(&request)); @@ -166,28 +155,11 @@ impl TailscaleBridgeManager { }; match session { - Some(session) => match self.fetch_status(session.as_ref()).await { - Ok(status) => Ok(Some(status)), - Err(err) => { - self.remove_session_by_id(session_id).await; - Err(err) - } - }, + Some(session) => self.fetch_status(session.as_ref()).await.map(Some), None => Ok(None), } } - pub async fn cancel(&self, session_id: &str) -> Result { - 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 { let mut last_error = None; let mut last_status = None; @@ -229,38 +201,6 @@ impl TailscaleBridgeManager { .await .context("invalid tailscale helper status response") } - - async fn remove_session_by_id(&self, session_id: &str) -> Option> { - 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 { @@ -309,10 +249,7 @@ fn state_root() -> PathBuf { .join("Burrow") .join("tailscale"); } - home.join(".local") - .join("share") - .join("burrow") - .join("tailscale") + home.join(".local").join("share").join("burrow").join("tailscale") } fn session_dir_name(request: &TailscaleLoginStartRequest) -> String { diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index 5fc7add..28b48bb 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -7,7 +7,6 @@ use super::TailnetProvider; pub const TAILNET_DISCOVERY_REL: &str = "https://burrow.net/rel/tailnet-control-server"; const TAILNET_DISCOVERY_PATH: &str = "/.well-known/burrow-tailnet"; const WEBFINGER_PATH: &str = "/.well-known/webfinger"; -const MANAGED_TAILSCALE_AUTHORITY: &str = "controlplane.tailscale.com"; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TailnetDiscovery { @@ -18,15 +17,6 @@ pub struct TailnetDiscovery { pub oidc_issuer: Option, } -#[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)] struct WebFingerDocument { #[serde(default)] @@ -53,63 +43,6 @@ pub async fn discover_tailnet(email: &str) -> Result { 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 { - 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( client: &Client, email: &str, @@ -124,7 +57,7 @@ pub async fn discover_tailnet_at( if let Some(authority) = discover_webfinger(client, email, base_url).await? { return Ok(TailnetDiscovery { domain, - provider: inferred_provider(Some(&authority), None), + provider: TailnetProvider::Headscale, authority, oidc_issuer: None, }); @@ -145,19 +78,6 @@ pub fn email_domain(email: &str) -> Result { 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> { let url = base_url .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> { - 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)] mod tests { use axum::{routing::get, Router}; @@ -258,13 +147,6 @@ mod tests { 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] async fn discovers_from_well_known_document() -> Result<()> { let router = Router::new().route( @@ -327,20 +209,4 @@ mod tests { server.abort(); 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(()) - } } diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 0a23ddc..1eb0629 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -13,20 +13,13 @@ use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, - Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, - State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, - TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, - TunnelStatusResponse, + networks_server::Networks, tunnel_server::Tunnel, Empty, Network, NetworkDeleteRequest, + NetworkListResponse, NetworkReorderRequest, State as RPCTunnelState, + TunnelConfigurationResponse, TunnelStatusResponse, }, runtime::{ActiveTunnel, ResolvedTunnel}, }; use crate::{ - auth::server::tailscale::{ - TailscaleBridgeManager, TailscaleLoginStartRequest as BridgeLoginStartRequest, - TailscaleLoginStatus, - }, - control::discovery, daemon::rpc::ServerConfig, database::{add_network, delete_network, get_connection, list_networks, reorder_network}, }; @@ -53,7 +46,6 @@ pub struct DaemonRPCServer { wg_state_chan: (watch::Sender, watch::Receiver), network_update_chan: (watch::Sender<()>, watch::Receiver<()>), active_tunnel: Arc>>, - tailnet_login: TailscaleBridgeManager, } impl DaemonRPCServer { @@ -64,7 +56,6 @@ impl DaemonRPCServer { wg_state_chan: watch::channel(RunState::Idle), network_update_chan: watch::channel(()), active_tunnel: Arc::new(RwLock::new(None)), - tailnet_login: TailscaleBridgeManager::default(), }) } @@ -136,11 +127,6 @@ impl DaemonRPCServer { Ok(()) } - - fn tailnet_control_url(authority: &str) -> Option { - let authority = discovery::normalize_authority(authority); - (!discovery::is_managed_tailscale_authority(&authority)).then_some(authority) - } } #[tonic::async_trait] @@ -280,101 +266,6 @@ impl Networks for DaemonRPCServer { } } -#[tonic::async_trait] -impl TailnetControl for DaemonRPCServer { - async fn discover( - &self, - request: Request, - ) -> Result, 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, - ) -> Result, 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, - ) -> Result, 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, - ) -> Result, 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, - ) -> Result, 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 { RspStatus::internal(err.to_string()) } @@ -392,21 +283,3 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse { 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, - } -} diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index 724e3bb..a016788 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -16,10 +16,7 @@ use tonic::transport::Server; use tracing::info; use crate::{ - daemon::rpc::grpc_defs::{ - networks_server::NetworksServer, tailnet_control_server::TailnetControlServer, - tunnel_server::TunnelServer, - }, + daemon::rpc::grpc_defs::{networks_server::NetworksServer, tunnel_server::TunnelServer}, database::get_connection, }; @@ -39,11 +36,9 @@ pub async fn daemon_main( let uds = UnixListener::bind(sock_path)?; let serve_job = tokio::spawn(async move { let uds_stream = UnixListenerStream::new(uds); - let tailnet_server = burrow_server.clone(); let _srv = Server::builder() .add_service(TunnelServer::new(burrow_server.clone())) .add_service(NetworksServer::new(burrow_server)) - .add_service(TailnetControlServer::new(tailnet_server)) .serve_with_incoming(uds_stream) .await?; Ok::<(), AhError>(()) diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index aa84c64..06a9b45 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -5,15 +5,11 @@ use tokio::net::UnixStream; use tonic::transport::{Endpoint, Uri}; use tower::service_fn; -use super::grpc_defs::{ - networks_client::NetworksClient, tailnet_control_client::TailnetControlClient, - tunnel_client::TunnelClient, -}; +use super::grpc_defs::{networks_client::NetworksClient, tunnel_client::TunnelClient}; use crate::daemon::get_socket_path; pub struct BurrowClient { pub networks_client: NetworksClient, - pub tailnet_client: TailnetControlClient, pub tunnel_client: TunnelClient, } @@ -35,11 +31,9 @@ impl BurrowClient { })) .await?; let nw_client = NetworksClient::new(channel.clone()); - let tailnet_client = TailnetControlClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone()); Ok(BurrowClient { networks_client: nw_client, - tailnet_client, tunnel_client: tun_client, }) } diff --git a/burrow/src/main.rs b/burrow/src/main.rs index 4ab7700..c91f36f 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -72,14 +72,6 @@ enum Commands { NetworkReorder(NetworkReorderArgs), /// Delete Network 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")] /// Run a command in an unshared Linux namespace using a Burrow backend Exec(ExecArgs), @@ -118,36 +110,6 @@ struct NetworkDeleteArgs { 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")] #[derive(Args)] struct TorExecArgs { @@ -278,393 +240,6 @@ async fn try_network_delete(id: i32) -> Result<()> { 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::(); - let sequence = 1_u16; - let packet = build_icmp_echo_request(local_ip, remote_ip, identifier, sequence, &message)?; - - let (outbound_tx, outbound_rx) = mpsc::channel::(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::(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 { - use anyhow::Context; - - let family_is_v4 = remote_ip.is_ipv4(); - addresses - .iter() - .filter_map(|cidr| cidr.split('/').next()) - .filter_map(|ip| ip.parse::().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 { - 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, -} - -#[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> { - 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> { - 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")] async fn try_tor_exec(payload_path: &str, command: Vec) -> Result<()> { let exit_code = usernet::run_exec(usernet::ExecInvocation { @@ -773,14 +348,6 @@ async fn main() -> Result<()> { Commands::NetworkList => try_network_list().await?, Commands::NetworkReorder(args) => try_network_reorder(args.id, args.index).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")] Commands::Exec(args) => { try_exec( diff --git a/contributors.nix b/contributors.nix deleted file mode 100644 index 22c28b6..0000000 --- a/contributors.nix +++ /dev/null @@ -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" - ]; - }; - }; -} diff --git a/evolution/README.md b/evolution/README.md index 794b1fe..e55a347 100644 --- a/evolution/README.md +++ b/evolution/README.md @@ -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. - -## 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 -``` diff --git a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md b/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md deleted file mode 100644 index 1227444..0000000 --- a/evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md +++ /dev/null @@ -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/` diff --git a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md b/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md deleted file mode 100644 index fea4aba..0000000 --- a/evolution/proposals/BEP-0006-tailnet-authority-first-control-plane.md +++ /dev/null @@ -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` diff --git a/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md b/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md deleted file mode 100644 index 1fde0fb..0000000 --- a/evolution/proposals/BEP-0007-identity-registry-and-operator-bootstrap.md +++ /dev/null @@ -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` diff --git a/flake.lock b/flake.lock index 0067dab..1bafc37 100644 --- a/flake.lock +++ b/flake.lock @@ -123,37 +123,13 @@ "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": { "inputs": { "agenix": "agenix", "disko": "disko", "flake-utils": "flake-utils", "hcloud-upload-image-src": "hcloud-upload-image-src", - "nixpkgs": "nixpkgs", - "nsc-autoscaler": "nsc-autoscaler" + "nixpkgs": "nixpkgs" } }, "systems": { diff --git a/flake.nix b/flake.nix index 1e91dcc..5814c19 100644 --- a/flake.nix +++ b/flake.nix @@ -12,18 +12,13 @@ url = "tarball+https://codeload.github.com/nix-community/disko/tar.gz/master"; 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 = { url = "tarball+https://codeload.github.com/apricote/hcloud-upload-image/tar.gz/v1.3.0"; 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 supportedSystems = [ "x86_64-linux" @@ -180,7 +175,7 @@ // { nixosModules.burrow-forge = import ./nixos/modules/burrow-forge.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-headscale = import ./nixos/modules/burrow-headscale.nix; diff --git a/nixos/README.md b/nixos/README.md index c79d8ce..07b421d 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -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 - `modules/burrow-forge.nix`: Forgejo, Caddy, PostgreSQL, and admin bootstrap module - `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-headscale.nix`: Headscale control plane rooted in Authentik OIDC - `../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/`. 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 `. -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/`. 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. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 75b76d4..d612ea8 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -1,28 +1,4 @@ -{ config, lib, 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 +{ config, self, ... }: { imports = [ @@ -63,12 +39,6 @@ in group = "forgejo"; mode = "0440"; }; - age.secrets.burrowTailscaleOidcClientSecret = { - file = ../../../secrets/infra/tailscale-oidc-client-secret.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -81,12 +51,6 @@ in group = "root"; mode = "0400"; }; - age.secrets.burrowAuthentikUiTestPassword = { - file = ../../../secrets/infra/authentik-ui-test-password.age; - owner = "root"; - group = "root"; - mode = "0400"; - }; networking.extraHosts = '' 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 = { enable = true; - contactEmail = identities.contact.canonicalEmail; - adminUsername = "contact"; - adminEmail = identities.contact.canonicalEmail; 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; - authorizedKeys = forgeAuthorizedKeys; + authorizedKeys = [ + (builtins.readFile ../../keys/contact_at_burrow_net.pub) + (builtins.readFile ../../keys/agent_at_burrow_net.pub) + ]; }; services.burrow.forgeRunner = { @@ -110,7 +72,7 @@ in sshPrivateKeyFile = "/var/lib/burrow/intake/agent_at_burrow_net_ed25519"; }; - services.forgejo-nsc = { + services.burrow.forgejoNsc = { enable = true; nscTokenFile = "/var/lib/burrow/intake/forgejo_nsc_token.txt"; dispatcher = { @@ -127,13 +89,25 @@ in envFile = config.age.secrets.burrowAuthentikEnv.path; forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; - tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleLoginMode = "redirect"; - userGroupName = contributors.groups.users; - adminGroupName = contributors.groups.admins; - bootstrapUsers = 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 = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 1616b36..4e31d43 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,9 +10,7 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; - tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; - tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 metadata: @@ -132,24 +130,6 @@ in 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 { type = lib.types.str; default = "git.burrow.net"; @@ -195,36 +175,6 @@ in 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 { type = lib.types.str; default = "burrow-users"; @@ -267,11 +217,6 @@ in default = false; 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 = [ ]; @@ -332,13 +277,6 @@ in 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 -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} @@ -530,7 +468,7 @@ EOF restartTriggers = [ directorySyncScript cfg.envFile - ] ++ lib.concatMap (user: lib.optional (user.passwordFile != null) user.passwordFile) cfg.bootstrapUsers; + ]; path = [ pkgs.bash pkgs.coreutils @@ -553,7 +491,7 @@ EOF export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName} export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} 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; }) 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) { description = "Reconcile the Burrow Authentik Forgejo OIDC application"; 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 = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index d733135..0d0f5c8 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -258,20 +258,20 @@ in "${cfg.siteDomain}".extraConfig = '' encode gzip zstd @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 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 @webfinger path /.well-known/webfinger 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 / redir @root ${homeRepoUrl} 308 respond 404 ''; } // 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 = '' encode gzip zstd diff --git a/nixos/modules/burrow-forgejo-nsc.nix b/nixos/modules/burrow-forgejo-nsc.nix new file mode 100644 index 0000000..ba116f7 --- /dev/null +++ b/nixos/modules/burrow-forgejo-nsc.nix @@ -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 + ]); + }; + }; +} diff --git a/proto/burrow.proto b/proto/burrow.proto index a590cb1..5b5a30b 100644 --- a/proto/burrow.proto +++ b/proto/burrow.proto @@ -17,14 +17,6 @@ service Networks { 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 { int32 id = 1; 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 { Stopped = 0; Running = 1; diff --git a/secrets.nix b/secrets.nix index c0b9b53..909b929 100644 --- a/secrets.nix +++ b/secrets.nix @@ -1,5 +1,4 @@ let - conradev = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBueQxNbP2246pxr/m7au4zNVm+ShC96xuOcfEcpIjWZ"; contact = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO42guJ5QvNMw3k6YKWlQnjcTsc+X4XI9F2GBtl8aHOa"; agent = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEN0+tRJy7Y2DW0uGYHb86N2t02WyU5lDNX6FaxBF/G8 agent@burrow.net"; burrowForgeHost = "age1quxf27gnun0xghlnxf3jrmqr3h3a3fzd8qxpallsaztd2u74pdfq9e7w9l"; @@ -8,14 +7,11 @@ let agent burrowForgeHost ]; - uiTestRecipients = burrowForgeRecipients ++ [ conradev ]; in { "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; "secrets/infra/authentik-google-client-id.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/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; - "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-ui-test-password.age b/secrets/infra/authentik-ui-test-password.age deleted file mode 100644 index e84a7be..0000000 --- a/secrets/infra/authentik-ui-test-password.age +++ /dev/null @@ -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----- diff --git a/secrets/infra/tailscale-oidc-client-secret.age b/secrets/infra/tailscale-oidc-client-secret.age deleted file mode 100644 index e88c2d1..0000000 --- a/secrets/infra/tailscale-oidc-client-secret.age +++ /dev/null @@ -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 "WL\#zDRq6.竂}#8²koyq>L\`wƔ>f/Ѵ^,# -hD<>]C \ No newline at end of file