chore: Cleanup unnecessary files and commit headplane patches
This commit is contained in:
parent
683c7ca545
commit
8a67df3a37
@ -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";
|
||||
}
|
||||
187
flake.lock.bak
187
flake.lock.bak
@ -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
|
||||
}
|
||||
187
flake.lock.bak2
187
flake.lock.bak2
@ -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
|
||||
}
|
||||
187
flake.lock.bak3
187
flake.lock.bak3
@ -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
|
||||
}
|
||||
173
flake.lock.bak4
173
flake.lock.bak4
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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 = {};
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
14087
headplane-node2nix/package-lock.json
generated
14087
headplane-node2nix/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
];
|
||||
|
||||
|
||||
}
|
||||
@ -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
7
headplane/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
1
headplane/.envrc
Normal file
1
headplane/.envrc
Normal file
@ -0,0 +1 @@
|
||||
use_flake
|
||||
2
headplane/.github/FUNDING.yml
vendored
Normal file
2
headplane/.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
github: tale
|
||||
ko_fi: atale
|
||||
31
headplane/.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
31
headplane/.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal 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
|
||||
1
headplane/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
headplane/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
15
headplane/.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
15
headplane/.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal 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
|
||||
33
headplane/.github/workflows/automated.yaml
vendored
Normal file
33
headplane/.github/workflows/automated.yaml
vendored
Normal 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
76
headplane/.github/workflows/build.yaml
vendored
Normal 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
55
headplane/.github/workflows/next.yaml
vendored
Normal 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
|
||||
53
headplane/.github/workflows/release.yaml
vendored
Normal file
53
headplane/.github/workflows/release.yaml
vendored
Normal 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
6
headplane/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
/.react-router
|
||||
/.cache
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
2
headplane/.npmrc
Normal file
2
headplane/.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
side-effects-cache = false
|
||||
public-hoist-pattern[]=*hono*
|
||||
2
headplane/.tool-versions
Normal file
2
headplane/.tool-versions
Normal file
@ -0,0 +1,2 @@
|
||||
pnpm 10.4.0
|
||||
node 22
|
||||
17
headplane/.zed/settings.json
Normal file
17
headplane/.zed/settings.json
Normal 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
239
headplane/CHANGELOG.md
Normal 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
18
headplane/Dockerfile
Normal 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
21
headplane/LICENSE
Normal 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
89
headplane/README.md
Normal 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
|
||||
15
headplane/agent.Dockerfile
Normal file
15
headplane/agent.Dockerfile
Normal 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"]
|
||||
40
headplane/agent/cmd/hp_agent/hp_agent.go
Normal file
40
headplane/agent/cmd/hp_agent/hp_agent.go
Normal 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()
|
||||
}
|
||||
56
headplane/agent/config/config.go
Normal file
56
headplane/agent/config/config.go
Normal 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
|
||||
}
|
||||
73
headplane/agent/config/preflight.go
Normal file
73
headplane/agent/config/preflight.go
Normal 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
|
||||
}
|
||||
83
headplane/agent/hpagent/handler.go
Normal file
83
headplane/agent/hpagent/handler.go
Normal 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()
|
||||
}
|
||||
11
headplane/agent/hpagent/sender.go
Normal file
11
headplane/agent/hpagent/sender.go
Normal 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
|
||||
}
|
||||
63
headplane/agent/hpagent/websocket.go
Normal file
63
headplane/agent/hpagent/websocket.go
Normal 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
|
||||
}
|
||||
47
headplane/agent/tsnet/peers.go
Normal file
47
headplane/agent/tsnet/peers.go
Normal 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
|
||||
}
|
||||
61
headplane/agent/tsnet/server.go
Normal file
61
headplane/agent/tsnet/server.go
Normal 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()
|
||||
}
|
||||
75
headplane/app/components/Attribute.tsx
Normal file
75
headplane/app/components/Attribute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
headplane/app/components/Button.tsx
Normal file
43
headplane/app/components/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
headplane/app/components/Card.tsx
Normal file
28
headplane/app/components/Card.tsx
Normal 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 });
|
||||
32
headplane/app/components/Chip.tsx
Normal file
32
headplane/app/components/Chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
headplane/app/components/Code.tsx
Normal file
50
headplane/app/components/Code.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
headplane/app/components/Dialog.tsx
Normal file
194
headplane/app/components/Dialog.tsx
Normal 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,
|
||||
});
|
||||
89
headplane/app/components/Error.tsx
Normal file
89
headplane/app/components/Error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
headplane/app/components/Footer.tsx
Normal file
49
headplane/app/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
headplane/app/components/Header.tsx
Normal file
192
headplane/app/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
headplane/app/components/IconButton.tsx
Normal file
46
headplane/app/components/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
headplane/app/components/Input.tsx
Normal file
84
headplane/app/components/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
headplane/app/components/Link.tsx
Normal file
35
headplane/app/components/Link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
headplane/app/components/Menu.tsx
Normal file
170
headplane/app/components/Menu.tsx
Normal 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,
|
||||
});
|
||||
16
headplane/app/components/Notice.tsx
Normal file
16
headplane/app/components/Notice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
headplane/app/components/NumberInput.tsx
Normal file
102
headplane/app/components/NumberInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
headplane/app/components/Options.tsx
Normal file
78
headplane/app/components/Options.tsx
Normal 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 });
|
||||
49
headplane/app/components/Popover.tsx
Normal file
49
headplane/app/components/Popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
headplane/app/components/ProgressBar.tsx
Normal file
26
headplane/app/components/ProgressBar.tsx
Normal 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',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
83
headplane/app/components/RadioGroup.tsx
Normal file
83
headplane/app/components/RadioGroup.tsx
Normal 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 });
|
||||
172
headplane/app/components/Select.tsx
Normal file
172
headplane/app/components/Select.tsx
Normal 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 });
|
||||
21
headplane/app/components/Spinner.tsx
Normal file
21
headplane/app/components/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
headplane/app/components/StatusCircle.tsx
Normal file
27
headplane/app/components/StatusCircle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
headplane/app/components/Switch.tsx
Normal file
61
headplane/app/components/Switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
headplane/app/components/TableList.tsx
Normal file
34
headplane/app/components/TableList.tsx
Normal 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 });
|
||||
90
headplane/app/components/Tabs.tsx
Normal file
90
headplane/app/components/Tabs.tsx
Normal 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 });
|
||||
11
headplane/app/components/Text.tsx
Normal file
11
headplane/app/components/Text.tsx
Normal 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>;
|
||||
}
|
||||
13
headplane/app/components/Title.tsx
Normal file
13
headplane/app/components/Title.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
headplane/app/components/ToastProvider.tsx
Normal file
87
headplane/app/components/ToastProvider.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
83
headplane/app/components/Tooltip.tsx
Normal file
83
headplane/app/components/Tooltip.tsx
Normal 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,
|
||||
});
|
||||
18
headplane/app/entry.client.tsx
Normal file
18
headplane/app/entry.client.tsx
Normal 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>,
|
||||
);
|
||||
});
|
||||
66
headplane/app/entry.server.tsx
Normal file
66
headplane/app/entry.server.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
72
headplane/app/layouts/dashboard.tsx
Normal file
72
headplane/app/layouts/dashboard.tsx
Normal 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" />;
|
||||
}
|
||||
169
headplane/app/layouts/shell.tsx
Normal file
169
headplane/app/layouts/shell.tsx
Normal 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
69
headplane/app/root.tsx
Normal 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
36
headplane/app/routes.ts
Normal 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'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
];
|
||||
113
headplane/app/routes/acls/acl-action.ts
Normal file
113
headplane/app/routes/acls/acl-action.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
64
headplane/app/routes/acls/acl-loader.ts
Normal file
64
headplane/app/routes/acls/acl-loader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
112
headplane/app/routes/acls/components/cm.client.tsx
Normal file
112
headplane/app/routes/acls/components/cm.client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
headplane/app/routes/acls/components/error.tsx
Normal file
45
headplane/app/routes/acls/components/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
headplane/app/routes/acls/components/fallback.tsx
Normal file
36
headplane/app/routes/acls/components/fallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
headplane/app/routes/acls/overview.tsx
Normal file
173
headplane/app/routes/acls/overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
headplane/app/routes/auth/login.tsx
Normal file
186
headplane/app/routes/auth/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
headplane/app/routes/auth/logout.ts
Normal file
28
headplane/app/routes/auth/logout.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
69
headplane/app/routes/auth/oidc-callback.ts
Normal file
69
headplane/app/routes/auth/oidc-callback.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
42
headplane/app/routes/auth/oidc-start.ts
Normal file
42
headplane/app/routes/auth/oidc-start.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
205
headplane/app/routes/dns/components/manage-domains.tsx
Normal file
205
headplane/app/routes/dns/components/manage-domains.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
headplane/app/routes/dns/components/manage-ns.tsx
Normal file
99
headplane/app/routes/dns/components/manage-ns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
headplane/app/routes/dns/components/manage-records.tsx
Normal file
75
headplane/app/routes/dns/components/manage-records.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
headplane/app/routes/dns/components/rename-tailnet.tsx
Normal file
48
headplane/app/routes/dns/components/rename-tailnet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
headplane/app/routes/dns/components/toggle-magic.tsx
Normal file
31
headplane/app/routes/dns/components/toggle-magic.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
headplane/app/routes/dns/dialogs/add-ns.tsx
Normal file
94
headplane/app/routes/dns/dialogs/add-ns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
headplane/app/routes/dns/dialogs/add-record.tsx
Normal file
59
headplane/app/routes/dns/dialogs/add-record.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
headplane/app/routes/dns/dns-actions.ts
Normal file
232
headplane/app/routes/dns/dns-actions.ts
Normal 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);
|
||||
}
|
||||
110
headplane/app/routes/dns/overview.tsx
Normal file
110
headplane/app/routes/dns/overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
headplane/app/routes/machines/components/machine-row.tsx
Normal file
201
headplane/app/routes/machines/components/machine-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
headplane/app/routes/machines/components/menu.tsx
Normal file
142
headplane/app/routes/machines/components/menu.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
headplane/app/routes/machines/dialogs/delete.tsx
Normal file
30
headplane/app/routes/machines/dialogs/delete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
headplane/app/routes/machines/dialogs/expire.tsx
Normal file
24
headplane/app/routes/machines/dialogs/expire.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
headplane/app/routes/machines/dialogs/move.tsx
Normal file
35
headplane/app/routes/machines/dialogs/move.tsx
Normal 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
Loading…
Reference in New Issue
Block a user