chore: Cleanup unnecessary files and commit headplane patches

This commit is contained in:
David Gillespie 2025-12-02 00:28:25 -07:00
parent 683c7ca545
commit 8a67df3a37
204 changed files with 20481 additions and 21954 deletions

View File

@ -1,36 +0,0 @@
{ config, lib, pkgs, inputs, ... }: {
imports = [
./hardware-configuration.nix
./headscale.nix
];
time.timeZone = "America/Denver";
virtualisation.docker.enable = true;
boot.tmp.cleanOnBoot = true;
zramSwap.enable = true;
networking.hostName = "headscale";
networking.domain = "subnet01021712.vcn01021712.oraclevcn.com";
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keys = [
''ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDE3856oAY7HcFwP1y9AvlDXx4cyvAcqa16x8jO/5KZjz7pHXxo3dT/0Rgqjc/k9NgSVYdKK4huhheyB5svKLlZFzXs4HjRgWA+omLORU4UIyz4BenwBzhXjeVQy5tiMbSEoHpJn3Qty08UO8ItoDIZWeJyD4XeRvUexFtt+967JkmbWIS5oreTHBzOXMzqbx3oRt3AdA4PyOzYSYAL8ewXZs7hQtVsn4VNkGBxegEMF2H0SmsxIuRkgdEzV6duZ4Ufia4agW0IciCvD/SwWli34WJcZo1HdGPoSAzD3YRTWkm7ko8uVhDXP58g7A+VoaQTSZC+jaSTI1m7Zuxxr56j+Hhm3fCGqVunM4BqnVaaq1MrpF2U7IWu1NljCZ/0uekWmYjHKRO4J58udDAJdoZgfRGEVcITx1QuilTHmIe61AEjjfqxjKisrLkwzXSEtLQyCuReSYMEdcaFPl3cjTTjuECCoDmL3igVohwH7MbgFPmanX1VDpIE99xGUrun8MTQkp3pxwOvGOA7Pbwyc1jYunABWr8ulFFwscc0VZXBHsEgURqtMJT1XVdq5oYP9LZFVU7aiT/ZnTEbbzH6QRLxBB63US7iRxtbfHSzmZk40u4rX7O2I3reVVzxLPa7TXYCD76rOo7I7huTZ/rZ6I/vZmqIFfmhW0BbUlbUyoI7vQ==''
''ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC6ay8vzXVSUl4/gpHmkdP9Dq+QgJtfRvLpL78BQEu20gKEFLhJWvcyVOMtTXw3MDst/b/DGNhUXlUyRdrXDFYewxTPHoG72PIBUmtFRlVb9KFTLRZfT0U4PjbPwp9yxcjL4H3hV1+p/uUg0FMhtZc+x2nuZ9Sqi8IyaVG1Qzf8egQzmBeaH9E/VxZoZ0MjlFiN2Oq3MbSVOCKH/MrYtURbcFusNvKVNQHpAUQaf1tIFDB2O19yW0grs5jeDoOAAoJlT40O4VCcB5CyC7cLu3jFYY4Uh8mbI3Ju6GCYnt/gkV4HF2q+QbrZOo6wH9b3e85fPvIjg2Y5KZPRGViQDmU82e6g4IjePcQqx6+3/bgxwkDxPm9lZzF1NvtXXX28G+RHiE7aENrSLkqkbS1eAH102Y69OLYoGtaEBKpWh66UQP5oyTEqf0gS6VQq47n/L0i7AczYW5s+Z+lGznHaoiihWBa3nS3tlkkwJxLPapC/i4ee9aQlew8XtQGq+mCAxdvgAbODDXT7eX7DSWMdcUe9oZSlAT6MGA2bHfQkVl3/mh5DQ26tTF9v857WDjWHC7a4vM8ZIdumgzwIHGmA4KIC9RfPDdP1PcxAuUPqwm/fODPmCPFzZhcMP0xxFdoGxEz91CnZIFvxBzbU2mnhtJXiTSr22Axdm9oVps2T8V77gw== djg@djg-nix''
];
# nixpkgs.config.permittedInsecurePackages = [
# "headscale-0.23.0" # Required if using older headscale version
# ];
environment.systemPackages = with pkgs; [
git
wget
nano
curl
neofetch
(pkgs.writeShellScriptBin "generate-headscale-key" '' ${config.services.headscale.package}/bin/headscale apikeys create --expiration 999d '')
inputs.headplane.packages.${pkgs.system}.headplane
];
system.stateVersion = "23.11";
}

View File

@ -1,187 +0,0 @@
{
"nodes": {
"devshell": {
"inputs": {
"nixpkgs": [
"headplane",
"nixpkgs"
]
},
"locked": {
"lastModified": 1741473158,
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
"owner": "numtide",
"repo": "devshell",
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"headplane": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1748534730,
"narHash": "sha256-yM02IlC3barMBeIybXf38NKv4SwS3RiJtPkKtkw5hZc=",
"owner": "tale",
"repo": "headplane",
"rev": "2f316176c8c37ad63946d7075c727478f81303b2",
"type": "github"
},
"original": {
"owner": "tale",
"repo": "headplane",
"type": "github"
}
},
"headscale": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1748293508,
"narHash": "sha256-cLjhwnl2bVj/Q0pKp9QVpUKglvPUl2v3/VV1fTT8N/E=",
"owner": "juanfont",
"repo": "headscale",
"rev": "b8044c29ddc59d9c6346337d589b73a7e5b0511e",
"type": "github"
},
"original": {
"owner": "juanfont",
"repo": "headscale",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1747958103,
"narHash": "sha256-qmmFCrfBwSHoWw7cVK4Aj+fns+c54EBP8cGqp/yK410=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fe51d34885f7b5e3e7b59572796e1bcb427eccb1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1746300365,
"narHash": "sha256-thYTdWqCRipwPRxWiTiH1vusLuAy0okjOyzRx4hLWh4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1748889542,
"narHash": "sha256-Hb4iMhIbjX45GcrgOp3b8xnyli+ysRPqAgZ/LZgyT5k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "10d7f8d34e5eb9c0f9a0485186c1ca691d2c5922",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"headplane": "headplane",
"headscale": "headscale",
"nixpkgs": "nixpkgs_3"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,187 +0,0 @@
{
"nodes": {
"devshell": {
"inputs": {
"nixpkgs": [
"headplane",
"nixpkgs"
]
},
"locked": {
"lastModified": 1741473158,
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
"owner": "numtide",
"repo": "devshell",
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"headplane": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1748534730,
"narHash": "sha256-yM02IlC3barMBeIybXf38NKv4SwS3RiJtPkKtkw5hZc=",
"owner": "tale",
"repo": "headplane",
"rev": "2f316176c8c37ad63946d7075c727478f81303b2",
"type": "github"
},
"original": {
"owner": "tale",
"repo": "headplane",
"type": "github"
}
},
"headscale": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1748293508,
"narHash": "sha256-cLjhwnl2bVj/Q0pKp9QVpUKglvPUl2v3/VV1fTT8N/E=",
"owner": "juanfont",
"repo": "headscale",
"rev": "b8044c29ddc59d9c6346337d589b73a7e5b0511e",
"type": "github"
},
"original": {
"owner": "juanfont",
"repo": "headscale",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1747958103,
"narHash": "sha256-qmmFCrfBwSHoWw7cVK4Aj+fns+c54EBP8cGqp/yK410=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fe51d34885f7b5e3e7b59572796e1bcb427eccb1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1746300365,
"narHash": "sha256-thYTdWqCRipwPRxWiTiH1vusLuAy0okjOyzRx4hLWh4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1748889542,
"narHash": "sha256-Hb4iMhIbjX45GcrgOp3b8xnyli+ysRPqAgZ/LZgyT5k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "10d7f8d34e5eb9c0f9a0485186c1ca691d2c5922",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"headplane": "headplane",
"headscale": "headscale",
"nixpkgs": "nixpkgs_3"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,187 +0,0 @@
{
"nodes": {
"devshell": {
"inputs": {
"nixpkgs": [
"headplane",
"nixpkgs"
]
},
"locked": {
"lastModified": 1741473158,
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
"owner": "numtide",
"repo": "devshell",
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"headplane": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1748534730,
"narHash": "sha256-yM02IlC3barMBeIybXf38NKv4SwS3RiJtPkKtkw5hZc=",
"owner": "tale",
"repo": "headplane",
"rev": "2f316176c8c37ad63946d7075c727478f81303b2",
"type": "github"
},
"original": {
"owner": "tale",
"repo": "headplane",
"type": "github"
}
},
"headscale": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1748293508,
"narHash": "sha256-cLjhwnl2bVj/Q0pKp9QVpUKglvPUl2v3/VV1fTT8N/E=",
"owner": "juanfont",
"repo": "headscale",
"rev": "b8044c29ddc59d9c6346337d589b73a7e5b0511e",
"type": "github"
},
"original": {
"owner": "juanfont",
"repo": "headscale",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1747958103,
"narHash": "sha256-qmmFCrfBwSHoWw7cVK4Aj+fns+c54EBP8cGqp/yK410=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fe51d34885f7b5e3e7b59572796e1bcb427eccb1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1746300365,
"narHash": "sha256-thYTdWqCRipwPRxWiTiH1vusLuAy0okjOyzRx4hLWh4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1748889542,
"narHash": "sha256-Hb4iMhIbjX45GcrgOp3b8xnyli+ysRPqAgZ/LZgyT5k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "10d7f8d34e5eb9c0f9a0485186c1ca691d2c5922",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"headplane": "headplane",
"headscale": "headscale",
"nixpkgs": "nixpkgs_3"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,173 +0,0 @@
{
"nodes": {
"devshell": {
"inputs": {
"nixpkgs": [
"headplane",
"nixpkgs"
]
},
"locked": {
"lastModified": 1741473158,
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
"owner": "numtide",
"repo": "devshell",
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"headplane": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1749098081,
"narHash": "sha256-lsgbrSNLncNoMLvqjeAun/KahsHTrowBQKdMgo2dVjo=",
"owner": "dahjah",
"repo": "headplane",
"rev": "6c3930815e37375d31bf9c55904cb60dfcd84767",
"type": "github"
},
"original": {
"owner": "dahjah",
"repo": "headplane",
"type": "github"
}
},
"headscale": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1748293508,
"narHash": "sha256-cLjhwnl2bVj/Q0pKp9QVpUKglvPUl2v3/VV1fTT8N/E=",
"owner": "juanfont",
"repo": "headscale",
"rev": "b8044c29ddc59d9c6346337d589b73a7e5b0511e",
"type": "github"
},
"original": {
"owner": "juanfont",
"repo": "headscale",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1746300365,
"narHash": "sha256-thYTdWqCRipwPRxWiTiH1vusLuAy0okjOyzRx4hLWh4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1748889542,
"narHash": "sha256-Hb4iMhIbjX45GcrgOp3b8xnyli+ysRPqAgZ/LZgyT5k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "10d7f8d34e5eb9c0f9a0485186c1ca691d2c5922",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"headplane": "headplane",
"headscale": "headscale",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,23 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
headscale.url = "github:juanfont/headscale";
headplane = {
url = "github:dahjah/headplane";
inputs.nixpkgs.follows = "nixpkgs"; # Add dependency tracking
};
};
outputs = { self, nixpkgs, headscale, headplane, ... }@inputs: {
nixosConfigurations.headscale = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
./hardware-configuration.nix
./headscale.nix
headplane.nixosModules.headplane # Changed to default
];
specialArgs = { inherit inputs; };
};
};
}

@ -1 +0,0 @@
Subproject commit dbdb759a7e4616231e0964074342c77e4b54fc28

View File

@ -1,12 +0,0 @@
# Fixed headplane module override
# This module fixes the type error in the upstream headplane module
# where agent.settings type is incorrectly defined
{ config, lib, pkgs, ... }:
{
options.services.headplane.agent.settings = lib.mkForce (lib.mkOption {
type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.bool);
description = "Headplane agent env vars config. See: https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md";
default = {};
});
}

View File

@ -1,17 +0,0 @@
# This file has been generated by node2nix 1.11.1. Do not edit!
{pkgs ? import <nixpkgs> {
inherit system;
}, system ? builtins.currentSystem, nodejs ? pkgs."nodejs_14"}:
let
nodeEnv = import ./node-env.nix {
inherit (pkgs) stdenv lib python2 runCommand writeTextFile writeShellScript;
inherit pkgs nodejs;
libtool = if pkgs.stdenv.isDarwin then pkgs.cctools or pkgs.darwin.cctools else null;
};
in
import ./node-packages.nix {
inherit (pkgs) fetchurl nix-gitignore stdenv lib fetchgit;
inherit nodeEnv;
}

View File

@ -1,689 +0,0 @@
# This file originates from node2nix
{lib, stdenv, nodejs, python2, pkgs, libtool, runCommand, writeTextFile, writeShellScript}:
let
# Workaround to cope with utillinux in Nixpkgs 20.09 and util-linux in Nixpkgs master
utillinux = if pkgs ? utillinux then pkgs.utillinux else pkgs.util-linux;
python = if nodejs ? python then nodejs.python else python2;
# Create a tar wrapper that filters all the 'Ignoring unknown extended header keyword' noise
tarWrapper = runCommand "tarWrapper" {} ''
mkdir -p $out/bin
cat > $out/bin/tar <<EOF
#! ${stdenv.shell} -e
$(type -p tar) "\$@" --warning=no-unknown-keyword --delay-directory-restore
EOF
chmod +x $out/bin/tar
'';
# Function that generates a TGZ file from a NPM project
buildNodeSourceDist =
{ name, version, src, ... }:
stdenv.mkDerivation {
name = "node-tarball-${name}-${version}";
inherit src;
buildInputs = [ nodejs ];
buildPhase = ''
export HOME=$TMPDIR
tgzFile=$(npm pack | tail -n 1) # Hooks to the pack command will add output (https://docs.npmjs.com/misc/scripts)
'';
installPhase = ''
mkdir -p $out/tarballs
mv $tgzFile $out/tarballs
mkdir -p $out/nix-support
echo "file source-dist $out/tarballs/$tgzFile" >> $out/nix-support/hydra-build-products
'';
};
# Common shell logic
installPackage = writeShellScript "install-package" ''
installPackage() {
local packageName=$1 src=$2
local strippedName
local DIR=$PWD
cd $TMPDIR
unpackFile $src
# Make the base dir in which the target dependency resides first
mkdir -p "$(dirname "$DIR/$packageName")"
if [ -f "$src" ]
then
# Figure out what directory has been unpacked
packageDir="$(find . -maxdepth 1 -type d | tail -1)"
# Restore write permissions to make building work
find "$packageDir" -type d -exec chmod u+x {} \;
chmod -R u+w "$packageDir"
# Move the extracted tarball into the output folder
mv "$packageDir" "$DIR/$packageName"
elif [ -d "$src" ]
then
# Get a stripped name (without hash) of the source directory.
# On old nixpkgs it's already set internally.
if [ -z "$strippedName" ]
then
strippedName="$(stripHash $src)"
fi
# Restore write permissions to make building work
chmod -R u+w "$strippedName"
# Move the extracted directory into the output folder
mv "$strippedName" "$DIR/$packageName"
fi
# Change to the package directory to install dependencies
cd "$DIR/$packageName"
}
'';
# Bundle the dependencies of the package
#
# Only include dependencies if they don't exist. They may also be bundled in the package.
includeDependencies = {dependencies}:
lib.optionalString (dependencies != []) (
''
mkdir -p node_modules
cd node_modules
''
+ (lib.concatMapStrings (dependency:
''
if [ ! -e "${dependency.packageName}" ]; then
${composePackage dependency}
fi
''
) dependencies)
+ ''
cd ..
''
);
# Recursively composes the dependencies of a package
composePackage = { name, packageName, src, dependencies ? [], ... }@args:
builtins.addErrorContext "while evaluating node package '${packageName}'" ''
installPackage "${packageName}" "${src}"
${includeDependencies { inherit dependencies; }}
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
'';
pinpointDependencies = {dependencies, production}:
let
pinpointDependenciesFromPackageJSON = writeTextFile {
name = "pinpointDependencies.js";
text = ''
var fs = require('fs');
var path = require('path');
function resolveDependencyVersion(location, name) {
if(location == process.env['NIX_STORE']) {
return null;
} else {
var dependencyPackageJSON = path.join(location, "node_modules", name, "package.json");
if(fs.existsSync(dependencyPackageJSON)) {
var dependencyPackageObj = JSON.parse(fs.readFileSync(dependencyPackageJSON));
if(dependencyPackageObj.name == name) {
return dependencyPackageObj.version;
}
} else {
return resolveDependencyVersion(path.resolve(location, ".."), name);
}
}
}
function replaceDependencies(dependencies) {
if(typeof dependencies == "object" && dependencies !== null) {
for(var dependency in dependencies) {
var resolvedVersion = resolveDependencyVersion(process.cwd(), dependency);
if(resolvedVersion === null) {
process.stderr.write("WARNING: cannot pinpoint dependency: "+dependency+", context: "+process.cwd()+"\n");
} else {
dependencies[dependency] = resolvedVersion;
}
}
}
}
/* Read the package.json configuration */
var packageObj = JSON.parse(fs.readFileSync('./package.json'));
/* Pinpoint all dependencies */
replaceDependencies(packageObj.dependencies);
if(process.argv[2] == "development") {
replaceDependencies(packageObj.devDependencies);
}
else {
packageObj.devDependencies = {};
}
replaceDependencies(packageObj.optionalDependencies);
replaceDependencies(packageObj.peerDependencies);
/* Write the fixed package.json file */
fs.writeFileSync("package.json", JSON.stringify(packageObj, null, 2));
'';
};
in
''
node ${pinpointDependenciesFromPackageJSON} ${if production then "production" else "development"}
${lib.optionalString (dependencies != [])
''
if [ -d node_modules ]
then
cd node_modules
${lib.concatMapStrings (dependency: pinpointDependenciesOfPackage dependency) dependencies}
cd ..
fi
''}
'';
# Recursively traverses all dependencies of a package and pinpoints all
# dependencies in the package.json file to the versions that are actually
# being used.
pinpointDependenciesOfPackage = { packageName, dependencies ? [], production ? true, ... }@args:
''
if [ -d "${packageName}" ]
then
cd "${packageName}"
${pinpointDependencies { inherit dependencies production; }}
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
fi
'';
# Extract the Node.js source code which is used to compile packages with
# native bindings
nodeSources = runCommand "node-sources" {} ''
tar --no-same-owner --no-same-permissions -xf ${nodejs.src}
mv node-* $out
'';
# Script that adds _integrity fields to all package.json files to prevent NPM from consulting the cache (that is empty)
addIntegrityFieldsScript = writeTextFile {
name = "addintegrityfields.js";
text = ''
var fs = require('fs');
var path = require('path');
function augmentDependencies(baseDir, dependencies) {
for(var dependencyName in dependencies) {
var dependency = dependencies[dependencyName];
// Open package.json and augment metadata fields
var packageJSONDir = path.join(baseDir, "node_modules", dependencyName);
var packageJSONPath = path.join(packageJSONDir, "package.json");
if(fs.existsSync(packageJSONPath)) { // Only augment packages that exist. Sometimes we may have production installs in which development dependencies can be ignored
console.log("Adding metadata fields to: "+packageJSONPath);
var packageObj = JSON.parse(fs.readFileSync(packageJSONPath));
if(dependency.integrity) {
packageObj["_integrity"] = dependency.integrity;
} else {
packageObj["_integrity"] = "sha1-000000000000000000000000000="; // When no _integrity string has been provided (e.g. by Git dependencies), add a dummy one. It does not seem to harm and it bypasses downloads.
}
if(dependency.resolved) {
packageObj["_resolved"] = dependency.resolved; // Adopt the resolved property if one has been provided
} else {
packageObj["_resolved"] = dependency.version; // Set the resolved version to the version identifier. This prevents NPM from cloning Git repositories.
}
if(dependency.from !== undefined) { // Adopt from property if one has been provided
packageObj["_from"] = dependency.from;
}
fs.writeFileSync(packageJSONPath, JSON.stringify(packageObj, null, 2));
}
// Augment transitive dependencies
if(dependency.dependencies !== undefined) {
augmentDependencies(packageJSONDir, dependency.dependencies);
}
}
}
if(fs.existsSync("./package-lock.json")) {
var packageLock = JSON.parse(fs.readFileSync("./package-lock.json"));
if(![1, 2].includes(packageLock.lockfileVersion)) {
process.stderr.write("Sorry, I only understand lock file versions 1 and 2!\n");
process.exit(1);
}
if(packageLock.dependencies !== undefined) {
augmentDependencies(".", packageLock.dependencies);
}
}
'';
};
# Reconstructs a package-lock file from the node_modules/ folder structure and package.json files with dummy sha1 hashes
reconstructPackageLock = writeTextFile {
name = "reconstructpackagelock.js";
text = ''
var fs = require('fs');
var path = require('path');
var packageObj = JSON.parse(fs.readFileSync("package.json"));
var lockObj = {
name: packageObj.name,
version: packageObj.version,
lockfileVersion: 2,
requires: true,
packages: {
"": {
name: packageObj.name,
version: packageObj.version,
license: packageObj.license,
bin: packageObj.bin,
dependencies: packageObj.dependencies,
engines: packageObj.engines,
optionalDependencies: packageObj.optionalDependencies
}
},
dependencies: {}
};
function augmentPackageJSON(filePath, packages, dependencies) {
var packageJSON = path.join(filePath, "package.json");
if(fs.existsSync(packageJSON)) {
var packageObj = JSON.parse(fs.readFileSync(packageJSON));
packages[filePath] = {
version: packageObj.version,
integrity: "sha1-000000000000000000000000000=",
dependencies: packageObj.dependencies,
engines: packageObj.engines,
optionalDependencies: packageObj.optionalDependencies
};
dependencies[packageObj.name] = {
version: packageObj.version,
integrity: "sha1-000000000000000000000000000=",
dependencies: {}
};
processDependencies(path.join(filePath, "node_modules"), packages, dependencies[packageObj.name].dependencies);
}
}
function processDependencies(dir, packages, dependencies) {
if(fs.existsSync(dir)) {
var files = fs.readdirSync(dir);
files.forEach(function(entry) {
var filePath = path.join(dir, entry);
var stats = fs.statSync(filePath);
if(stats.isDirectory()) {
if(entry.substr(0, 1) == "@") {
// When we encounter a namespace folder, augment all packages belonging to the scope
var pkgFiles = fs.readdirSync(filePath);
pkgFiles.forEach(function(entry) {
if(stats.isDirectory()) {
var pkgFilePath = path.join(filePath, entry);
augmentPackageJSON(pkgFilePath, packages, dependencies);
}
});
} else {
augmentPackageJSON(filePath, packages, dependencies);
}
}
});
}
}
processDependencies("node_modules", lockObj.packages, lockObj.dependencies);
fs.writeFileSync("package-lock.json", JSON.stringify(lockObj, null, 2));
'';
};
# Script that links bins defined in package.json to the node_modules bin directory
# NPM does not do this for top-level packages itself anymore as of v7
linkBinsScript = writeTextFile {
name = "linkbins.js";
text = ''
var fs = require('fs');
var path = require('path');
var packageObj = JSON.parse(fs.readFileSync("package.json"));
var nodeModules = Array(packageObj.name.split("/").length).fill("..").join(path.sep);
if(packageObj.bin !== undefined) {
fs.mkdirSync(path.join(nodeModules, ".bin"))
if(typeof packageObj.bin == "object") {
Object.keys(packageObj.bin).forEach(function(exe) {
if(fs.existsSync(packageObj.bin[exe])) {
console.log("linking bin '" + exe + "'");
fs.symlinkSync(
path.join("..", packageObj.name, packageObj.bin[exe]),
path.join(nodeModules, ".bin", exe)
);
}
else {
console.log("skipping non-existent bin '" + exe + "'");
}
})
}
else {
if(fs.existsSync(packageObj.bin)) {
console.log("linking bin '" + packageObj.bin + "'");
fs.symlinkSync(
path.join("..", packageObj.name, packageObj.bin),
path.join(nodeModules, ".bin", packageObj.name.split("/").pop())
);
}
else {
console.log("skipping non-existent bin '" + packageObj.bin + "'");
}
}
}
else if(packageObj.directories !== undefined && packageObj.directories.bin !== undefined) {
fs.mkdirSync(path.join(nodeModules, ".bin"))
fs.readdirSync(packageObj.directories.bin).forEach(function(exe) {
if(fs.existsSync(path.join(packageObj.directories.bin, exe))) {
console.log("linking bin '" + exe + "'");
fs.symlinkSync(
path.join("..", packageObj.name, packageObj.directories.bin, exe),
path.join(nodeModules, ".bin", exe)
);
}
else {
console.log("skipping non-existent bin '" + exe + "'");
}
})
}
'';
};
prepareAndInvokeNPM = {packageName, bypassCache, reconstructLock, npmFlags, production}:
let
forceOfflineFlag = if bypassCache then "--offline" else "--registry http://www.example.com";
in
''
# Pinpoint the versions of all dependencies to the ones that are actually being used
echo "pinpointing versions of dependencies..."
source $pinpointDependenciesScriptPath
# Patch the shebangs of the bundled modules to prevent them from
# calling executables outside the Nix store as much as possible
patchShebangs .
# Deploy the Node.js package by running npm install. Since the
# dependencies have been provided already by ourselves, it should not
# attempt to install them again, which is good, because we want to make
# it Nix's responsibility. If it needs to install any dependencies
# anyway (e.g. because the dependency parameters are
# incomplete/incorrect), it fails.
#
# The other responsibilities of NPM are kept -- version checks, build
# steps, postprocessing etc.
export HOME=$TMPDIR
cd "${packageName}"
runHook preRebuild
${lib.optionalString bypassCache ''
${lib.optionalString reconstructLock ''
if [ -f package-lock.json ]
then
echo "WARNING: Reconstruct lock option enabled, but a lock file already exists!"
echo "This will most likely result in version mismatches! We will remove the lock file and regenerate it!"
rm package-lock.json
else
echo "No package-lock.json file found, reconstructing..."
fi
node ${reconstructPackageLock}
''}
node ${addIntegrityFieldsScript}
''}
npm ${forceOfflineFlag} --nodedir=${nodeSources} ${npmFlags} ${lib.optionalString production "--production"} rebuild
runHook postRebuild
if [ "''${dontNpmInstall-}" != "1" ]
then
# NPM tries to download packages even when they already exist if npm-shrinkwrap is used.
rm -f npm-shrinkwrap.json
npm ${forceOfflineFlag} --nodedir=${nodeSources} --no-bin-links --ignore-scripts ${npmFlags} ${lib.optionalString production "--production"} install
fi
# Link executables defined in package.json
node ${linkBinsScript}
'';
# Builds and composes an NPM package including all its dependencies
buildNodePackage =
{ name
, packageName
, version ? null
, dependencies ? []
, buildInputs ? []
, production ? true
, npmFlags ? ""
, dontNpmInstall ? false
, bypassCache ? false
, reconstructLock ? false
, preRebuild ? ""
, dontStrip ? true
, unpackPhase ? "true"
, buildPhase ? "true"
, meta ? {}
, ... }@args:
let
extraArgs = removeAttrs args [ "name" "dependencies" "buildInputs" "dontStrip" "dontNpmInstall" "preRebuild" "unpackPhase" "buildPhase" "meta" ];
in
stdenv.mkDerivation ({
name = "${name}${if version == null then "" else "-${version}"}";
buildInputs = [ tarWrapper python nodejs ]
++ lib.optional (stdenv.isLinux) utillinux
++ lib.optional (stdenv.isDarwin) libtool
++ buildInputs;
inherit nodejs;
inherit dontStrip; # Stripping may fail a build for some package deployments
inherit dontNpmInstall preRebuild unpackPhase buildPhase;
compositionScript = composePackage args;
pinpointDependenciesScript = pinpointDependenciesOfPackage args;
passAsFile = [ "compositionScript" "pinpointDependenciesScript" ];
installPhase = ''
source ${installPackage}
# Create and enter a root node_modules/ folder
mkdir -p $out/lib/node_modules
cd $out/lib/node_modules
# Compose the package and all its dependencies
source $compositionScriptPath
${prepareAndInvokeNPM { inherit packageName bypassCache reconstructLock npmFlags production; }}
# Create symlink to the deployed executable folder, if applicable
if [ -d "$out/lib/node_modules/.bin" ]
then
ln -s $out/lib/node_modules/.bin $out/bin
# Fixup all executables
ls $out/bin/* | while read i
do
file="$(readlink -f "$i")"
chmod u+rwx "$file"
if isScript "$file"
then
sed -i 's/\r$//' "$file" # convert crlf to lf
fi
done
fi
# Create symlinks to the deployed manual page folders, if applicable
if [ -d "$out/lib/node_modules/${packageName}/man" ]
then
mkdir -p $out/share
for dir in "$out/lib/node_modules/${packageName}/man/"*
do
mkdir -p $out/share/man/$(basename "$dir")
for page in "$dir"/*
do
ln -s $page $out/share/man/$(basename "$dir")
done
done
fi
# Run post install hook, if provided
runHook postInstall
'';
meta = {
# default to Node.js' platforms
platforms = nodejs.meta.platforms;
} // meta;
} // extraArgs);
# Builds a node environment (a node_modules folder and a set of binaries)
buildNodeDependencies =
{ name
, packageName
, version ? null
, src
, dependencies ? []
, buildInputs ? []
, production ? true
, npmFlags ? ""
, dontNpmInstall ? false
, bypassCache ? false
, reconstructLock ? false
, dontStrip ? true
, unpackPhase ? "true"
, buildPhase ? "true"
, ... }@args:
let
extraArgs = removeAttrs args [ "name" "dependencies" "buildInputs" ];
in
stdenv.mkDerivation ({
name = "node-dependencies-${name}${if version == null then "" else "-${version}"}";
buildInputs = [ tarWrapper python nodejs ]
++ lib.optional (stdenv.isLinux) utillinux
++ lib.optional (stdenv.isDarwin) libtool
++ buildInputs;
inherit dontStrip; # Stripping may fail a build for some package deployments
inherit dontNpmInstall unpackPhase buildPhase;
includeScript = includeDependencies { inherit dependencies; };
pinpointDependenciesScript = pinpointDependenciesOfPackage args;
passAsFile = [ "includeScript" "pinpointDependenciesScript" ];
installPhase = ''
source ${installPackage}
mkdir -p $out/${packageName}
cd $out/${packageName}
source $includeScriptPath
# Create fake package.json to make the npm commands work properly
cp ${src}/package.json .
chmod 644 package.json
${lib.optionalString bypassCache ''
if [ -f ${src}/package-lock.json ]
then
cp ${src}/package-lock.json .
chmod 644 package-lock.json
fi
''}
# Go to the parent folder to make sure that all packages are pinpointed
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
${prepareAndInvokeNPM { inherit packageName bypassCache reconstructLock npmFlags production; }}
# Expose the executables that were installed
cd ..
${lib.optionalString (builtins.substring 0 1 packageName == "@") "cd .."}
mv ${packageName} lib
ln -s $out/lib/node_modules/.bin $out/bin
'';
} // extraArgs);
# Builds a development shell
buildNodeShell =
{ name
, packageName
, version ? null
, src
, dependencies ? []
, buildInputs ? []
, production ? true
, npmFlags ? ""
, dontNpmInstall ? false
, bypassCache ? false
, reconstructLock ? false
, dontStrip ? true
, unpackPhase ? "true"
, buildPhase ? "true"
, ... }@args:
let
nodeDependencies = buildNodeDependencies args;
extraArgs = removeAttrs args [ "name" "dependencies" "buildInputs" "dontStrip" "dontNpmInstall" "unpackPhase" "buildPhase" ];
in
stdenv.mkDerivation ({
name = "node-shell-${name}${if version == null then "" else "-${version}"}";
buildInputs = [ python nodejs ] ++ lib.optional (stdenv.isLinux) utillinux ++ buildInputs;
buildCommand = ''
mkdir -p $out/bin
cat > $out/bin/shell <<EOF
#! ${stdenv.shell} -e
$shellHook
exec ${stdenv.shell}
EOF
chmod +x $out/bin/shell
'';
# Provide the dependencies in a development shell through the NODE_PATH environment variable
inherit nodeDependencies;
shellHook = lib.optionalString (dependencies != []) ''
export NODE_PATH=${nodeDependencies}/lib/node_modules
export PATH="${nodeDependencies}/bin:$PATH"
'';
} // extraArgs);
in
{
buildNodeSourceDist = lib.makeOverridable buildNodeSourceDist;
buildNodePackage = lib.makeOverridable buildNodePackage;
buildNodeDependencies = lib.makeOverridable buildNodeDependencies;
buildNodeShell = lib.makeOverridable buildNodeShell;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +0,0 @@
{ config, lib, inputs, pkgs, ... }: # Added pkgs to arguments
let
domain = "kennys.mom";
in {
imports = [ inputs.headplane.nixosModules.default ];
services.headplane = {
enable = true;
settings = {
server.addr = "127.0.0.1:8080"; # Use dedicated port
headscale = {
url = "https://headscale.${domain}";
grpc_address = "127.0.0.1:50443";
api_key_file = "/etc/headscale/apikey";
};
};
};
# Required firewall openings
networking.firewall.allowedTCPPorts = [
50443 # Headscale gRPC port
];
}

View File

@ -1,114 +0,0 @@
{ config, pkgs, ... }:
let
domain = "kennys.mom";
headplanePort = 3000; # Adjusted to match your configuration
headplaneDir = "/opt/headplane"; # Directory to store the project
configYamlPath = "/etc/headplane/config/headplane.yaml"; # Path to the generated config YAML
headscaleConfigPath = "/var/lib/headscale/config.yaml"; # Default path for Headscale config
cookieSecret = "iQ0bUyaFgwaijWaSyZ1ILA9RwfywrbZ3";
# Define the structure of the YAML file data
yamlData = {
server = {
host = "0.0.0.0";
port = headplanePort;
cookie_secret = cookieSecret;
cookie_secure = false;
};
headscale = {
url = "https://headscale.${domain}";
config_path = headscaleConfigPath;
config_strict = true;
};
integration = {
proc = {
enabled = true;
};
};
};
# Define a YAML format for generating the configuration
settingsFormat = pkgs.formats.yaml { };
# Generate the headplane.yaml file using the settings format
configFile = settingsFormat.generate "headplane.yaml" yamlData;
# If you need to create another config file for CLI, you could do so here (optional)
# cliConfigFile = settingsFormat.generate "cli_config.yaml" cliData;
in
{
# Ensure the generated config file is placed in the correct location
environment.etc."headplane/config/headplane.yaml".source = configFile;
services = {
# NGINX configuration for Headplane
nginx = {
enable = true;
virtualHosts."headplane.${domain}" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:${toString headplanePort}";
proxyWebsockets = true;
};
};
};
};
# Ensure the necessary directories exist
# systemd.tmpfiles.rules = [
# "d /etc/headplane/config 0755 root root" # Creating directory for headplane.yaml
# "d /opt/headplane 0755 root root" # Ensure the main directory for Headplane exists
# ];
# Systemd service for building, running, and managing Headplane
# systemd.services.headplane = {
# description = "Headplane Service";
# wantedBy = [ "multi-user.target" ];
# # Use a shell script to automate building and running Headplane
# # script = ''
# # # Clone Headplane repository if not already cloned
# # if [ ! -d "${headplaneDir}" ]; then
# # echo "CLONING REPO NOW";
# # git clone https://github.com/tale/headplane ${headplaneDir}
# # fi
# # cd ${headplaneDir}
# # # Install dependencies and build the project
# # ${pkgs.pnpm}/bin/pnpm install
# # ${pkgs.pnpm}/bin/pnpm build
# # # Start the Headplane server with the generated config
# # node build/headplane/server.js --config ${configYamlPath}
# # # ${pkgs.nodejs}/bin/node ${headplaneDir}/build/headplane/server.js --config ${configYamlPath}
# # '';
# # serviceConfig = {
# # Restart = "always"; # Ensure the service is always running
# # User = "headplane"; # You may need to create this user (e.g., `useradd headplane`)
# # Group = "headplane"; # Same for the group
# # WorkingDirectory = headplaneDir;
# # ExecStart = "${pkgs.nodejs}/bin/node ${headplaneDir}/build/server/index.js --config ${configYamlPath}";
# # };
# };
# Optional: Ensure we have necessary dependencies like Node.js, pnpm, etc.
# environment.systemPackages = with pkgs; [
# nodejs
# nodePackages.pnpm
# git
# ];
# # Create the headplane user and group
# users.groups.headplane = {};
# users.users.headplane = {
# isSystemUser = true;
# home = "/opt/headplane";
# group = "headplane";
# };
}

7
headplane/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
.dockerignore
Dockerfile
/.cache
/build
.env

1
headplane/.envrc Normal file
View File

@ -0,0 +1 @@
use_flake

2
headplane/.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: tale
ko_fi: atale

View File

@ -0,0 +1,31 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: [bug, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true

View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -0,0 +1,15 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: [enhancement, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true

View File

@ -0,0 +1,33 @@
name: Automated
on:
schedule:
- cron: "0 8 * * 0"
workflow_dispatch:
concurrency:
group: automation-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: write # Read/write access to the repository
pull-requests: write # Allow creating pull requests
jobs:
flake-inputs:
name: flake-inputs
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
automated

76
headplane/.github/workflows/build.yaml vendored Normal file
View File

@ -0,0 +1,76 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
jobs:
native:
name: native
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
nix:
name: nix
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- name: Check flake inputs
uses: DeterminateSystems/flake-checker-action@main
- name: Check flake outputs
run: nix flake check --all-systems

55
headplane/.github/workflows/next.yaml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Pre-release (next)
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pre-release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=next
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

View File

@ -0,0 +1,53 @@
name: Release
on:
push:
tags:
- "*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
docker:
name: Docker Release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

6
headplane/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
/.react-router
/.cache
/build
/test
.env

2
headplane/.npmrc Normal file
View File

@ -0,0 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*

2
headplane/.tool-versions Normal file
View File

@ -0,0 +1,2 @@
pnpm 10.4.0
node 22

View File

@ -0,0 +1,17 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}

239
headplane/CHANGELOG.md Normal file
View File

@ -0,0 +1,239 @@
### 0.5.10 (April 4, 2025)
- Fix an issue where other prefernences to skip onboarding affected every user.
### 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it.
### 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
### 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
### 0.5.6 (April 2, 2025)
### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented *proper* authentication methods for OIDC.
This is a large update and copies the permission system from Tailscale.
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
By default, new users will have a `member` role which forbids access to the UI.
If there are no users, the first user will be given an `owner` role which cannot be removed.
**Changes**:
- Switched the internal server to use `hono` for better performance.
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
- Headplane will not send API requests when the tab is not focused.
- Continue loosening the configuration requirements for Headscale (part of an ongoing effort).
- Unknown values in the Headplane config no longer cause a crash.
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
### 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
### 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
- OIDC auth was not using information from the `user_info` endpoint.
- Support the picture of the user who is logged in via OIDC if available.
- Rewrote the Agent implementation to better utilize disk space and perform better (coming soon).
- Loosened checking for the Headscale configuration as it was too strict and required certain optional fields.
- Deleting a node will now correctly redirect back to the nodes page (fixes [#137](https://github.com/tale/headplane/issues/137))
- Supports connecting to Headscale via TLS and can accept a certificate file (partially fixes [#82](https://github.com/tale/headplane/issues/82))
- Add support for running Headplane through Nix, though currently unsupported (via [#132](https://github.com/tale/headplane/pull/132))
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
### 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
### 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request
### 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config
### 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format.
- Completely redesigned the UI from the ground up for accessibility and performance.
- Switched to a config-file setup (this introduces breaking changes, see [config.example.yaml](/config.example.yaml) for the new format).
- If the config is read-only, the options are still visible, just disabled (fixes [#48](https://github.com/tale/headplane/issues/48))
- Added support for Headscale 0.25.0 (this drops support for any older versions).
- Fixed issues where renaming, deleting, and changing node owners via users was not possible (fixes [#91](https://github.com/tale/headplane/issues/91))
- Operations now have significantly less moving parts and better error handling.
- Updated to `pnpm` 10 and Node.js 22.
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
### 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
### 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
- The footer now blurs your Headscale URL as a privacy measure
- Updated to the next stable beta of the React Compiler
- Changed `/healthz` to use a well-known endpoint instead of trying an invalid API key
- Support `OIDC_REDIRECT_URI` to force a specific redirect URI
- Redo the OIDC integration for better error handling and configuration
- Gracefully handle when Headscale is unreachable instead of crashing the dashboard
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
- Tweaked some CSS issues in the UI
### 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
### 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
### 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
- Fixed a bug where removing Split DNS configurations would crash the UI.
### 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
### 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps.
- Reimplemented the integration system to be more resilient and log more information.
- Fixed an issue where the /proc integration found `undefined` PIDs.
### 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly.
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
### 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page).
### 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page.
- Fixed the expiry issue once and for all.
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
### 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines.
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
### 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
- Headplane's context now only loads once at start instead of being lazy-loaded.
- Improved logging and error propagation so that it's easier to debug issues.
### 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
### 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
- Fixed button responders not actually being invoked (should fix the ACL page).
### 0.1.4 (May 15, 2024)
- Users can now be created, renamed, and deleted on the users page.
- Machines can be dragged between users to change their ownership.
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
- Implemented some fixes that should stop dialogs from hanging a webpage.
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
- Upgrade other dependencies
### 0.1.3 (May 4, 2024)
- Switched to a better icon set for the UI.
- Support stable scrollbar gutter if supported by the browser.
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
### 0.1.2 (May 1, 2024)
- Added support for renaming, expiring, removing, and managing the routes of a machine.
- Implemented an expiry check for machines which now reflect on the machine table.
- Fixed an issue where `HEADSCALE_CONTAINER` was needed to start even without the Docker integration.
- Removed the requirement for the root `API_KEY` unless OIDC was being used for authentication.
- Switched to [React Aria](https://react-spectrum.adobe.com/react-aria/) for better accessibility support.
- Cleaned up various different UI inconsistencies and copied components that could've been abstracted.
- Added a changelog for any new versions going forward.

18
headplane/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN npm install -g pnpm@10
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM node:22-alpine
RUN mkdir -p /var/lib/headplane
WORKDIR /app
COPY --from=build /app/build /app/build
CMD [ "node", "./build/server/index.js" ]

21
headplane/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Aarnav Tale
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

89
headplane/README.md Normal file
View File

@ -0,0 +1,89 @@
# Headplane
> A feature-complete web UI for [Headscale](https://headscale.net)
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/preview-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/preview-light.png"
>
<img
alt="Preview"
src="./assets/preview-dark.png"
>
</picture>
Headscale is the de-facto self-hosted version of Tailscale, a popular Wireguard
based VPN service. By default, it does not ship with a web UI, which is where
Headplane comes in. Headplane is a feature-complete web UI for Headscale, allowing
you to manage your nodes, networks, and ACLs with ease.
Headplane aims to replicate the functionality offered by the official Tailscale
product and dashboard, being one of the most feature complete Headscale UIs available.
These are some of the features that Headplane offers:
- Machine management, including expiry, network routing, name, and owner management
- Access Control List (ACL) and tagging configuration for ACL enforcement
- Support for OpenID Connect (OIDC) as a login provider
- The ability to edit DNS settings and automatically provision Headscale
- Configurability for Headscale's settings
## Deployment
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
It's available as a Docker image (recommended) or through a manual installation.
There are 2 ways to deploy Headplane:
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
Integrated mode unlocks all the features of Headplane and is the most
feature-complete deployment method. It communicates with Headscale directly.
- ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
### Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
### Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
---
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/acls-light.png"
>
<img
alt="ACLs"
src="./assets/acls-dark.png"
>
</picture>
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/machine-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/machine-light.png"
>
<img
alt="Machine Management"
src="./assets/machine-dark.png"
>
</picture>
> Copyright (c) 2025 Aarnav Tale

View File

@ -0,0 +1,15 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]

View File

@ -0,0 +1,40 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/config"
"github.com/tale/headplane/agent/tsnet"
"github.com/tale/headplane/agent/hpagent"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}

View File

@ -0,0 +1,56 @@
package config
import (
"os"
_ "github.com/joho/godotenv/autoload"
)
// Config represents the configuration for the agent.
type Config struct {
Debug bool
Hostname string
TSControlURL string
TSAuthKey string
HPControlURL string
HPAuthKey string
}
const (
DebugEnv = "HEADPLANE_AGENT_DEBUG"
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
func Load() (*Config, error) {
c := &Config{
Debug: false,
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
}
if os.Getenv(DebugEnv) == "true" {
c.Debug = true
}
if err := validateRequired(c); err != nil {
return nil, err
}
if err := validateTSReady(c); err != nil {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}

View File

@ -0,0 +1,73 @@
package config
import (
"fmt"
"net/http"
"strings"
)
// Checks to make sure all required environment variables are set
func validateRequired(config *Config) error {
if config.Hostname == "" {
return fmt.Errorf("%s is required", HostnameEnv)
}
if config.TSControlURL == "" {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.HPAuthKey == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv)
}
return nil
}
// Pings the Tailscale control server to make sure it's up and running
func validateTSReady(config *Config) error {
testURL := config.TSControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to TS control server: %s", resp.Status)
}
return nil
}
// Pings the Headplane server to make sure it's up and running
func validateHPReady(config *Config) error {
testURL := config.HPControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/healthz", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to HP control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
}
return nil
}

View File

@ -0,0 +1,83 @@
package hpagent
import (
"encoding/json"
"log"
"sync"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string `json:omitempty`
}
// Starts listening for messages from the Headplane master
func (s *Socket) StartListening() {
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Printf("error reading message: %v", err)
return
}
var msg RecvMessage
err = json.Unmarshal(message, &msg)
if err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if s.Debug {
log.Printf("got message: %s", message)
}
if len(msg.NodeIDs) == 0 {
log.Printf("got a message with no node IDs? %s", message)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID)
if err != nil {
log.Printf("error getting status: %v", err)
return
}
if result == nil {
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
err = s.SendStatus(results)
if err != nil {
log.Printf("error sending status: %v", err)
return
}
if s.Debug {
log.Printf("sent status: %s", results)
}
}
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
}

View File

@ -0,0 +1,11 @@
package hpagent
import (
"tailscale.com/tailcfg"
)
// Sends the status to the Headplane master
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
err := s.WriteJSON(status)
return err
}

View File

@ -0,0 +1,63 @@
package hpagent
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}

View File

@ -0,0 +1,47 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}

View File

@ -0,0 +1,61 @@
package tsnet
import (
"context"
"fmt"
"log"
"os"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
Debug bool
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
AuthKey: authKey,
Logf: func(string, ...interface{}) {}, // Disabled by default
}
if debug {
s.Logf = log.New(
os.Stderr,
fmt.Sprintf("[DBG:%s] ", hostname),
log.LstdFlags,
).Printf
}
return &TSAgent{s, nil, "", debug}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) StartAndFetchID() {
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatalf("Failed to start agent: %v", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatalf("Failed to create local client: %v", err)
}
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
s.ID = string(status.Self.ID)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}

View File

@ -0,0 +1,75 @@
import { Check, Copy } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
export interface AttributeProps {
name: string;
value: string;
isCopyable?: boolean;
link?: string;
suppressHydrationWarning?: boolean;
}
export default function Attribute({
name,
value,
link,
isCopyable,
suppressHydrationWarning,
}: AttributeProps) {
return (
<dl className="flex items-center w-full gap-x-1">
<dt className="font-semibold w-1/4 shrink-0 text-sm">
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
)}
</dt>
<dd
suppressHydrationWarning={suppressHydrationWarning}
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
'focus-within:outline-none focus-within:ring-2',
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
>
{isCopyable ? (
<button
type="button"
className="w-full flex items-center gap-x-1 outline-none"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
await navigator.clipboard.writeText(value);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<p
suppressHydrationWarning={suppressHydrationWarning}
className="truncate"
>
{value}
</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
) : (
value
)}
</dd>
</dl>
);
}

View File

@ -0,0 +1,43 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: variant === 'danger'
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
}
function Card({ variant = 'raised', ...props }: Props) {
return (
<div
{...props}
className={cn(
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(Card, { Title, Text });

View File

@ -0,0 +1,32 @@
import React from 'react';
import cn from '~/utils/cn';
export interface ChipProps {
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export default function Chip({
text,
className,
leftIcon,
rightIcon,
}: ChipProps) {
return (
<span
className={cn(
'text-xs px-2 py-0.5 rounded-full',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}

View File

@ -0,0 +1,50 @@
import { Check, Copy } from 'lucide-react';
import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[];
}
export default function Code({ isCopyable, children, className }: CodeProps) {
return (
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-none focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{isCopyable && (
<button
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
)}
</code>
);
}

View File

@ -0,0 +1,194 @@
import React, { cloneElement, useEffect, useRef } from 'react';
import {
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from 'react-aria';
import { Form, type HTMLFormMethod } from 'react-router';
import {
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import Card from '~/components/Card';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
return (
<Form
{...dialogProps}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === 'unactionable' ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
</Button>
</>
)}
</div>
</Card>
</Form>
);
}
interface DModalProps extends AriaModalOverlayProps {
children: React.ReactNode;
state: OverlayTriggerState;
}
function DModal(props: DModalProps) {
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
if (!state.isOpen) {
return null;
}
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
)}
/>
<div
{...modalProps}
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
)}
>
{children}
</div>
</Overlay>
);
}
export default Object.assign(Dialog, {
Button,
IconButton,
Panel,
Title,
Text,
});

View File

@ -0,0 +1,89 @@
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
interface Props {
type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return {
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
}
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const { title, message } = getMessage(error);
return (
<div
className={cn(
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : title}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data.message : message}
</Card.Text>
</Card>
</div>
);
}

View File

@ -0,0 +1,49 @@
import Link from '~/components/Link';
import cn from '~/utils/cn';
interface FooterProps {
url: string;
debug: boolean;
}
export default function Footer({ url, debug }: FooterProps) {
return (
<footer
className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14',
'flex flex-col justify-center gap-1 shadow-inner',
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-t dark:border-headplane-800',
)}
>
<p className="container text-xs">
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm',
)}
>
{url}
</button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'}
</p>
</footer>
);
}

View File

@ -0,0 +1,192 @@
import {
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
interface Props {
configAvailable: boolean;
onboarding: boolean;
user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
'after:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-none focus:ring',
isActive ? 'after:visible' : 'after:invisible',
)
}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
'hidden sm:block hover:underline text-sm',
'focus:outline-none focus:ring rounded-md',
)}
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<header
className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-800',
'shadow-inner',
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PlaneTakeoff />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton
label="User"
className={cn(data.user.picture ? 'p-0' : '')}
>
{data.user.picture ? (
<img
src={data.user.picture}
alt={data.user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
onAction={(key) => {
if (key === 'logout') {
submit(
{},
{
method: 'POST',
action: '/logout',
},
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-headplane-50">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
</div>
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
) : undefined}
{data.access.users ? (
<TabLink
to="/users"
name="Users"
icon={<Users className="w-5" />}
/>
) : undefined}
{data.access.policy ? (
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />}
/>
) : undefined}
{data.access.settings ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
) : undefined}
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}

View File

@ -0,0 +1,46 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({
variant = 'light',
...props
}: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
}

View File

@ -0,0 +1,84 @@
import { Asterisk } from 'lucide-react';
import { useRef } from 'react';
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
}
// TODO: Custom isInvalid logic for custom error messages
export default function Input(props: InputProps) {
const { label, labelHidden, className } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
'aria-label': label,
},
ref,
);
return (
<div className="flex flex-col w-full" aria-label={label}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
>
{label}
{props.isRequired && (
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
)}
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
import { ExternalLink } from 'lucide-react';
import cn from '~/utils/cn';
export interface LinkProps {
to: string;
name: string;
children: string;
className?: string;
}
export default function Link({
to,
name: alt,
children,
className,
}: LinkProps) {
return (
<a
href={to}
aria-label={alt}
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
'focus:outline-none focus:ring rounded-md',
className,
)}
>
{children}
<ExternalLink className="w-3.5" />
</a>
);
}

View File

@ -0,0 +1,170 @@
import React, { useRef, cloneElement } from 'react';
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
import { Item, Section } from 'react-stately';
import {
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
{},
state,
ref,
);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
const { separatorProps } = useSeparator({
elementType: 'li',
});
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn(
'mx-2 mt-1 mb-1 border-t',
'border-headplane-200 dark:border-headplane-800',
)}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-none select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
)}
>
{item.rendered}
</li>
);
}
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
});

View File

@ -0,0 +1,16 @@
import { CircleSlash2 } from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
export interface NoticeProps {
children: React.ReactNode;
}
export default function Notice({ children }: NoticeProps) {
return (
<Card className="flex w-full max-w-full gap-4 font-semibold">
<CircleSlash2 />
{children}
</Card>
);
}

View File

@ -0,0 +1,102 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useId,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
{...decrementButtonProps}
label="Decrement"
className="w-7.5 h-7.5 rounded-lg"
>
<Minus className="p-1" />
</IconButton>
<IconButton
{...incrementButtonProps}
label="Increment"
className="w-7.5 h-7.5 rounded-lg"
>
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,78 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });

View File

@ -0,0 +1,49 @@
import React, { useRef } from 'react';
import {
type AriaPopoverProps,
DismissButton,
Overlay,
usePopover,
} from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
}
export default function Popover(props: PopoverProps) {
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-sm rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}

View File

@ -0,0 +1,26 @@
import { useProgressBar } from 'react-aria';
import cn from '~/utils/cn';
export interface ProgressBarProps {
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: 'Loading...',
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
'bg-headplane-950 dark:bg-headplane-50',
isVisible && 'animate-loading opacity-100',
)}
/>
);
}

View File

@ -0,0 +1,83 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });

View File

@ -0,0 +1,172 @@
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import {
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from 'react-aria';
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
export interface SelectProps extends AriaComboBoxProps<object> {
className?: string;
}
function Select(props: SelectProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
ref={inputRef}
id={id}
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
return (
<li
{...optionProps}
ref={ref}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });

View File

@ -0,0 +1,21 @@
import clsx from 'clsx';
interface Props {
className?: string;
}
export default function Spinner({ className }: Props) {
return (
<div className={clsx('inline-block align-middle mb-0.5', className)}>
<div
className={clsx(
'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent',
className,
)}
>
<span className="sr-only">Loading...</span>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import cn from '~/utils/cn';
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
}
export default function StatusCircle({
isOnline,
className,
}: StatusCircleProps) {
return (
<svg
className={cn(
isOnline
? 'text-green-600 dark:text-green-500'
: 'text-headplane-200 dark:text-headplane-800',
className,
)}
viewBox="0 0 24 24"
fill="currentColor"
>
<title>{isOnline ? 'Online' : 'Offline'}</title>
<circle cx="12" cy="12" r="8" />
</svg>
);
}

View File

@ -0,0 +1,61 @@
import { useRef } from 'react';
import {
AriaSwitchProps,
VisuallyHidden,
useFocusRing,
useSwitch,
} from 'react-aria';
import { useToggleState } from 'react-stately';
import cn from '~/utils/cn';
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
}
export default function Switch(props: SwitchProps) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
'aria-label': props.label,
},
state,
ref,
);
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input
{...inputProps}
{...focusProps}
aria-label={props.label}
ref={ref}
/>
</VisuallyHidden>
<div
aria-hidden
className={cn(
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
'bg-headplane-300 dark:bg-headplane-700',
'border border-transparent dark:border-headplane-800',
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
state.isSelected && 'translate-x-[100%]',
)}
/>
</div>
</label>
);
}

View File

@ -0,0 +1,34 @@
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
}
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'border-b border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(TableList, { Item });

View File

@ -0,0 +1,90 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
>
{rendered}
</div>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });

View File

@ -0,0 +1,11 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TextProps {
children: React.ReactNode;
className?: string;
}
export default function Text({ children, className }: TextProps) {
return <p className={cn('text-md my-0', className)}>{children}</p>;
}

View File

@ -0,0 +1,13 @@
import React from 'react';
import cn from '~/utils/cn';
export interface TitleProps {
children: React.ReactNode;
className?: string;
}
export default function Title({ children, className }: TitleProps) {
return (
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
);
}

View File

@ -0,0 +1,87 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
}

View File

@ -0,0 +1,83 @@
import React, { cloneElement, useRef } from 'react';
import {
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-none focus:ring rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-none rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
>
{props.children}
</span>
);
}
export default Object.assign(Tooltip, {
Body,
});

View File

@ -0,0 +1,18 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});

View File

@ -0,0 +1,66 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? 'onAllReady'
: 'onShellReady';
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
// biome-ignore lint/style/noParameterAssign: Lazy
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}

View File

@ -0,0 +1,72 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import log from '~/utils/log';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) {
try {
await context.client.get('v1/apikey', session.get('api_key')!);
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
}
}
return {
healthy,
};
}
export default function Layout() {
const { healthy } = useLoaderData<typeof loader>();
return (
<>
{!healthy ? (
<div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}
>
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}
>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}

View File

@ -0,0 +1,169 @@
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
}
if (!onboarded) {
return redirect('/onboarding');
}
}
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
// No session, so we can just return
return redirect('/login');
}
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...data} />
</>
);
}

69
headplane/app/root.tsx Normal file
View File

@ -0,0 +1,69 @@
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{
name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
</body>
</html>
</LiveDataProvider>
);
}
export function ErrorBoundary() {
return <ErrorPopup />;
}
export default function App() {
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
);
}

36
headplane/app/routes.ts Normal file
View File

@ -0,0 +1,36 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// Authentication Routes
route('/login', 'routes/auth/login.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];

View File

@ -0,0 +1,113 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data400('Missing `policy` in the form data.');
}
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
policy: policyData,
});
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -0,0 +1,64 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
// 1. Does the user have permission to read the policy?
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
// Try to load the ACL policy from the API.
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
flags.writable = true;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -0,0 +1,112 @@
import * as shopify from '@shopify/lang-jsonc';
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
import CodeMirror from '@uiw/react-codemirror';
import { BookCopy, CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import Merge from 'react-codemirror-merge';
import { ErrorBoundary } from 'react-error-boundary';
import { ClientOnly } from 'remix-utils/client-only';
import Fallback from './fallback';
interface EditorProps {
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">Failed to load the editor.</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
);
}
interface DifferProps {
left: string;
right: string;
}
export function Differ(props: DifferProps) {
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches);
});
});
return (
<div className="text-sm">
{props.left === props.right ? (
<div className="flex flex-col items-center gap-2.5 py-8">
<BookCopy />
<p className="text-lg font-semibold">No changes</p>
</div>
) : (
<div className="h-editor overflow-y-scroll">
<ErrorBoundary
fallback={
<div className="flex flex-col items-center gap-2.5 py-8">
<CircleX />
<p className="text-lg font-semibold">
Failed to load the editor.
</p>
</div>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { AlertIcon } from '@primer/octicons-react';
import React from 'react';
import Card from '~/components/Card';
interface NoticeViewProps {
title: string;
children: React.ReactNode;
}
export function NoticeView({ children, title }: NoticeViewProps) {
return (
<Card variant="flat" className="max-w-2xl my-8">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">{title}</Card.Title>
<AlertIcon className="w-8 h-8 text-yellow-500" />
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
interface ErrorViewProps {
children: string;
}
export function ErrorView({ children }: ErrorViewProps) {
const [title, ...rest] = children.split(':');
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
return (
<Card variant="flat" className="max-w-2xl mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
{title.trim() ?? 'Error'}
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to the ACL policy:
<br />
<span className="font-mono">{formattedMessage}</span>
</Card.Text>
</Card>
);
}

View File

@ -0,0 +1,36 @@
import cn from '~/utils/cn';
interface Props {
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
return (
<div className="relative w-full h-editor flex">
<div
className={cn(
'h-full w-8 flex justify-center p-1',
'border-r border-headscale-400 dark:border-headscale-800',
)}
>
<div
aria-hidden
className={cn(
'h-5 w-5 animate-spin rounded-full',
'border-headplane-900 dark:border-headplane-100',
'border-2 border-t-transparent dark:border-t-transparent',
)}
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
value={acl}
/>
</div>
);
}

View File

@ -0,0 +1,173 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { ErrorView, NoticeView } from './components/error';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<NoticeView title="ACL Policy restricted">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</NoticeView>
) : !writable ? (
<NoticeView title="Read-only ACL Policy">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
configuration.
</NoticeView>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<ErrorView>{fetcher.data.error}</ErrorView>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
console.log(codePolicy);
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}

View File

@ -0,0 +1,186 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
return {
error: 'Invalid API key',
};
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
) : undefined}
{oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import type { LoadContext } from '~/server';
export async function loader() {
return redirect('/machines');
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login
? '/login?s=logout'
: '/login';
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}

View File

@ -0,0 +1,69 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
}
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}

View File

@ -0,0 +1,42 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
return redirect('/machines');
}
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
);
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await context.sessions.commit(session),
},
});
}

View File

@ -0,0 +1,205 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
interface Props {
searchDomains: string[];
isDisabled: boolean;
magic?: string;
}
export default function ManageDomains({
searchDomains,
isDisabled,
magic,
}: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{magic ? (
<TableList.Item key="magic-dns-sd">
<div
className={cn(
'flex items-center gap-4',
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
)}
>
<Lock className="p-0.5" />
<p className="font-mono text-sm py-0.5">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
method="POST"
className="flex items-center justify-between w-full"
>
<input type="hidden" name="action_id" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
interface DomainProps {
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
}
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
)}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="font-mono text-sm flex items-center gap-4">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring outline-none rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
)}
</TableList.Item>
);
}

View File

@ -0,0 +1,99 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
interface Props {
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function ManageNS({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
name={key}
/>
))}
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input
type="hidden"
name="split_name"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}

View File

@ -0,0 +1,75 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddRecord from '../dialogs/add-record';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
isDisabled: boolean;
}
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-24 items-center">
<div className="flex gap-4 items-center">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md',
'bg-headplane-100 dark:bg-headplane-700/30',
)}
>
{record.type}
</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
)}
</TableList>
{isDisabled ? undefined : <AddRecord records={records} />}
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
name: string;
isDisabled: boolean;
}
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm"
label="Tailnet name"
value={name}
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<Input
isRequired
label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
/>
</Dialog.Panel>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,31 @@
import Dialog from '~/components/Dialog';
interface Props {
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,94 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Switch from '~/components/Switch';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
const isInvalid = useMemo(() => {
if (ns === '') return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input type="hidden" name="action_id" value="add_ns" />
<Input
isRequired
label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns"
onChange={setNs}
isInvalid={isInvalid}
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Chip
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')}
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
for most platforms) will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input
isRequired={split === true}
label="Domain"
placeholder="example.com"
name="split_name"
onChange={setDomain}
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : (
<input type="hidden" name="split_name" value="global" />
)}
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,59 @@
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<input type="hidden" name="record_type" value="A" />
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder="101.101.101.101"
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address{' '}
<Code>{ip}</Code> already exists.
</p>
) : undefined}
</div>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,232 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData, context);
case 'add_ns':
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await context.integration?.onConfigChange(context.client);
}

View File

@ -0,0 +1,110 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
import RenameTailnet from './components/rename-tailnet';
import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
const check = await context.sessions.check(
request,
Capabilities.read_network,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_network,
);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
};
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
};
}
export async function action(data: ActionFunctionArgs) {
return dnsAction(data);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key];
}
allNs.global = data.nameservers;
const isDisabled = data.access === false || data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.writable ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
{data.access ? undefined : (
<Notice>
Your permissions do not allow you to modify the DNS settings for this
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageDomains
searchDomains={data.searchDomains}
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
/>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="mb-4">
Automatically register domain names for each device on the tailnet.
Devices will be accessible at{' '}
<Code>
[device].
{data.baseDomain}
</Code>{' '}
when Magic DNS is enabled.
</p>
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
</div>
</div>
);
}

View File

@ -0,0 +1,201 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { useMemo } from 'react';
import { Link } from 'react-router';
import Chip from '~/components/Chip';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import type { HostInfo, Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
isDisabled?: boolean;
}
export default function MachineRow({
machine,
routes,
users,
isAgent,
magic,
stats,
isDisabled,
}: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
}
const prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic;
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnetApproved: Route[];
subnet: Route[];
}>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
if (route.enabled) {
acc.subnetApproved.push(route);
return acc;
}
acc.subnet.push(route);
return acc;
},
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
if (exitEnabled) {
tags.unshift('Exit Node');
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets');
}
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return (
<tr
key={machine.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2 focus-within:ring">
<Link
to={`/machines/${machine.id}`}
className={cn('group/link h-full focus:outline-none')}
>
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm font-mono opacity-50">{machine.name}</p>
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard');
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn(
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
>
{ip}
<CopyIcon className="w-3 h-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p suppressHydrationWarning>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
isDisabled={isDisabled}
/>
</td>
</tr>
);
}

View File

@ -0,0 +1,142 @@
import { Cog, Ellipsis } from 'lucide-react';
import { useState } from 'react';
import Menu from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
machine,
routes,
magic,
users,
isFullButton,
isDisabled,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
return (
<>
{modal === 'remove' && (
<Delete
machine={machine}
isOpen={modal === 'remove'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'move' && (
<Move
machine={machine}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'rename' && (
<Rename
machine={machine}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
isOpen={modal === 'routes'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'tags' && (
<Tags
machine={machine}
isOpen={modal === 'tags'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
isOpen={modal === 'expire'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
) : (
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
<Menu.Item key="move">Change owner</Menu.Item>
</Menu.Section>
<Menu.Section>
{expired ? (
<></>
) : (
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
)}
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}

View File

@ -0,0 +1,30 @@
import { useNavigate } from 'react-router';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
interface DeleteProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
const navigate = useNavigate();
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant="destructive"
onSubmit={() => navigate('/machines')}
>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,24 @@
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
interface ExpireProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,35 @@
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
interface MoveProps {
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
>
{users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More