Add governance and identity registry scaffolding

This commit is contained in:
Conrad Kramer 2026-04-03 01:36:10 -07:00
parent 1da00ecdf3
commit f6a7f0922d
13 changed files with 612 additions and 21 deletions

View file

@ -0,0 +1,27 @@
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

23
.github/workflows/lint-governance.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Governance Lint
on:
pull_request:
branches:
- "*"
jobs:
governance:
name: BEP Metadata
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Validate BEP metadata
shell: bash
run: |
set -euo pipefail
python3 Scripts/check-bep-metadata.py

14
AGENTS.md Normal file
View file

@ -0,0 +1,14 @@
# 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.

View file

@ -10,6 +10,12 @@ check:
build: build:
@cargo build @cargo build
bep-check:
@python3 Scripts/check-bep-metadata.py
bep-list:
@Scripts/bep list
daemon-console: daemon-console:
@$(sudo_cargo_console) daemon @$(sudo_cargo_console) daemon

View file

@ -10,6 +10,7 @@ Routine verification now runs unprivileged with `cargo test --workspace --all-fe
The repository now carries its own design and deployment record: The repository now carries its own design and deployment record:
- [Constitution](./CONSTITUTION.md) - [Constitution](./CONSTITUTION.md)
- [Agent Instructions](./AGENTS.md)
- [Burrow Evolution](./evolution/README.md) - [Burrow Evolution](./evolution/README.md)
- [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.md) - [WireGuard Rust Lineage](./docs/WIREGUARD_LINEAGE.md)
- [Protocol Roadmap](./docs/PROTOCOL_ROADMAP.md) - [Protocol Roadmap](./docs/PROTOCOL_ROADMAP.md)
@ -19,6 +20,8 @@ The repository now carries its own design and deployment record:
Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh).
Agent and governance-sensitive work should start with [AGENTS.md](./AGENTS.md), [CONSTITUTION.md](./CONSTITUTION.md), and the relevant BEPs under [`evolution/proposals/`](./evolution/proposals/). Identity and bootstrap metadata now live in [`contributors.nix`](./contributors.nix).
The project structure is divided in the following folders: The project structure is divided in the following folders:
``` ```

133
Scripts/bep Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(git rev-parse --show-toplevel)
proposals_dir="$repo_root/evolution/proposals"
auto_browse() {
if command -v wisu >/dev/null 2>&1; then
exec wisu -i -g --icons "$repo_root/evolution"
fi
exec ls -la "$repo_root/evolution"
}
usage() {
cat <<'USAGE'
Usage: bep [command]
Commands:
list [--status <Status>] List BEPs, optionally filtered by status.
open <BEP-XXXX|XXXX|X> Open a BEP in $EDITOR.
help Show this help.
If no command is provided, bep launches a simple browser for evolution/.
USAGE
}
normalize_id() {
local raw="$1"
if [[ "$raw" =~ ^BEP-[0-9]+$ ]]; then
printf '%s' "$raw"
return 0
fi
if [[ "$raw" =~ ^[0-9]+$ ]]; then
printf 'BEP-%04d' "$raw"
return 0
fi
return 1
}
read_status() {
local file="$1"
awk -F ': ' '/^Status:/ {print $2; exit}' "$file"
}
read_title() {
local file="$1"
local line
line=$(head -n 1 "$file" || true)
printf '%s' "$line" | sed -E 's/^# `[^`]+`[[:space:]]+//; s/^[^A-Za-z0-9]+//'
}
list_bep() {
local filter="${1:-}"
local filter_lower=""
if [[ -n "$filter" ]]; then
filter_lower=$(printf '%s' "$filter" | tr '[:upper:]' '[:lower:]')
fi
printf '%-10s %-18s %s\n' "BEP" "Status" "Title"
local file
local entries=()
for file in "$proposals_dir"/BEP-*.md; do
[[ -e "$file" ]] || continue
local base
base=$(basename "$file")
local id
id=$(printf '%s' "$base" | cut -d- -f1-2)
local status
status=$(read_status "$file")
local status_lower
status_lower=$(printf '%s' "$status" | tr '[:upper:]' '[:lower:]')
if [[ -n "$filter_lower" && "$status_lower" != "$filter_lower" ]]; then
continue
fi
local title
title=$(read_title "$file")
entries+=("$(printf '%-10s %-18s %s' "$id" "$status" "$title")")
done
if [[ ${#entries[@]} -gt 0 ]]; then
printf '%s\n' "${entries[@]}" | sort
fi
}
open_bep() {
local raw="$1"
local id
if ! id=$(normalize_id "$raw"); then
echo "Unknown BEP id: $raw" >&2
exit 1
fi
local matches
matches=("$proposals_dir"/"$id"-*.md)
if [[ ${#matches[@]} -eq 0 || ! -e "${matches[0]}" ]]; then
echo "No proposal found for $id" >&2
exit 1
fi
if [[ ${#matches[@]} -gt 1 ]]; then
echo "Multiple proposals match $id:" >&2
printf ' %s\n' "${matches[@]}" >&2
exit 1
fi
local editor="${EDITOR:-vi}"
exec "$editor" "${matches[0]}"
}
command=${1:-}
case "$command" in
"")
auto_browse
;;
list)
if [[ ${2:-} == "--status" && -n ${3:-} ]]; then
list_bep "$3"
else
list_bep
fi
;;
open)
if [[ -z ${2:-} ]]; then
echo "bep open requires an id" >&2
exit 1
fi
open_bep "$2"
;;
help|-h|--help)
usage
;;
*)
echo "Unknown command: $command" >&2
usage
exit 1
;;
esac

94
Scripts/check-bep-metadata.py Executable file
View file

@ -0,0 +1,94 @@
#!/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())

47
contributors.nix Normal file
View file

@ -0,0 +1,47 @@
{
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"
];
};
};
}

View file

@ -58,3 +58,17 @@ evolution/
``` ```
Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly. Use ASCII Markdown. Keep metadata at the top of each proposal so tooling and future agents can parse it quickly.
## BEP Helper
Use the `bep` helper under `Scripts/` to browse or list proposals:
- `Scripts/bep` opens a quick browser for `evolution/`.
- `Scripts/bep list --status Draft` lists proposals by status.
- `Scripts/bep open BEP-0005` opens a proposal in `$EDITOR`.
Validate proposal metadata with:
```bash
python3 Scripts/check-bep-metadata.py
```

View file

@ -0,0 +1,78 @@
# `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/`

View file

@ -0,0 +1,71 @@
# `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. Burrows 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-0002s 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`

View file

@ -0,0 +1,73 @@
# `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`

View file

@ -1,4 +1,23 @@
{ config, self, ... }: { config, lib, self, ... }:
let
contributors = import ../../../contributors.nix;
identities = contributors.identities;
bootstrapUsers = lib.mapAttrsToList
(
username: identity: {
inherit username;
name = identity.displayName;
email = identity.canonicalEmail;
sourceEmail = identity.sourceEmail or null;
isAdmin = identity.isAdmin or false;
}
)
(lib.filterAttrs (_: identity: identity.bootstrapAuthentik or false) identities);
forgeAuthorizedKeys = map
(username: builtins.readFile identities.${username}.sshPublicKeyPath)
(builtins.attrNames (lib.filterAttrs (_: identity: identity.forgeAuthorized or false) identities));
in
{ {
imports = [ imports = [
@ -59,12 +78,14 @@
services.burrow.forge = { services.burrow.forge = {
enable = true; enable = true;
contactEmail = identities.contact.canonicalEmail;
adminUsername = "contact";
adminEmail = identities.contact.canonicalEmail;
adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt";
oidcAdminGroup = contributors.groups.admins;
oidcRestrictedGroup = contributors.groups.users;
oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path;
authorizedKeys = [ authorizedKeys = forgeAuthorizedKeys;
(builtins.readFile ../../keys/contact_at_burrow_net.pub)
(builtins.readFile ../../keys/agent_at_burrow_net.pub)
];
}; };
services.burrow.forgeRunner = { services.burrow.forgeRunner = {
@ -92,22 +113,9 @@
googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path;
googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path;
googleLoginMode = "redirect"; googleLoginMode = "redirect";
bootstrapUsers = [ userGroupName = contributors.groups.users;
{ adminGroupName = contributors.groups.admins;
username = "contact"; bootstrapUsers = bootstrapUsers;
name = "Burrow";
email = "contact@burrow.net";
sourceEmail = "net.burrow@gmail.com";
isAdmin = true;
}
{
username = "conrad";
name = "Conrad Kramer";
email = "conrad@burrow.net";
sourceEmail = "ckrames1234@gmail.com";
isAdmin = true;
}
];
}; };
services.burrow.headscale = { services.burrow.headscale = {