├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── docker-build.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Caddyfile ├── Dockerfile ├── LICENSE ├── README.md ├── build.cmd ├── example ├── Caddyfile ├── docker-compose.yml └── headscale-conf │ └── config.yml ├── extract.cmd ├── img ├── API-Info.png ├── API-Tokens.png ├── HA-ACL-Config-Load.png ├── HA-ACL-Config.png ├── HA-ACL-Groups.png ├── HA-ACL-Hosts.png ├── HA-ACL-Policies-Entry.png ├── HA-ACL-Policies.png ├── HA-ACL-SSH-Entry.png ├── HA-ACL-SSH.png ├── HA-ACL-TagOwners.png ├── HA-Deploy.png ├── HA-Home.png ├── HA-Nodes-List.png ├── HA-Nodes-Tile.png ├── HA-Routes.png ├── HA-Settings.png ├── HA-Users-List.png ├── HA-Users-Tile.png ├── Token-DNS-Edit.png └── Token-Zone-Read.png ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── src ├── app.d.ts ├── app.html ├── app.postcss ├── index.test.ts ├── lib │ ├── Navigation.svelte │ ├── States.svelte.ts │ ├── cards │ │ ├── CardListContainer.svelte │ │ ├── CardListEntry.svelte │ │ ├── CardListItem.svelte │ │ ├── CardListPage.svelte │ │ ├── CardSeparator.svelte │ │ ├── CardTileContainer.svelte │ │ ├── CardTileEntry.svelte │ │ ├── CardTilePage.svelte │ │ ├── acl │ │ │ ├── GroupListCard.svelte │ │ │ ├── HostListCard.svelte │ │ │ ├── ListEntry.svelte │ │ │ ├── PolicyListCard.svelte │ │ │ ├── SshRuleListCard.svelte │ │ │ └── TagOwnerListCard.svelte │ │ ├── common │ │ │ ├── ItemCreatedAt.svelte │ │ │ ├── ItemDelete.svelte │ │ │ └── ItemListName.svelte │ │ ├── node │ │ │ ├── NodeAddresses.svelte │ │ │ ├── NodeCreate.svelte │ │ │ ├── NodeExpiresAt.svelte │ │ │ ├── NodeHostname.svelte │ │ │ ├── NodeInfo.svelte │ │ │ ├── NodeLastSeen.svelte │ │ │ ├── NodeListCard.svelte │ │ │ ├── NodeOwner.svelte │ │ │ ├── NodeRegistrationMethod.svelte │ │ │ ├── NodeRoute.svelte │ │ │ ├── NodeRoutes.svelte │ │ │ ├── NodeTags.svelte │ │ │ └── NodeTileCard.svelte │ │ ├── route │ │ │ ├── RouteInfo.svelte │ │ │ ├── RouteListCard.svelte │ │ │ └── RouteTileCard.svelte │ │ └── user │ │ │ ├── UserCreate.svelte │ │ │ ├── UserDisplayName.svelte │ │ │ ├── UserEmail.svelte │ │ │ ├── UserInfo.svelte │ │ │ ├── UserListCard.svelte │ │ │ ├── UserListNodes.svelte │ │ │ ├── UserListPreAuthKey.svelte │ │ │ ├── UserListPreAuthKeys.svelte │ │ │ ├── UserProvider.svelte │ │ │ └── UserTileCard.svelte │ ├── common │ │ ├── acl.svelte.ts │ │ ├── api │ │ │ ├── base.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── get.ts │ │ │ ├── index.ts │ │ │ ├── modify.ts │ │ │ └── url.ts │ │ ├── debug.ts │ │ ├── errors.ts │ │ ├── funcs.ts │ │ ├── themes.ts │ │ ├── types.ts │ │ └── usables.ts │ ├── index.ts │ ├── page │ │ ├── DrawerEntry.svelte │ │ ├── Page.svelte │ │ ├── PageDrawer.svelte │ │ └── PageHeader.svelte │ └── parts │ │ ├── CloseBtn.svelte │ │ ├── Delete.svelte │ │ ├── FilterOnlineBtn.svelte │ │ ├── LoaderModal.svelte │ │ ├── MultiSelect.svelte │ │ ├── NewItem.svelte │ │ ├── OnlineNodeIndicator.svelte │ │ ├── OnlineUserIndicator.svelte │ │ ├── SortBtn.svelte │ │ ├── Tabbed.svelte │ │ └── Text.svelte └── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ ├── acls │ ├── +page.svelte │ ├── Config.svelte │ ├── Groups.svelte │ ├── Hosts.svelte │ ├── Policies.svelte │ ├── SshRules.svelte │ └── TagOwners.svelte │ ├── deploy │ ├── +page.svelte │ └── DeployCheck.svelte │ ├── nodes │ └── +page.svelte │ ├── routes │ └── +page.svelte │ ├── settings │ └── +page.svelte │ └── users │ └── +page.svelte ├── static ├── favicon.png └── fonts │ ├── AbrilFatface.ttf │ ├── PlayfairDisplay-Italic.ttf │ ├── Quicksand.ttf │ └── SpaceGrotesk.ttf ├── svelte.config.js ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:svelte/recommended', 7 | 'prettier' 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint'], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020, 14 | extraFileExtensions: ['.svelte'] 15 | }, 16 | env: { 17 | browser: true, 18 | es2017: true, 19 | node: true 20 | }, 21 | overrides: [ 22 | { 23 | files: ['*.svelte'], 24 | parser: 'svelte-eslint-parser', 25 | parserOptions: { 26 | parser: '@typescript-eslint/parser' 27 | } 28 | } 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | on: 3 | push: 4 | branches: 5 | - dev 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Qemu 12 | uses: docker/setup-qemu-action@v1 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v1 15 | - name: Log in to DockerHub 16 | uses: docker/login-action@v1 17 | with: 18 | username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 20 | - name: Build and Push 21 | uses: docker/build-push-action@v2 22 | with: 23 | context: . 24 | file: ./Dockerfile 25 | push: true 26 | tags: goodieshq/headscale-admin:dev 27 | platforms: linux/amd64,linux/arm64,linux/arm64/v8 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Deployment folder I created 2 | TRAEFIK 3 | .history 4 | *.old.svelte 5 | 6 | # VSCode 7 | .vscode 8 | 9 | # Svelte: 10 | .svelte-kit 11 | build/ 12 | 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | lerna-debug.log* 20 | .pnpm-debug.log* 21 | 22 | # Diagnostic reports (https://nodejs.org/api/report.html) 23 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | *.pid.lock 30 | 31 | # Directory for instrumented libs generated by jscoverage/JSCover 32 | lib-cov 33 | 34 | # Coverage directory used by tools like istanbul 35 | coverage 36 | *.lcov 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (https://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | 57 | # Snowpack dependency directory (https://snowpack.dev/) 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | *.tsbuildinfo 62 | 63 | # Optional npm cache directory 64 | .npm 65 | 66 | # Optional eslint cache 67 | .eslintcache 68 | 69 | # Optional stylelint cache 70 | .stylelintcache 71 | 72 | # Microbundle cache 73 | .rpt2_cache/ 74 | .rts2_cache_cjs/ 75 | .rts2_cache_es/ 76 | .rts2_cache_umd/ 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variable files 88 | .env 89 | .env.development.local 90 | .env.test.local 91 | .env.production.local 92 | .env.local 93 | 94 | # parcel-bundler cache (https://parceljs.org/) 95 | .cache 96 | .parcel-cache 97 | 98 | # Next.js build output 99 | .next 100 | out 101 | 102 | # Nuxt.js build / generate output 103 | .nuxt 104 | dist 105 | 106 | # Gatsby files 107 | .cache/ 108 | # Comment in the public line in if your project uses Gatsby and not Next.js 109 | # https://nextjs.org/blog/next-9-1#public-directory-support 110 | # public 111 | 112 | # vuepress build output 113 | .vuepress/dist 114 | 115 | # vuepress v2.x temp and cache directory 116 | .temp 117 | .cache 118 | 119 | # Docusaurus cache and generated files 120 | .docusaurus 121 | 122 | # Serverless directories 123 | .serverless/ 124 | 125 | # FuseBox cache 126 | .fusebox/ 127 | 128 | # DynamoDB Local files 129 | .dynamodb/ 130 | 131 | # TernJS port file 132 | .tern-port 133 | 134 | # Stores VSCode versions used for testing VSCode extensions 135 | .vscode-test 136 | 137 | # yarn v2 138 | .yarn/cache 139 | .yarn/unplugged 140 | .yarn/build-state.yml 141 | .yarn/install-state.gz 142 | .pnp.* 143 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | :{$PORT:80} 2 | root * /app 3 | encode gzip zstd 4 | try_files {path}.html {path} 5 | file_server 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ENDPOINT=/admin 2 | ARG PORT=80 3 | 4 | FROM node:20-alpine AS build 5 | ARG ENDPOINT 6 | ENV ENDPOINT=$ENDPOINT 7 | 8 | # Set up app directory 9 | WORKDIR /app 10 | COPY package.json ./ 11 | COPY package-lock.json ./ 12 | 13 | # Install all dependencies 14 | RUN npm install 15 | 16 | # Copy all required build files 17 | COPY .eslintignore ./ 18 | COPY .eslintrc.cjs ./ 19 | COPY .npmrc ./ 20 | COPY .prettierignore ./ 21 | COPY .prettierrc ./ 22 | COPY postcss.config.cjs ./ 23 | COPY svelte.config.js ./ 24 | COPY tailwind.config.ts ./ 25 | COPY tsconfig.json ./ 26 | COPY vite.config.ts ./ 27 | 28 | # Copy source and static assets 29 | COPY static/ ./static/ 30 | COPY src/ ./src/ 31 | 32 | # Build static application, endpoint is provided by $ENDPOINT 33 | RUN npm run build 34 | 35 | FROM caddy:latest 36 | 37 | ARG ENDPOINT 38 | ARG PORT 39 | ENV PORT=${PORT} 40 | 41 | WORKDIR /app 42 | 43 | # Use the endpoint name as the directory so it can be served without URL stripping 44 | COPY --from=build /app/build/ ./${ENDPOINT} 45 | 46 | COPY Caddyfile /etc/caddy/Caddyfile 47 | CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] -------------------------------------------------------------------------------- /build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | SETLOCAL ENABLEDELAYEDEXPANSION 3 | 4 | REM Check if at least one parameter is provided 5 | if "%~1"=="" ( 6 | echo Usage: %~nx0 [version1] [version2] [version3] ... 7 | goto fail 8 | ) 9 | 10 | REM Initialize variables 11 | set "VERSION1=%~1" 12 | set "VERSIONPATH=%CD%\build\v%VERSION1%" 13 | 14 | REM Initialize the tags variable 15 | set "TAGS=" 16 | 17 | REM Iterate over all provided parameters 18 | :loop 19 | if "%~1"=="" goto after_loop 20 | set "TAGS=!TAGS! -t goodieshq/headscale-admin:%~1" 21 | shift 22 | goto loop 23 | :after_loop 24 | 25 | REM build and run the container 26 | docker buildx build --platform linux/amd64,linux/arm64 --build-arg ENDPOINT=/admin !TAGS! --push . 27 | docker run -d -v %VERSIONPATH%:/mnt --name headscale-tmp -it goodieshq/headscale-admin:%VERSION1% 28 | 29 | REM copy the build directory 30 | docker exec -it headscale-tmp /bin/sh -c "cp -r /app/admin /mnt/" 31 | docker container kill headscale-tmp 32 | docker container rm headscale-tmp 33 | 34 | REM create the .tar.gz, .zip, and .7z files 35 | 7z.exe a -ttar "%VERSIONPATH%\admin.tar" "%VERSIONPATH%\admin\*" 36 | 7z.exe a -tgzip -mx=9 "%VERSIONPATH%\admin.tar.gz" "%VERSIONPATH%\admin.tar" 37 | del %VERSIONPATH%\admin.tar 38 | 7z.exe a -tzip -mx=9 "%VERSIONPATH%\admin.zip" "%VERSIONPATH%\admin\*" 39 | 7z.exe a -tzip -mx=9 "%VERSIONPATH%\admin.zip" "%VERSIONPATH%\admin\*" 40 | 7z.exe a -t7z -mx=9 "%VERSIONPATH%\admin.7z" "%VERSIONPATH%\admin\*" 41 | exit 42 | 43 | :fail 44 | echo Error: At least one version parameter is required. 45 | exit /b 1 46 | 47 | :end 48 | ENDLOCAL -------------------------------------------------------------------------------- /example/Caddyfile: -------------------------------------------------------------------------------- 1 | (cors) { 2 | @cors_preflight method OPTIONS 3 | 4 | header { 5 | Access-Control-Allow-Origin "{args[0]}" 6 | Access-Control-Allow-Headers "*" 7 | Vary Origin 8 | Access-Control-Expose-Headers "Authorization" 9 | Access-Control-Allow-Credentials "true" 10 | } 11 | 12 | handle @cors_preflight { 13 | header { 14 | Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" 15 | Access-Control-Max-Age "3600" 16 | } 17 | respond "" 204 18 | } 19 | } 20 | 21 | headscale.example.com { 22 | ### Use this line if you need CORS for another domain, for example: 23 | # import cors https://headscale-admin.example.com 24 | 25 | # /admin endpoint maps to headscale-admin 26 | reverse_proxy /admin* headscale-admin:80 { 27 | header_up Host {http.request.host} 28 | } 29 | 30 | # all other endpoints go to headscale API 31 | reverse_proxy /* headscale:8080 32 | } -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: caddy:latest 4 | container_name: caddy 5 | restart: unless-stopped 6 | ports: 7 | - 80:80 8 | - 443:443 9 | volumes: 10 | - ./Caddyfile:/etc/caddy/Caddyfile 11 | - ./caddy-data:/data 12 | - ./caddy-conf:/config 13 | networks: 14 | - proxy 15 | 16 | headscale: 17 | image: headscale/headscale:0.25 18 | container_name: headscale 19 | restart: unless-stopped 20 | environment: 21 | - TZ=America/Los_Angeles 22 | volumes: 23 | - ./headscale-conf:/etc/headscale 24 | - ./headscale-data:/var/lib/headscale 25 | entrypoint: headscale serve 26 | ports: 27 | - 3478:3478/udp 28 | networks: 29 | - proxy 30 | 31 | headscale-admin: 32 | image: goodieshq/headscale-admin:0.25 33 | container_name: headscale-admin 34 | restart: unless-stopped 35 | networks: 36 | - proxy 37 | 38 | watchtower: 39 | container_name: watchtower 40 | image: containrrr/watchtower 41 | volumes: 42 | - /var/run/docker.sock:/var/run/docker.sock 43 | environment: 44 | WATCHTOWER_INTERVAL: 300 45 | restart: unless-stopped 46 | 47 | networks: 48 | proxy: -------------------------------------------------------------------------------- /example/headscale-conf/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | server_url: https://headscale.example.com 3 | 4 | listen_addr: 0.0.0.0:8080 5 | metrics_listen_addr: 0.0.0.0:9090 6 | grpc_listen_addr: 0.0.0.0:50443 7 | grpc_allow_insecure: false 8 | noise: 9 | private_key_path: /var/lib/headscale/noise_private.key 10 | 11 | prefixes: 12 | v4: 100.70.0.0/16 13 | v6: fd7a:115c:a1e0:0070::/64 14 | 15 | derp: 16 | server: 17 | enabled: true 18 | region_id: 999 19 | region_code: "headscale-server" 20 | region_name: "HeadScale" 21 | stun_listen_addr: "0.0.0.0:3478" 22 | automatically_add_embedded_derp_region: true 23 | private_key_path: /var/lib/headscale/private.key 24 | auto_update_enabled: true 25 | update_frequency: 4h 26 | # ipv4: 7.7.7.7 # optional: public IP forwarded to the DERP server on port 3478 27 | 28 | disable_check_updates: false 29 | ephemeral_node_inactivity_timeout: 30m 30 | node_update_check_interval: 10s 31 | 32 | database: 33 | type: sqlite3 34 | 35 | sqlite: 36 | path: /var/lib/headscale/hsdb.sqlite 37 | write_ahead_log: true 38 | wal_autocheckpoint: 1000 39 | 40 | acme_url: https://acme-v02.api.letsencrypt.org/directory 41 | acme_email: "" 42 | tls_letsencrypt_hostname: "" 43 | tls_letsencrypt_cache_dir: /var/lib/headscale/cache 44 | tls_letsencrypt_challenge_type: HTTP-01 45 | tls_letsencrypt_listen: ":http" 46 | 47 | tls_cert_path: "" 48 | tls_key_path: "" 49 | 50 | ### Log level and format, change as needed 51 | log: 52 | format: text 53 | level: info 54 | 55 | ### Policy must be in database mode for ACL builder 56 | policy: 57 | mode: "database" 58 | 59 | ### DNS settings, uncomment and modify as needed 60 | # dns: 61 | # override_local_dns: true 62 | # nameservers: 63 | # global: 64 | # - 1.1.1.2 65 | # - 1.0.0.2 66 | # split: 67 | # example.com: 68 | # - 10.1.1.100 69 | # search_domains: 70 | # - example.com 71 | # magic_dns: true 72 | # base_domain: ts.example.com 73 | 74 | unix_socket: /var/run/headscale/headscale.sock 75 | unix_socket_permission: "0770" 76 | 77 | ### OIDC setup. Enable as needed, example setup below: 78 | # oidc: 79 | # only_start_if_oidc_is_available: true 80 | # issuer: "https://login.microsoftonline.com/f113741c-606c-4247-9507-f46028b63b75/v2.0" 81 | # client_id: "94451126-69c9-43ab-9c6f-7cfaa5a42813" 82 | # client_secret: "RMe15YewU16q6UWXOCGNULpYdAEf39E8oDf8AtB7" 83 | # expiry: 7d 84 | # scope: ["openid", "profile", "email"] 85 | # strip_email_domain: true 86 | # extra_params: 87 | # domain_hint: example.com 88 | # prompt: select_account 89 | # allowed_domains: 90 | # - example.com 91 | # allowed_groups: 92 | # - fd151f4f-3a1a-4edb-895b-cc8b04ca49da 93 | 94 | logtail: 95 | enabled: false 96 | 97 | randomize_client_port: false # 41641 -------------------------------------------------------------------------------- /extract.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | set VERSION=%~1 4 | 5 | if "%VERSION%" == "" if [%VERSION%] == [] goto fail 6 | 7 | set VERSIONPATH="%CD%\build\v%VERSION%" 8 | 9 | docker run -d -v %VERSIONPATH%:/mnt --name headscale-tmp -it goodieshq/headscale-admin:%VERSION% 10 | 11 | REM copy the build directory 12 | docker exec -it headscale-tmp /bin/sh -c "cp -r /app/admin /mnt/" 13 | docker container kill headscale-tmp 14 | docker container rm headscale-tmp 15 | 16 | REM create the .tar.gz, .zip, and .7z files 17 | 7z.exe a -ttar "%VERSIONPATH%\admin.tar" "%VERSIONPATH%\admin\*" 18 | 7z.exe a -tgzip -mx=9 "%VERSIONPATH%\admin.tar.gz" "%VERSIONPATH%\admin.tar" 19 | del %VERSIONPATH%\admin.tar 20 | 7z.exe a -tzip -mx=9 "%VERSIONPATH%\admin.zip" "%VERSIONPATH%\admin\*" 21 | 7z.exe a -tzip -mx=9 "%VERSIONPATH%\admin.zip" "%VERSIONPATH%\admin\*" 22 | 7z.exe a -t7z -mx=9 "%VERSIONPATH%\admin.7z" "%VERSIONPATH%\admin\*" 23 | exit 24 | 25 | :fail 26 | echo "Usage: %~0 " -------------------------------------------------------------------------------- /img/API-Info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/API-Info.png -------------------------------------------------------------------------------- /img/API-Tokens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/API-Tokens.png -------------------------------------------------------------------------------- /img/HA-ACL-Config-Load.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-Config-Load.png -------------------------------------------------------------------------------- /img/HA-ACL-Config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-Config.png -------------------------------------------------------------------------------- /img/HA-ACL-Groups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-Groups.png -------------------------------------------------------------------------------- /img/HA-ACL-Hosts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-Hosts.png -------------------------------------------------------------------------------- /img/HA-ACL-Policies-Entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-Policies-Entry.png -------------------------------------------------------------------------------- /img/HA-ACL-Policies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-Policies.png -------------------------------------------------------------------------------- /img/HA-ACL-SSH-Entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-SSH-Entry.png -------------------------------------------------------------------------------- /img/HA-ACL-SSH.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-SSH.png -------------------------------------------------------------------------------- /img/HA-ACL-TagOwners.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-ACL-TagOwners.png -------------------------------------------------------------------------------- /img/HA-Deploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Deploy.png -------------------------------------------------------------------------------- /img/HA-Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Home.png -------------------------------------------------------------------------------- /img/HA-Nodes-List.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Nodes-List.png -------------------------------------------------------------------------------- /img/HA-Nodes-Tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Nodes-Tile.png -------------------------------------------------------------------------------- /img/HA-Routes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Routes.png -------------------------------------------------------------------------------- /img/HA-Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Settings.png -------------------------------------------------------------------------------- /img/HA-Users-List.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Users-List.png -------------------------------------------------------------------------------- /img/HA-Users-Tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/HA-Users-Tile.png -------------------------------------------------------------------------------- /img/Token-DNS-Edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/Token-DNS-Edit.png -------------------------------------------------------------------------------- /img/Token-Zone-Read.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/img/Token-Zone-Read.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headscale-admin", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "test": "vitest", 12 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 13 | "format": "prettier --plugin-search-dir . --write ." 14 | }, 15 | "devDependencies": { 16 | "@iconify/json": "^2.2.311", 17 | "@skeletonlabs/skeleton": "^2.11.0", 18 | "@skeletonlabs/tw-plugin": "^0.4.1", 19 | "@sveltejs/adapter-auto": "^4.0.0", 20 | "@sveltejs/adapter-static": "^3.0.8", 21 | "@sveltejs/kit": "^2.17.3", 22 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 23 | "@tailwindcss/forms": "0.5.10", 24 | "@tailwindcss/typography": "^0.5.16", 25 | "@types/node": "^22.13.5", 26 | "@typescript-eslint/eslint-plugin": "^8.25.0", 27 | "@typescript-eslint/parser": "^8.25.0", 28 | "autoprefixer": "10.4.20", 29 | "eslint": "^9.21.0", 30 | "eslint-config-prettier": "^10.0.2", 31 | "eslint-plugin-svelte": "^3.0.2", 32 | "postcss": "^8.5.3", 33 | "prettier": "^3.5.2", 34 | "prettier-plugin-svelte": "^3.3.3", 35 | "svelte": "^5.20.5", 36 | "svelte-check": "^4.1.4", 37 | "tailwindcss": "3.4.17", 38 | "tslib": "^2.8.1", 39 | "typescript": "^5.7.3", 40 | "vite": "^6.2.0", 41 | "vite-plugin-tailwind-purgecss": "0.3.5", 42 | "vitest": "^3.0.7" 43 | }, 44 | "type": "module", 45 | "dependencies": { 46 | "@floating-ui/dom": "^1.6.13", 47 | "async-mutex": "^0.5.0", 48 | "dompurify": "^3.2.4", 49 | "highlight.js": "11.11.1", 50 | "ipaddr.js": "^2.2.0", 51 | "js-xxhash": "^4.0.0", 52 | "json5": "^2.2.3", 53 | "svelte-jsoneditor": "^2.4.0", 54 | "unplugin-icons": "^22.1.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | // and what to do when importing types 4 | import 'unplugin-icons/types/svelte' 5 | 6 | declare namespace App { 7 | // interface Locals {} 8 | // interface PageData {} 9 | // interface Error {} 10 | // interface Platform {} 11 | } 12 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 | 13 |
14 | %sveltekit.body% 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/app.postcss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | 6 | /* 7 | html, 8 | body { 9 | @apply h-full overflow-scroll; 10 | } 11 | */ 12 | html, 13 | body { 14 | @apply overflow-y-scroll; 15 | } 16 | 17 | /* modern theme */ 18 | @font-face { 19 | font-family: 'Quicksand'; 20 | src: url('/fonts/Quicksand.ttf'); 21 | font-display: swap; 22 | } 23 | /* rocket theme */ 24 | @font-face { 25 | font-family: 'Space Grotesk'; 26 | src: url('/fonts/SpaceGrotesk.ttf'); 27 | font-display: swap; 28 | } 29 | /* gold-nouveau theme */ 30 | @font-face { 31 | font-family: 'Quicksand'; 32 | src: url('/fonts/Quicksand.ttf'); 33 | font-display: swap; 34 | } 35 | /* vintage theme */ 36 | @font-face { 37 | font-family: 'Abril Fatface'; 38 | src: url('/fonts/AbrilFatface.ttf'); 39 | font-display: swap; 40 | } 41 | /* seafoam theme */ 42 | @font-face { 43 | font-family: 'Playfair Display'; 44 | src: url('/fonts/PlayfairDisplay-Italic.ttf'); 45 | font-display: swap; 46 | } 47 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/Navigation.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 | 88 | -------------------------------------------------------------------------------- /src/lib/States.svelte.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from 'async-mutex'; 2 | 3 | import { browser } from '$app/environment'; 4 | import type { User, Node, PreAuthKey, Route, ApiKeyInfo, ApiApiKeys, Deployment } from '$lib/common/types'; 5 | import { getUsers, getPreAuthKeys, getNodes, getRoutes } from '$lib/common/api/get'; 6 | import type { ToastStore } from '@skeletonlabs/skeleton'; 7 | import { apiGet } from './common/api'; 8 | import { arraysEqual, clone, toastError, toastWarning } from './common/funcs'; 9 | import { debug } from './common/debug'; 10 | 11 | export type LayoutStyle = 'tile' | 'list'; 12 | 13 | function toggledLayout(style: LayoutStyle): LayoutStyle { 14 | return style === 'list' ? 'tile' : 'list'; 15 | } 16 | 17 | export type Valued = { 18 | value: T 19 | } 20 | 21 | export class State { 22 | #value = $state() as T; 23 | #effect?: (value?: T) => void 24 | 25 | get value(): T { 26 | return this.#value 27 | } 28 | 29 | set value(value: T) { 30 | this.#value = value 31 | if (this.#effect !== undefined) { 32 | this.#effect(value) 33 | } 34 | } 35 | 36 | constructor(value: T, effect?: (value?: T) => void) { 37 | this.#value = value 38 | this.#effect = effect 39 | } 40 | } 41 | 42 | 43 | // state that is wrapped in LocalStorage 44 | export class StateLocal { 45 | #key: string; 46 | #value = $state() as T; 47 | #effect?: (value?: T) => void; 48 | // #saver = $derived(this.save(this.#value)) 49 | 50 | get key() { 51 | return this.#key; 52 | } 53 | 54 | get value(): T { 55 | return this.#value; 56 | } 57 | 58 | set value(value: T) { 59 | this.#value = value; 60 | // this.save(this.#value); 61 | if(this.#effect !== undefined) { 62 | this.#effect(value); 63 | } 64 | } 65 | 66 | save(value: T) { 67 | debug(`Saving '${this.#key}' in localStorage...`); 68 | localStorage.setItem(this.#key, this.serialize(value)); 69 | } 70 | 71 | 72 | constructor(key: string, valueDefault: T, effect?: (value?: T) => void) { 73 | this.#key = key; 74 | this.#effect = effect; 75 | 76 | if(browser){ 77 | const storedValue = localStorage.getItem(this.#key); 78 | if (storedValue) { 79 | this.#value = this.deserialize(storedValue); 80 | } else { 81 | this.#value = valueDefault; 82 | } 83 | 84 | // how do I clean this up? 85 | $effect.root(()=>{ 86 | $effect(()=>{ 87 | this.save(this.#value); 88 | }) 89 | }) 90 | } 91 | } 92 | 93 | serialize(value: T): string { 94 | return JSON.stringify(value); 95 | } 96 | 97 | deserialize(item: string): T { 98 | return JSON.parse(item); 99 | } 100 | } 101 | 102 | // application data states 103 | export class HeadscaleAdmin { 104 | users = new State([]); 105 | nodes = new State([]); 106 | routes = new State([]); 107 | preAuthKeys = new State([]); 108 | 109 | // debugging status 110 | debug = new StateLocal('debug', false); 111 | 112 | // theme information 113 | theme = new StateLocal('theme', 'skeleton', (themeName) => { 114 | if(themeName !== undefined) { 115 | document.body.setAttribute('data-theme', themeName); 116 | } 117 | }) 118 | 119 | // api info 120 | apiValid = $state(false); 121 | apiUrl = new StateLocal('apiUrl', ''); 122 | apiKey = new StateLocal('apiKey', ''); 123 | apiTtl = new StateLocal('apiTTL', 10000); 124 | apiKeyInfo = new StateLocal('apiKeyInfo', { 125 | authorized: null, 126 | expires: '', 127 | informedUnauthorized: false, 128 | informedExpiringSoon: false, 129 | }) 130 | hasApiKey = $derived(isInitialized() && !!this.apiKey.value) 131 | hasApiUrl = $derived(isInitialized() && !!this.apiUrl.value) 132 | hasApi = $derived(this.hasApiKey && this.hasApiUrl) 133 | hasValidApi = $derived(this.hasApi && this.apiKeyInfo.value.authorized === true) 134 | 135 | // layouts 136 | layoutUser = new StateLocal('layoutUser', 'list'); 137 | layoutNode = new StateLocal('layoutNode', 'list'); 138 | layoutRoute = new StateLocal('layoutRoute', 'list'); 139 | 140 | toggleLayoutUser() { 141 | this.layoutUser.value = toggledLayout(this.layoutUser.value) 142 | } 143 | 144 | toggleLayoutNode() { 145 | this.layoutNode.value = toggledLayout(this.layoutNode.value) 146 | } 147 | 148 | // deployments 149 | deploymentDefaults = new StateLocal('deploymentDefaults', { 150 | // general 151 | shieldsUp: false, 152 | generateQR: false, 153 | reset: false, 154 | operator: false, 155 | operatorValue: '$USER', 156 | forceReauth: false, 157 | sshServer: false, 158 | usePreAuthKey: false, 159 | preAuthKeyUser: '', 160 | preAuthKey: '', 161 | unattended: false, 162 | // advertise 163 | advertiseExitNode: false, 164 | advertiseExitNodeLocalAccess: false, 165 | advertiseRoutes: false, 166 | advertiseRoutesValues: [], 167 | advertiseTags: false, 168 | advertiseTagsValues: [], 169 | // accept 170 | acceptDns: false, 171 | acceptRoutes: false, 172 | acceptExitNode: false, 173 | acceptExitNodeValue: '', 174 | }) 175 | 176 | async populateUsers(users?: User[]): Promise { 177 | if (users === undefined) { 178 | users = await getUsers() 179 | } 180 | if(!arraysEqual(this.users.value, users)){ 181 | this.users.value = users 182 | return true 183 | } 184 | return false 185 | } 186 | 187 | async populateNodes(nodes?: Node[]): Promise { 188 | if (nodes === undefined) { 189 | nodes = await getNodes() 190 | } 191 | if(!arraysEqual(this.nodes.value, nodes)){ 192 | this.nodes.value = nodes 193 | return true 194 | } 195 | return false 196 | } 197 | 198 | async populateRoutes(routes?: Route[]): Promise { 199 | if (routes === undefined) { 200 | routes = await getRoutes() 201 | } 202 | if(!arraysEqual(this.routes.value, routes)){ 203 | this.routes.value = routes 204 | return true 205 | } 206 | return false 207 | } 208 | 209 | async populatePreAuthKeys(preAuthKeys?: PreAuthKey[]): Promise { 210 | if (preAuthKeys === undefined) { 211 | preAuthKeys = await getPreAuthKeys() 212 | } 213 | if(!arraysEqual(this.preAuthKeys.value, preAuthKeys)){ 214 | this.preAuthKeys.value = preAuthKeys 215 | return true 216 | } 217 | return false 218 | } 219 | 220 | async populateApiKeyInfo(): Promise { 221 | const { apiKeys } = await apiGet(`/api/v1/apikey`); 222 | const myKey = apiKeys.filter((key) => this.apiKey.value.startsWith(key.prefix))[0]; 223 | const apiKeyInfo = this.apiKeyInfo.value 224 | apiKeyInfo.expires = myKey.expiration; 225 | apiKeyInfo.authorized = true; 226 | return true 227 | } 228 | 229 | async populateAll(handler?: (err: unknown) => void, repeat: boolean = true){ 230 | if (this.hasValidApi) { 231 | const promises = [] 232 | promises.push(this.populateUsers()); 233 | promises.push(this.populateNodes()); 234 | promises.push(this.populatePreAuthKeys()); 235 | promises.push(this.populateRoutes()); 236 | promises.push(this.populateApiKeyInfo()); 237 | await Promise.allSettled(promises); 238 | promises.forEach((p) => p.catch(handler)); 239 | debug('Completed all store population requests.'); 240 | } 241 | 242 | if (repeat === true) { 243 | setTimeout(() => { 244 | this.populateAll(handler, true) 245 | }, this.apiTtl.value) 246 | } 247 | } 248 | 249 | toggleLayout(layout?: Valued) { 250 | if (layout) { 251 | layout.value = (layout.value === 'tile' ? 'list' : 'tile'); 252 | } 253 | } 254 | 255 | saveDeploymentDefaults(deployment: Deployment) { 256 | const d = clone(deployment) 257 | d.preAuthKeyUser = '' 258 | d.preAuthKey = '' 259 | this.deploymentDefaults.value = d 260 | } 261 | 262 | updateValue(valued: Valued, item: Identified) { 263 | valued.value = valued.value.map((itemOld) => (itemOld.id === item.id ? item : itemOld)); 264 | } 265 | } 266 | 267 | export const App = new HeadscaleAdmin() 268 | 269 | 270 | function isInitialized(): boolean { 271 | return typeof window !== 'undefined'; 272 | } 273 | 274 | interface Identified { 275 | id: string; 276 | } 277 | 278 | export function updateItem(items: Identified[], item: Identified): Identified[] { 279 | return items.map((itemOld) => (itemOld.id === item.id ? item : itemOld)) 280 | } 281 | 282 | const mu = new Mutex(); 283 | 284 | export function informUserUnauthorized(toastStore: ToastStore) { 285 | mu.runExclusive(() => { 286 | App.apiKeyInfo; 287 | if (App.apiKeyInfo.value.informedUnauthorized === true) { 288 | return; 289 | } 290 | App.apiKeyInfo.value.informedUnauthorized = true; 291 | App.apiKeyInfo.value.authorized = false; 292 | toastError('API Key is Unauthorized or Invalid', toastStore); 293 | }); 294 | } 295 | 296 | export function informUserExpiringSoon(toastStore: ToastStore) { 297 | mu.runExclusive(() => { 298 | if (App.apiKeyInfo.value.informedExpiringSoon === true) { 299 | return; 300 | } 301 | App.apiKeyInfo.value.informedUnauthorized = true; 302 | App.apiKeyInfo.value.authorized = false; 303 | toastWarning('API Key Expires Soon', toastStore); 304 | }); 305 | } -------------------------------------------------------------------------------- /src/lib/cards/CardListContainer.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | {@render children()} 7 |
8 | -------------------------------------------------------------------------------- /src/lib/cards/CardListEntry.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 |
28 | 29 |
30 | {#if title !== undefined} 31 | {title} 32 | {:else if childTitle !== undefined} 33 | {@render childTitle()} 34 | {/if} 35 |
36 | 37 |
38 | {#if value !== undefined} 39 | {value} 40 | {:else if children !== undefined} 41 | {@render children()} 42 | {/if} 43 |
44 |
45 | {#if childBottom} 46 | {@render childBottom()} 47 | {/if} 48 |
49 | -------------------------------------------------------------------------------- /src/lib/cards/CardListItem.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | {@render children()} 21 | 22 | -------------------------------------------------------------------------------- /src/lib/cards/CardListPage.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | 12 | {@render children()} 13 | 14 |
15 | -------------------------------------------------------------------------------- /src/lib/cards/CardSeparator.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | -------------------------------------------------------------------------------- /src/lib/cards/CardTileContainer.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
24 | 27 |
-------------------------------------------------------------------------------- /src/lib/cards/CardTileEntry.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 | {#if title !== undefined} 19 |
{title}
20 | {/if} 21 |
22 | {#if value !== undefined} 23 | {value} 24 | {:else if children !== undefined} 25 | {@render children()} 26 | {/if} 27 |
28 |
29 | -------------------------------------------------------------------------------- /src/lib/cards/CardTilePage.svelte: -------------------------------------------------------------------------------- 1 | 9 |
10 |
11 | {@render children()} 12 |
13 |
14 | -------------------------------------------------------------------------------- /src/lib/cards/acl/GroupListCard.svelte: -------------------------------------------------------------------------------- 1 | 95 | 96 | 97 | {#snippet children()} 98 | 99 |

100 | Members of 101 | { group.name = groupNameNew; return true }} 105 | classes="font-extralight text-secondary-500 dark:text-secondary-300 rounded-md" 106 | showRenameIcon={true} 107 | /> 108 |

109 | 116 |
117 | 118 |
119 |
120 | {/snippet} 121 |
122 | -------------------------------------------------------------------------------- /src/lib/cards/acl/HostListCard.svelte: -------------------------------------------------------------------------------- 1 | 106 | 107 |
111 |

Host '{hostName}' has the same name as a user.
Please rename the host.

112 |
113 |
114 | 115 |
118 |
119 | {#if userNames.includes(hostName)} 120 | 128 | {/if} 129 | { host.name = hostNameNew; return true }} 133 | classes="font-extralight rounded-md" 134 | showRenameIcon={false} 135 | /> 136 |
137 |
138 | { host.cidr = hostCIDRNew; return true }} 142 | classes="text-sm font-mono rounded-md text-primary-500 dark:text-primary-300" 143 | showRenameIcon={false} 144 | /> 145 |
146 |
147 | 148 |
149 |
-------------------------------------------------------------------------------- /src/lib/cards/acl/ListEntry.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | {name} 42 |
43 |
44 | 45 | {@render children?.()} 46 | 47 |
-------------------------------------------------------------------------------- /src/lib/cards/acl/SshRuleListCard.svelte: -------------------------------------------------------------------------------- 1 | 136 | 137 | 138 | {#snippet children()} 139 | 140 |

141 | Sources: 142 |

143 |
144 | 153 | 154 | 155 |
156 |
157 | {#if optionsSrc != undefined} 158 |
159 | { 163 | srcNewHost = evt.detail.label 164 | }} 165 | /> 166 |
167 | {/if} 168 |
169 | 175 | 191 |
192 |
193 | {#each rule.src as src, i} 194 |
197 |
198 | {src} 199 |
200 |
201 | {delSrc(i)}} disabled={loading} /> 202 |
203 |
204 | {/each} 205 | 206 |

207 | Destinations: 208 |

209 |
210 | 219 | 220 | 221 |
222 |
223 | {#if optionsDst != undefined} 224 |
225 | { 229 | dstNewHost = evt.detail.label 230 | }} 231 | /> 232 |
233 | {/if} 234 |
235 | 241 | 257 |
258 |
259 | {#each rule.dst as dst, i} 260 |
263 |
264 | {dst} 265 |
266 |
267 | {delDst(i)}} disabled={loading} /> 268 |
269 |
270 | {/each} 271 |

272 | Usernames: 273 |

274 | { 278 | delUsername(item) 279 | }} 280 | /> 281 |
282 | 283 |
284 |
285 | {/snippet} 286 |
287 | -------------------------------------------------------------------------------- /src/lib/cards/acl/TagOwnerListCard.svelte: -------------------------------------------------------------------------------- 1 | 99 | 100 | 101 | {#snippet children()} 102 | 103 |

104 | Owners of 105 | { tag.name = tagNameNew; return true}} 109 | classes="font-extralight text-secondary-500 dark:text-secondary-300 rounded-md" 110 | showRenameIcon={true} 111 | /> 112 |

113 | 120 |
121 | 122 |
123 |
124 | {/snippet} 125 |
126 | -------------------------------------------------------------------------------- /src/lib/cards/common/ItemCreatedAt.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {new Date(item.createdAt).toLocaleString('en-Gb', { 15 | minute: '2-digit', 16 | year: 'numeric', 17 | month: 'short', 18 | day: '2-digit', 19 | hour: '2-digit', 20 | hour12: false, 21 | })} 22 | 23 | -------------------------------------------------------------------------------- /src/lib/cards/common/ItemDelete.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/lib/cards/common/ItemListName.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 |
37 | {#if showRename} 38 |
42 |
43 |
44 | 51 | 111 | 119 |
120 |
121 |
122 | {:else} 123 |
127 |
128 | {item.givenName ?? item.name} 129 | {#if allowed} 130 | 139 | {/if} 140 |
141 |
142 | {/if} 143 |
144 |
145 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeAddresses.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 |
14 | {#each node.ipAddresses as ipAddress} 15 |
{ipAddress}
16 | {/each} 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeCreate.svelte: -------------------------------------------------------------------------------- 1 | 51 | 52 |
53 |
54 | 62 | 67 | 70 |
71 |
72 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeExpiresAt.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 |
32 | 33 | {diff.message} 34 | 35 | 36 | { 38 | loading = true 39 | try{ 40 | App.updateValue(App.nodes, await expireNode(node)) 41 | } finally { 42 | loading = false 43 | } 44 | }} 45 | /> 46 | 47 |
48 |
49 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeHostname.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | {node.name} 14 | 15 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeInfo.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 58 | 59 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeLastSeen.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | {#if node.online} 26 | Online Now 27 | {:else} 28 | {lastSeen} 29 | {/if} 30 | 31 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeListCard.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 26 | 27 | 28 | 29 | 30 |
31 | 32 | {node.givenName} 33 | 34 |
35 |
36 | 37 | 38 | 39 |
40 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeOwner.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 |
33 | 34 | { 37 | openDrawer(drawerStore, 'userDrawer-' + node.user.id, node.user); 38 | }} 39 | > 40 | {node.user.name} 41 | 42 | 43 | 44 | 53 |
54 | {#if showTransfer} 55 |
56 | New Owner: 57 | 64 | 92 | 101 |
102 | {/if} 103 |
104 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeRegistrationMethod.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | {nodeRegMethod} 26 | 27 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeRoute.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 |
37 | {route.prefix} 38 | {#if route.advertised === false} 39 | (not advertised) 40 | {/if} 41 |
42 |
43 | 74 | {#if showDelete} 75 | { 77 | await deleteRoute(route); 78 | }} 79 | /> 80 | {/if} 81 | 113 |
114 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeRoutes.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | 47 |
48 | 51 | 54 |
55 | {#if childBottom === undefined} 56 | {#each routes as _, i} 57 |
58 | 59 |
60 | {/each} 61 | {:else} 62 | {@render childBottom()} 63 | {/if} 64 |
65 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeTags.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 |
68 |

The following tags have been prevented by the current ACL:

69 |

70 | {#if popupInvalidTagsShow == true} 71 | {#each tagsInvalid as tag} 72 | 73 | {/each} 74 | {/if} 75 |

76 |
77 |
78 | 79 |
80 | 81 | 90 | 91 | 92 | {#snippet childTitle()} 93 | 94 | Advertised Tags: 95 | {#if tagsInvalid.length > 0} 96 | 106 | {/if} 107 | 108 | {/snippet} 109 |
110 | {#each tagsValid as tag} 111 | 112 | {/each} 113 |
114 |
115 |
116 | -------------------------------------------------------------------------------- /src/lib/cards/node/NodeTileCard.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | openDrawer(drawerStore, 'nodeDrawer-' + node.id, node)}> 49 |
50 |
51 | 52 | ID: {node.id} 53 |
54 |
55 | {node.givenName} 56 |
57 |
58 | 59 | {dateToStr(node.createdAt)} 60 | 61 | 62 | {#if node.online} 63 | Online Now 64 | {:else} 65 | {lastSeen} 66 | {/if} 67 | 68 | 69 |
70 | {node.user.name} 71 | 72 |
73 |
74 | 75 |
76 | {node.ipAddresses.filter((s) => /^\d+\.\d+\.\d+\.\d+$/.test(s)).at(0)} 77 |
78 |
79 | 80 | {routeCount} 81 | 82 |
83 |
84 | -------------------------------------------------------------------------------- /src/lib/cards/route/RouteInfo.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/cards/route/RouteListCard.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | {node.givenName} 32 | 33 |
34 |
35 | 36 | 37 | 38 |
39 | -------------------------------------------------------------------------------- /src/lib/cards/route/RouteTileCard.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 | 33 |
34 |
35 | 36 | ID: {node.id} 37 |
38 |
39 | {node.givenName} 40 |
41 |
42 | 43 | 46 | 47 |
48 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserCreate.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 |
39 |
40 | 48 | 51 |
52 |
53 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserDisplayName.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {user.displayName} 15 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserEmail.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {user.email} 15 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserInfo.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 23 | {#if user.displayName} 24 | 25 | 26 | {/if} 27 | {#if user.email} 28 | 29 | 30 | {/if} 31 | 32 | 33 | 34 | 35 | 36 | 37 | {#if user.name || user.email} 38 | 39 | 40 | {/if} 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserListCard.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | {getUserDisplay(user)} 32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 |
40 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserListNodes.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | {#each filteredNodes as node} 30 | 41 | {/each} 42 | 43 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserListPreAuthKey.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 |
35 |
36 | 45 | 46 | { 48 | await expirePreAuthKey(preAuthKey); 49 | const keys = await getPreAuthKeys([preAuthKey.user]); 50 | keys.forEach((pak) => { 51 | App.updateValue(App.preAuthKeys, pak) 52 | }); 53 | }} 54 | /> 55 | 56 |
57 |
58 |
59 | 64 | Used 65 | 66 | 71 | Expired 72 | 73 |
74 |
75 | 80 | Ephemeral 81 | 82 | 87 | Reusable 88 | 89 |
90 |
91 |
92 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserListPreAuthKeys.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 |
59 |
60 | 61 |

Hide Invalid

62 | 77 |
78 | {#if showCreate} 79 |
83 |
84 |
87 | 110 | 113 |
114 |
117 | 123 |
124 |
127 | 136 | 145 |
146 |
147 |
148 | {/if} 149 |
150 | {#snippet childBottom()} 151 |
152 | {#each preAuthKeys as preAuthKey} 153 | 154 | 155 | {/each} 156 |
157 | {/snippet} 158 |
159 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserProvider.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {user.provider || 'local'} 15 | -------------------------------------------------------------------------------- /src/lib/cards/user/UserTileCard.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | openDrawer(drawerStore, 'userDrawer-' + user.id, user)}> 25 |
26 |
27 | 28 | ID: {user.id} 29 |
30 |
31 | {getUserDisplay(user)} 32 |
33 |
34 |
35 |
Created:
36 |
37 | {dateToStr(new Date(user.createdAt))} 38 |
39 |
40 |
41 |
Nodes:
42 |
43 | {nodeCount} 44 |
45 |
46 |
47 |
48 | -------------------------------------------------------------------------------- /src/lib/common/api/base.ts: -------------------------------------------------------------------------------- 1 | import { App } from '$lib/States.svelte'; 2 | import { ApiAuthErrorUnauthorized } from '../errors'; 3 | import { debug } from '../debug'; 4 | 5 | // errors received from headscale 6 | export type ApiError = { 7 | code: number; 8 | message: string; 9 | details: unknown[]; 10 | }; 11 | 12 | export type ApiResponse = T | ApiError; 13 | 14 | function isApiError(response: ApiResponse): response is ApiError { 15 | return (response as ApiError).code !== undefined; 16 | } 17 | 18 | async function toApiResponse(response: Response): Promise { 19 | if (!response.ok) { 20 | const text = await response.text(); 21 | if (text === 'Unauthorized') { 22 | throw new ApiAuthErrorUnauthorized(); 23 | } 24 | 25 | try{ 26 | const data = JSON.parse(text) 27 | if (isApiError(data)) { 28 | throw new Error(data.message); 29 | } 30 | } catch(e) { 31 | if (!(e instanceof SyntaxError)) { 32 | throw e 33 | } 34 | } 35 | 36 | 37 | // unspecified errors 38 | throw new Error('Unspecified Error: ' + text); 39 | } 40 | 41 | const data = await response.json(); 42 | if (isApiError(data)) { 43 | throw new Error(data.message); 44 | } 45 | 46 | return data as T; 47 | } 48 | 49 | function headers(): { headers: HeadersInit } { 50 | if (typeof window === 'undefined') { 51 | return { headers: {} }; 52 | } 53 | return { 54 | headers: { 55 | Authorization: 'Bearer ' + App.apiKey.value, 56 | Accept: 'application/json', 57 | }, 58 | }; 59 | } 60 | 61 | export function toUrl(path: string): string { 62 | return new URL(path, App.apiUrl.value).href 63 | } 64 | 65 | async function apiFetch(path: string, init?: RequestInit, verbose: boolean = false): Promise { 66 | try { 67 | const response = await fetch(toUrl(path), { ...headers(), ...init }); 68 | if (verbose) { 69 | debug(response); 70 | } 71 | const apiResponse = await toApiResponse(response); 72 | if (App.apiKeyInfo.value.authorized === null) { 73 | App.apiKeyInfo.value.authorized = true 74 | } 75 | return apiResponse; 76 | } catch (err) { 77 | if (err instanceof Error) { 78 | debug('Fetch Error:', err.message); 79 | } 80 | throw err; 81 | } 82 | } 83 | 84 | export async function apiGet( 85 | path: string, 86 | init?: RequestInit, 87 | verbose: boolean = false, 88 | ): Promise { 89 | return await apiFetch(path, init, verbose); 90 | } 91 | 92 | export async function apiDelete(path: string, init?: RequestInit): Promise { 93 | return await apiFetch(path, { method: 'DELETE', ...init }); 94 | } 95 | 96 | export async function apiPost( 97 | path: string, 98 | data: unknown = null, 99 | init?: RequestInit, 100 | verbose: boolean = false, 101 | ): Promise { 102 | const body = JSON.stringify(data ?? {}); 103 | return await apiFetch(path, { method: 'POST', body, ...init }, verbose); 104 | } 105 | 106 | export async function apiPut( 107 | path: string, 108 | data: unknown = null, 109 | init?: RequestInit, 110 | verbose: boolean = false, 111 | ): Promise { 112 | const body = JSON.stringify(data ?? {}); 113 | return await apiFetch(path, { method: 'PUT', body, ...init }, verbose); 114 | } 115 | 116 | export async function apiTest(): Promise { 117 | return true; 118 | } 119 | -------------------------------------------------------------------------------- /src/lib/common/api/create.ts: -------------------------------------------------------------------------------- 1 | import { apiPost } from './base'; 2 | import { 3 | type ApiApiKey, 4 | type ApiNode, 5 | type ApiPreAuthKey, 6 | type ApiUser, 7 | type Node, 8 | type User, 9 | } from '$lib/common/types'; 10 | import { debug } from '$lib/common/debug'; 11 | import { API_URL_APIKEY, API_URL_NODE, API_URL_PREAUTHKEY, API_URL_USER } from './url'; 12 | 13 | export async function createApiKey() { 14 | // create 90-day API Key 15 | const date = new Date(); 16 | date.setDate(date.getDate() + 90); 17 | const data = { expiration: date.toISOString() }; 18 | const { apiKey } = await apiPost(API_URL_APIKEY, data); 19 | debug('Created API Key "...' + apiKey.slice(-10) + '"') 20 | return apiKey; 21 | } 22 | 23 | export async function createUser(username: string): Promise { 24 | if (username.length === 0) { 25 | throw new Error("Username cannot be empty") 26 | } 27 | const data = { name: username }; 28 | const { user } = await apiPost(API_URL_USER, data); 29 | debug('Created user "' + username + '"'); 30 | return user; 31 | } 32 | 33 | export async function createNode(key: string, username: string): Promise { 34 | const data = '?user=' + username + '&key=' + key; 35 | const { node } = await apiPost(API_URL_NODE + '/register' + data) 36 | debug('Created Node "' + node.givenName + '" for user "' + username + '"'); 37 | return node; 38 | } 39 | 40 | export async function createPreAuthKey( 41 | user: User, 42 | ephemeral: boolean, 43 | reusable: boolean, 44 | expiration: Date | string, 45 | ) { 46 | const data = { 47 | user: user.name, 48 | reusable, 49 | ephemeral, 50 | expiration: new Date(expiration).toISOString(), 51 | }; 52 | const { preAuthKey } = await apiPost(API_URL_PREAUTHKEY, data); 53 | debug('Created PreAuthKey for user "' + user.name + '"'); 54 | return preAuthKey; 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/common/api/delete.ts: -------------------------------------------------------------------------------- 1 | import { apiDelete, apiPost } from './base'; 2 | import type { User, Node, Route } from '$lib/common/types'; 3 | import { debug } from '../debug'; 4 | import { API_URL_APIKEY, API_URL_NODE, API_URL_ROUTES, API_URL_USER } from './url'; 5 | import { App } from '$lib/States.svelte'; 6 | 7 | export async function expireApiKey(apiKey: string) { 8 | if (apiKey.indexOf('.') > -1) { 9 | apiKey = apiKey.split('.').at(0) || ''; 10 | } 11 | if (!apiKey) { 12 | debug('Invalid API Key/Prefix'); 13 | return; 14 | } 15 | try { 16 | await apiPost(`${API_URL_APIKEY}/expire`, { prefix: apiKey }); 17 | debug('Expired API Key with Prefix ' + apiKey); 18 | } catch (error) { 19 | debug(error); 20 | } 21 | } 22 | 23 | export async function deleteUser(user: User): Promise { 24 | try { 25 | await apiDelete(`${API_URL_USER}/${user.id}`); 26 | App.users.value = App.users.value.filter((u: User) => u.id != user.id) 27 | debug('Deleted User "' + user.name + '"'); 28 | return true; 29 | } catch (error) { 30 | debug(error); 31 | return false; 32 | } 33 | } 34 | 35 | export async function deleteNode(node: Node): Promise { 36 | try { 37 | await apiDelete(`${API_URL_NODE}/${node.id}`); 38 | App.nodes.value = App.nodes.value.filter((n: Node) => n.id != node.id); 39 | debug('Deleted Node "' + node.name + '"'); 40 | return true; 41 | } catch (error) { 42 | debug(error); 43 | return false; 44 | } 45 | } 46 | 47 | export async function deleteRoute(route: Route): Promise { 48 | try { 49 | await apiDelete(`${API_URL_ROUTES}/${route.id}`); 50 | App.routes.value = App.routes.value.filter((r: Route) => r.id != route.id) 51 | debug('Deleted Route "' + route.prefix + '"'); 52 | return true; 53 | } catch (error) { 54 | debug(error); 55 | return false; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/common/api/get.ts: -------------------------------------------------------------------------------- 1 | import { API_URL_NODE, API_URL_POLICY, API_URL_PREAUTHKEY, API_URL_ROUTES, API_URL_USER, apiGet } from '$lib/common/api'; 2 | import type { 3 | ApiNodes, 4 | ApiPolicy, 5 | ApiPreAuthKeys, 6 | ApiRoutes, 7 | ApiUsers, 8 | Node, 9 | PreAuthKey, 10 | Route, 11 | User, 12 | } from '$lib/common/types'; 13 | 14 | export async function getPreAuthKeys( 15 | usernames?: string[], 16 | init?: RequestInit, 17 | ): Promise { 18 | if (usernames == undefined) { 19 | usernames = (await getUsers(init)).map((u) => u.name); 20 | } 21 | const promises: Promise[] = []; 22 | let preAuthKeysAll: PreAuthKey[] = []; 23 | 24 | usernames.forEach(async (username: string) => { 25 | if(username != ""){ 26 | promises.push( 27 | apiGet(API_URL_PREAUTHKEY + '?user=' + username, init), 28 | ); 29 | } 30 | }); 31 | 32 | promises.forEach(async (p) => { 33 | const { preAuthKeys } = await p; 34 | preAuthKeysAll = preAuthKeysAll.concat(preAuthKeys); 35 | }); 36 | 37 | await Promise.all(promises); 38 | return preAuthKeysAll; 39 | } 40 | 41 | type GetUserOptions = 42 | {id: string, name?: never, email?: never} | 43 | {id?: never, name: string, email?: never} | 44 | {id?: never, name?: never, email: string} 45 | 46 | export async function getUsers(init?: RequestInit, options?: GetUserOptions): Promise { 47 | let url = API_URL_USER; 48 | if (options !== undefined){ 49 | if(options.id !== undefined) { 50 | url += "?id=" + options.id 51 | } else if (options.name !== undefined) { 52 | url += "?name=" + options.name 53 | } else if (options.email !== undefined) { 54 | url += "?email=" + options.email 55 | } else { 56 | throw new Error("Invalid User Parameters") 57 | } 58 | } 59 | const { users } = await apiGet(url, init); 60 | return users; 61 | } 62 | 63 | export async function getNodes(): Promise { 64 | const { nodes } = await apiGet(API_URL_NODE); 65 | return nodes; 66 | } 67 | 68 | export async function getRoutes(): Promise { 69 | const { routes } = await apiGet(API_URL_ROUTES); 70 | return routes; 71 | } 72 | 73 | export async function getPolicy(): Promise { 74 | const { policy } = await apiGet(API_URL_POLICY) 75 | return policy 76 | } 77 | -------------------------------------------------------------------------------- /src/lib/common/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base'; 2 | export * from './create'; 3 | export * from './delete'; 4 | export * from './modify'; 5 | export * from './get'; 6 | export * from './url'; 7 | -------------------------------------------------------------------------------- /src/lib/common/api/modify.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ApiNode, 3 | ApiPolicy, 4 | ApiRoute, 5 | ApiUser, 6 | Node, 7 | PreAuthKey, 8 | Route, 9 | User, 10 | } from '$lib/common/types'; 11 | import { debug } from '../debug'; 12 | import { apiPost, apiPut } from './base'; 13 | import type { ACLBuilder } from '../acl.svelte'; 14 | import { API_URL_NODE, API_URL_POLICY, API_URL_PREAUTHKEY, API_URL_ROUTES, API_URL_USER } from './url'; 15 | import { createApiKey } from './create'; 16 | import { expireApiKey } from './delete'; 17 | import { App } from '$lib/States.svelte'; 18 | 19 | export async function renameUser(u: User, nameNew: string): Promise { 20 | const path = `${API_URL_USER}/${u.id}/rename/${nameNew}`; 21 | const { user } = await apiPost(path, undefined); 22 | debug('Renamed User from "' + u.name + '" to "' + nameNew + '"'); 23 | return user; 24 | } 25 | 26 | export async function renameNode(n: Node, nameNew: string): Promise { 27 | const path = `${API_URL_NODE}/${n.id}/rename/${nameNew}`; 28 | const { node } = await apiPost(path, undefined); 29 | debug('Renamed Node from "' + n.givenName + '" to "' + nameNew + '"'); 30 | return node; 31 | } 32 | 33 | export async function changeNodeOwner(n: Node, newUserID: string): Promise { 34 | const path = `${API_URL_NODE}/${n.id}/user`; 35 | const { node } = await apiPost(path, {user: newUserID}); 36 | debug('Re-assigned Node from "' + n.user.name + '" to "' + node.user.name + '"'); 37 | return node; 38 | } 39 | 40 | export async function expirePreAuthKey(pak: PreAuthKey) { 41 | const path = `${API_URL_PREAUTHKEY}/expire`; 42 | const data = { user: pak.user, key: pak.key }; 43 | await apiPost(path, data); 44 | } 45 | 46 | export async function expireNode(n: Node): Promise { 47 | const path = `${API_URL_NODE}/${n.id}/expire`; 48 | const { node } = await apiPost(path, undefined); 49 | debug('Expired Node "' + n.givenName + '"'); 50 | return node; 51 | } 52 | 53 | export async function setNodeTags(n: Node, tags: string[]): Promise { 54 | const path = `${API_URL_NODE}/${n.id}/tags`; 55 | tags = tags.map((tag) => (tag.startsWith('tag:') ? tag : 'tag:' + tag)); 56 | const { node } = await apiPost(path, { tags }); 57 | debug('Set Tags for Node "' + n.givenName + '"'); 58 | return node; 59 | } 60 | 61 | export async function enableRoute(r: Route): Promise { 62 | const path = `${API_URL_ROUTES}/${r.id}/enable`; 63 | const { route } = await apiPost(path); 64 | debug('Enabled Route "' + r.prefix + '"'); 65 | return route; 66 | } 67 | 68 | export async function disableRoute(r: Route): Promise { 69 | const path = `${API_URL_ROUTES}/${r.id}/disable`; 70 | const { route } = await apiPost(path); 71 | debug('Disabled Route "' + r.prefix + '"'); 72 | return route; 73 | } 74 | 75 | export async function setPolicy(acl: ACLBuilder) { 76 | const path = `${API_URL_POLICY}` 77 | await apiPut(path, {"policy": acl.JSON(4)}) 78 | } 79 | 80 | export async function refreshApiKey() { 81 | const apiKeyNew = await createApiKey(); 82 | const apiKeyOld = App.apiKey.value 83 | await expireApiKey(apiKeyOld); 84 | App.apiKey.value = apiKeyNew 85 | App.apiKeyInfo.value.informedExpiringSoon = false 86 | App.apiKeyInfo.value.informedUnauthorized = false 87 | } 88 | -------------------------------------------------------------------------------- /src/lib/common/api/url.ts: -------------------------------------------------------------------------------- 1 | export type ApiEndpoints = { 2 | User: string; 3 | Node: string; 4 | Routes: string; 5 | ApiKey: string; 6 | PreAuthKey: string; 7 | Policy: string; 8 | Debug: string; 9 | }; 10 | 11 | export const API_URL_USER = '/api/v1/user'; 12 | export const API_URL_NODE = '/api/v1/node'; 13 | export const API_URL_POLICY = '/api/v1/policy'; 14 | export const API_URL_MACHINE = '/api/v1/machine'; 15 | export const API_URL_ROUTES = '/api/v1/routes'; 16 | export const API_URL_APIKEY = '/api/v1/apikey'; 17 | export const API_URL_PREAUTHKEY = '/api/v1/preauthkey'; 18 | export const API_URL_DEBUG = '/api/v1/debug'; -------------------------------------------------------------------------------- /src/lib/common/debug.ts: -------------------------------------------------------------------------------- 1 | import { App } from "$lib/States.svelte"; 2 | 3 | export const version = '0.25.6'; 4 | 5 | export function debug(...data: unknown[]) { 6 | // output if console debugging is enabled 7 | if (App.debug.value) { 8 | console.log(new Date().toLocaleTimeString('en-US', { hour12: false }), ...data); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/common/errors.ts: -------------------------------------------------------------------------------- 1 | // TODO: add support for more specific errors so error handling can be far more graceful 2 | 3 | import { informUserUnauthorized } from '$lib/States.svelte'; 4 | import type { ToastStore } from '@skeletonlabs/skeleton'; 5 | import { debug } from './debug'; 6 | 7 | export class ApiAuthError extends Error { 8 | constructor() { 9 | super(); 10 | } 11 | } 12 | export class ApiAuthErrorUnauthorized extends ApiAuthError { 13 | constructor() { 14 | super(); 15 | } 16 | } 17 | 18 | export function createPopulateErrorHandler(ToastStore: ToastStore) { 19 | return (err: unknown) => { 20 | if (err instanceof ApiAuthErrorUnauthorized) { 21 | informUserUnauthorized(ToastStore); 22 | } 23 | debug('Error Handler:', err); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/common/themes.ts: -------------------------------------------------------------------------------- 1 | export const ALL_THEMES = [ 2 | 'skeleton', 3 | 'wintry', 4 | 'modern', 5 | 'rocket', 6 | 'seafoam', 7 | 'vintage', 8 | 'sahara', 9 | 'hamlindigo', 10 | 'gold-nouveau', 11 | 'crimson', 12 | ]; 13 | 14 | export function setTheme(theme: string) { 15 | document.body.setAttribute('data-theme', theme) 16 | } -------------------------------------------------------------------------------- /src/lib/common/types.ts: -------------------------------------------------------------------------------- 1 | export interface Named { 2 | id: string; 3 | createdAt: string; 4 | name: string; 5 | givenName?: string; 6 | } 7 | 8 | export type ItemTypeName = 'user' | 'node'; 9 | 10 | export type User = { 11 | id: string; 12 | name: string; 13 | createdAt: string; 14 | displayName: string; 15 | email: string; 16 | providerId: string; 17 | provider: string; 18 | profilePicUrl: string; 19 | }; 20 | 21 | export type ExpirationMessage = { 22 | message: string; 23 | color: string; 24 | }; 25 | 26 | export function isNamed(item: unknown): item is Named { 27 | if (item != null && typeof item === 'object') { 28 | return ( 29 | typeof (item as Named).id === 'string' && 30 | typeof (item as Named).name === 'string' && 31 | typeof (item as Named).createdAt === 'string' 32 | ); 33 | } 34 | return false; 35 | } 36 | 37 | export function isNode(item: Named): item is Node { 38 | return isNamed(item) && (item as Node).user !== undefined; 39 | } 40 | 41 | export function isUser(item: Named): item is User { 42 | return isNamed(item) && !isNode(item); 43 | } 44 | 45 | export function getUserDisplay(user: User): string { 46 | if(user.displayName) { 47 | return user.name + " (" + user.displayName + ")" 48 | } else { 49 | return user.name; 50 | } 51 | } 52 | 53 | export function getTypeName(item: Named): ItemTypeName { 54 | if (isNode(item)) { 55 | return 'node'; 56 | } 57 | if (isUser(item)) { 58 | return 'user'; 59 | } 60 | throw new Error('Item Provided is an Invalid Type'); 61 | } 62 | 63 | export type ApiUsers = { 64 | users: User[]; 65 | }; 66 | 67 | export type ApiUser = { 68 | user: User; 69 | }; 70 | 71 | export type ApiPreAuthKeys = { 72 | preAuthKeys: PreAuthKey[]; 73 | }; 74 | 75 | export type ApiPreAuthKey = { 76 | preAuthKey: PreAuthKey; 77 | }; 78 | 79 | export class PreAuthKey { 80 | constructor( 81 | public user: string, 82 | public id: string, 83 | public key: string, 84 | public reusable: boolean, 85 | public ephemeral: boolean, 86 | public used: boolean, 87 | public expiration: string, 88 | public createdAt: string, 89 | public aclTags: string[], 90 | ) { } 91 | isExpired: () => boolean = () => { 92 | return new Date() > new Date(this.expiration); 93 | }; 94 | } 95 | 96 | export class PreAuthKeys { 97 | constructor(public preAuthKeys: PreAuthKey[]) { } 98 | } 99 | 100 | export type Route = { 101 | id: string; 102 | createdAt: string; 103 | deletedAt: string; 104 | node: Node; 105 | machine: never; 106 | prefix: string; 107 | advertised: boolean; 108 | enabled: boolean; 109 | isPrimary: boolean; 110 | }; 111 | 112 | export type ApiRoute = { 113 | route: Route; 114 | }; 115 | 116 | export type ApiRoutes = { 117 | routes: Route[]; 118 | }; 119 | 120 | export type ApiPolicy = { 121 | policy: string; 122 | updatedAt?: string; 123 | } 124 | 125 | export type Node = { 126 | id: string; 127 | machineKey: string; 128 | nodeKey: string; 129 | discoKey: string; 130 | ipAddresses: string[]; 131 | name: string; 132 | user: User; 133 | lastSeen: string | null; 134 | lastSuccessfulUpdate: string | null; 135 | expiry: string | null; 136 | preAuthKey: string | null; 137 | createdAt: string; 138 | registerMethod: 139 | | 'REGISTER_METHOD_UNSPECIFIED' 140 | | 'REGISTER_METHOD_AUTH_KEY' 141 | | 'REGISTER_METHOD_CLI' 142 | | 'REGISTER_METHOD_OIDC'; 143 | forcedTags: string[]; 144 | invalidTags: string[]; 145 | validTags: string[]; 146 | givenName: string; 147 | online: boolean; 148 | }; 149 | 150 | export type ApiNodes = { 151 | nodes: Node[]; 152 | }; 153 | 154 | export type ApiNode = { 155 | node: Node; 156 | }; 157 | 158 | export type ApiMachine = { 159 | machine: Node; 160 | }; 161 | 162 | export type ApiKey = { 163 | id: string; 164 | createdAt: string; 165 | prefix: string; 166 | expiration: string; 167 | lastSeen: string; 168 | }; 169 | export type ApiApiKey = { 170 | apiKey: string; 171 | }; 172 | export type ApiApiKeys = { 173 | apiKeys: ApiKey[]; 174 | }; 175 | 176 | export type ApiKeyInfo = { 177 | authorized: boolean | null; 178 | expires: string; // validity period of the API key. Alert the user if it is within 30 days. 179 | informedUnauthorized: boolean; // whether or not the user has been informed that the key is unauthorized 180 | informedExpiringSoon: boolean; // whether or not the user has been informed that the key is expiring soon 181 | }; 182 | 183 | export type Direction = 'up' | 'down'; 184 | export type OnlineStatus = 'online' | 'offline' | 'all'; 185 | 186 | export type Deployment = { 187 | // general 188 | shieldsUp: boolean; 189 | generateQR: boolean; 190 | reset: boolean; 191 | operator: boolean; 192 | operatorValue: string; 193 | forceReauth: boolean; 194 | sshServer: boolean; 195 | usePreAuthKey: boolean; 196 | preAuthKeyUser: string; 197 | preAuthKey: string; 198 | unattended: boolean; 199 | // advertise 200 | advertiseExitNode: boolean; 201 | advertiseExitNodeLocalAccess: boolean; 202 | advertiseRoutes: boolean; 203 | advertiseRoutesValues: string[]; 204 | advertiseTags: boolean; 205 | advertiseTagsValues: string[]; 206 | // accept 207 | acceptDns: boolean; 208 | acceptRoutes: boolean; 209 | acceptExitNode: boolean; 210 | acceptExitNodeValue: string; 211 | }; 212 | -------------------------------------------------------------------------------- /src/lib/common/usables.ts: -------------------------------------------------------------------------------- 1 | 2 | export function clickOutside(node: HTMLElement, callback: () => void): { destroy: () => void} { 3 | const handleClick = (event: MouseEvent) => { 4 | if (!node.contains(event.target as Node)) { 5 | callback(); 6 | } 7 | }; 8 | 9 | document.addEventListener('click', handleClick, true); 10 | 11 | return { 12 | destroy: () => { 13 | document.removeEventListener('click', handleClick, true) 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /src/lib/page/DrawerEntry.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |
17 | {title} 18 |
19 |
20 | 21 |
22 |
23 | {@render children()} -------------------------------------------------------------------------------- /src/lib/page/Page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 | {@render children()} 18 |
19 | -------------------------------------------------------------------------------- /src/lib/page/PageDrawer.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 |
15 | {#if $drawerStore?.id?.startsWith('userDrawer-')} 16 | u.id === $drawerStore?.meta.id)?.name ?? 'N/A'}> 17 | u.id === $drawerStore?.meta.id) || $drawerStore.meta} /> 18 | 19 | {/if} 20 | {#if $drawerStore?.id?.startsWith('nodeDrawer-')} 21 | n.id === $drawerStore?.meta.id)?.givenName ?? 'N/A'}> 22 | n.id === $drawerStore?.meta.id) || $drawerStore.meta} /> 23 | 24 | {/if} 25 | {#if $drawerStore?.id?.startsWith('navDrawer')} 26 | 27 | 28 | 29 | {/if} 30 |
31 |
32 | -------------------------------------------------------------------------------- /src/lib/page/PageHeader.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 |
45 |
46 |
{title}
47 | {#if layout && layoutCurrent} 48 |
49 | 50 | App.toggleLayout(layout)} 55 | active="bg-primary-500" 56 | background="bg-secondary-500" 57 | size="sm" 58 | /> 59 | 60 |
61 | {/if} 62 |
63 | {#if button !== undefined} 64 |
65 | {#if buttonText !== ""} 66 | 73 | {/if} 74 | {#if filterString !== undefined} 75 | 82 | {/if} 83 |
84 | {/if} 85 |
86 | {#if button !== undefined && show} 87 |
88 | {@render button()} 89 |
90 | {/if} 91 | -------------------------------------------------------------------------------- /src/lib/parts/CloseBtn.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /src/lib/parts/Delete.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 | {#if show} 19 | 20 | 38 | 46 | 47 | {/if} 48 | 49 | 52 | 53 |
54 | -------------------------------------------------------------------------------- /src/lib/parts/FilterOnlineBtn.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 28 | -------------------------------------------------------------------------------- /src/lib/parts/LoaderModal.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 | -------------------------------------------------------------------------------- /src/lib/parts/MultiSelect.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 |
47 |
48 | {#each items as item} 49 | 52 | {/each} 53 |
54 |
55 |
57 | { selectShow = true; }} 63 | /> 64 |
65 | 66 | 67 | 68 | {#if options !== undefined && selectShow} 69 |
70 | { 76 | items.push(evt.detail.value) 77 | selectItem = '' 78 | selectInputFocus() 79 | }} 80 | /> 81 |
82 | {/if} 83 |
84 |
-------------------------------------------------------------------------------- /src/lib/parts/NewItem.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
{ 28 | try { 29 | if (value === undefined) { 30 | submit(name); 31 | } else { 32 | submit(name, value); 33 | } 34 | } catch (e) { 35 | if (e instanceof Error) { 36 | toastError('', ToastStore, e); 37 | } 38 | } 39 | }} 40 | class="flex flex-row w-full my-2 items-center space-x-2" 41 | > 42 | 51 | {#if value !== undefined} 52 | 59 | {/if} 60 | 67 |
68 | -------------------------------------------------------------------------------- /src/lib/parts/OnlineNodeIndicator.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/parts/OnlineUserIndicator.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/lib/parts/SortBtn.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /src/lib/parts/Tabbed.svelte: -------------------------------------------------------------------------------- 1 | 13 |
14 | {#each tabs as tab, i} 15 | 21 | 22 | 23 | 24 | 25 | 26 | {tab.title} 27 | 28 | {/each} 29 |
-------------------------------------------------------------------------------- /src/lib/parts/Text.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | {#if !showModify} 29 | 41 | {:else} 42 |
{ 45 | x.preventDefault() 46 | if(submit(x)) { 47 | showModify = false 48 | } 49 | }} 50 | > 51 | 57 | 58 | 61 | 69 | 70 |
71 | {/if} 72 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
73 | 88 | Headscale-Admin 89 | {version} 90 |
91 |
92 | 93 | 94 | 95 | 101 | 102 | GitHub 103 | 104 | 105 |
106 |
107 | 108 | 109 | 110 |
111 | {@render children()} 112 |
113 |
114 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const ssr = false; 2 | export const prerender = true; 3 | export const trailingSlash = 'always'; 4 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 77 | 78 | 79 | 80 | 81 | 82 | {#each summaries as summary} 83 | { 86 | goto(base + summary.path); 87 | }} 88 | > 89 |
90 |
91 | {summary.value} 92 |
93 |
94 |
95 |
96 | 97 |
98 |
99 |
100 |
101 | {summary.title} 102 |
103 |
104 |
105 | {/each} 106 |
107 |
108 | -------------------------------------------------------------------------------- /src/routes/acls/+page.svelte: -------------------------------------------------------------------------------- 1 | 52 | 53 | 54 | 55 | 64 |
65 | 66 |
67 | 68 | {#if tabs[tabSet].name == 'groups'} 69 | 70 | {:else if tabs[tabSet].name == 'tag-owners'} 71 | 72 | {:else if tabs[tabSet].name == 'hosts'} 73 | 74 | {:else if tabs[tabSet].name == 'policies'} 75 | 76 | {:else if tabs[tabSet].name == 'ssh'} 77 | 78 | {:else if tabs[tabSet].name == 'config'} 79 | 80 | {/if} 81 | 82 |
83 |
84 | -------------------------------------------------------------------------------- /src/routes/acls/Config.svelte: -------------------------------------------------------------------------------- 1 | 80 | 81 | 82 |
83 | 88 | 91 | 109 | {#if editing} 110 | 113 | {:else} 114 | 117 | {/if} 118 | 121 |
122 | {#if !editing} 123 | 124 | {:else} 125 |
126 | { 127 | if(isTextContent(updatedContent)){ 128 | aclEditJSON = updatedContent 129 | } 130 | }} /> 131 |
132 | {/if} 133 |
134 | -------------------------------------------------------------------------------- /src/routes/acls/Groups.svelte: -------------------------------------------------------------------------------- 1 | 57 | 58 | 59 |
60 |
61 | 66 | 69 |
70 | {#if showCreateGroup} 71 | 77 | {/if} 78 |
79 | 80 |
81 | 88 |
89 | 90 | {#each filteredGroups as groupName} 91 | 92 | {/each} 93 | 94 |
95 | -------------------------------------------------------------------------------- /src/routes/acls/Hosts.svelte: -------------------------------------------------------------------------------- 1 | 61 | 62 | 63 | 64 |
65 |
66 | 71 | 74 |
75 | {#if showCreateHost} 76 | { 82 | newHost(); 83 | }} 84 | /> 85 | {/if} 86 |
87 | 88 |
89 | 96 |
97 | 98 | {#each filteredHosts as [hostName, hostCIDR]} 99 | 100 | {/each} 101 |
102 | -------------------------------------------------------------------------------- /src/routes/acls/Policies.svelte: -------------------------------------------------------------------------------- 1 | 61 | 62 | 63 |
64 |
65 | 70 | 73 |
74 |
75 | 76 |
77 | 84 |
85 | 86 | {#each filteredPolicies as {idx}} 87 | 94 | {/each} 95 | 96 |
97 | -------------------------------------------------------------------------------- /src/routes/acls/SshRules.svelte: -------------------------------------------------------------------------------- 1 | 57 | 58 | 59 |
60 |
61 | 66 | 69 |
70 |
71 | 72 |
73 | 80 |
81 | 82 | {#each filteredSshRules as {idx}} 83 | 84 | {/each} 85 | 86 |
-------------------------------------------------------------------------------- /src/routes/acls/TagOwners.svelte: -------------------------------------------------------------------------------- 1 | 52 | 53 | 54 |
55 |
56 | 61 | 64 |
65 | {#if showCreateTag} 66 | { 71 | newTag(); 72 | }} 73 | /> 74 | {/if} 75 |
76 | 77 |
78 | 85 |
86 | 87 | {#each filteredTags as tag} 88 | 89 | {/each} 90 | 91 |
92 | -------------------------------------------------------------------------------- /src/routes/deploy/+page.svelte: -------------------------------------------------------------------------------- 1 | 65 | 66 | 67 | 68 | {#snippet button()} 69 | 77 | {/snippet} 78 | 79 | 80 |
81 |

General:

82 | 87 | 92 | 97 | 102 | 103 | 104 | 109 | 114 | 119 |
120 | 126 | {#if deployment.preAuthKeyUser} 127 |
128 | 136 |
137 | {/if} 138 |
139 |
140 | 145 | 150 | 151 |

Advertise:

152 | 157 | 162 | { 167 | toastError('Tag should be a lowercase alphanumeric word', ToastStore); 168 | }} 169 | /> 170 | 171 | 176 | { 181 | toastError('Invalid CIDR Format', ToastStore); 182 | }} 183 | /> 184 | 185 | 186 |

Accept:

187 | 192 | 197 | 202 | 211 | 212 |
213 | 219 |
220 | -------------------------------------------------------------------------------- /src/routes/deploy/DeployCheck.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
44 |

{help}

45 |
46 |
47 | 48 |
49 |
50 | {#if help !== undefined} 51 | 59 | {/if} 60 | 61 | 62 |
63 | {#if children != undefined && checked} 64 |
65 | {@render children()} 66 |
67 | {/if} 68 |
69 | -------------------------------------------------------------------------------- /src/routes/nodes/+page.svelte: -------------------------------------------------------------------------------- 1 | 38 | 39 | 40 | 41 | {#snippet button()} 42 | 43 | {/snippet} 44 | 45 | 46 |
49 | 50 | 51 | 52 |
53 |
56 | 57 | 58 | 59 |
60 | 61 | 62 | {#each nodesSortedFiltered as node} 63 | 64 | {/each} 65 | 66 |
67 | -------------------------------------------------------------------------------- /src/routes/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 39 | {#snippet button()} 40 | x 41 | {/snippet} 42 | 43 | 44 |
47 | 48 | 49 |
50 |
53 | 54 | 55 | 56 |
57 | 58 | 59 | {#each nodesSortedFiltered as node} 60 | 61 | {/each} 62 | 63 |
64 | -------------------------------------------------------------------------------- /src/routes/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 92 | 93 | 94 | 95 |
96 |
97 |
98 | 99 | 107 |
108 | 109 |
110 | 111 |
112 | 120 | 133 | 151 |
152 | {#if apiKeyInfo.authorized !== null} 153 |
154 | 155 | {apiKeyInfo.authorized ? "Authorized" : "Not Authorized"} 156 | 157 | {#if apiKeyInfo.authorized && apiKeyExpirationMessage} 158 | 159 | Expires in: {apiKeyExpirationMessage.message} 160 | 161 | {/if} 162 |
163 | {:else if loading} 164 |
Checking authorization...
165 | {/if} 166 |
167 | 168 |
169 | 170 | 178 |
179 | 180 |
181 | 188 | 191 |
192 | 193 |
194 | 197 | 200 | 203 | 206 |
207 | 208 |
209 | 210 | 223 |
224 | 225 |
226 | 234 |
235 |
236 |
237 |
-------------------------------------------------------------------------------- /src/routes/users/+page.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | 44 | {#snippet button()} 45 | 46 | {/snippet} 47 | 48 | 49 |
52 | 53 | 54 |
55 |
58 | 59 | 60 | 61 |
62 | 63 | 64 | {#each usersSortedFiltered as user} 65 | 66 | {/each} 67 | 68 | 73 |
74 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/static/favicon.png -------------------------------------------------------------------------------- /static/fonts/AbrilFatface.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/static/fonts/AbrilFatface.ttf -------------------------------------------------------------------------------- /static/fonts/PlayfairDisplay-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/static/fonts/PlayfairDisplay-Italic.ttf -------------------------------------------------------------------------------- /static/fonts/Quicksand.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/static/fonts/Quicksand.ttf -------------------------------------------------------------------------------- /static/fonts/SpaceGrotesk.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoodiesHQ/headscale-admin/c8db9b9f59cf27ecd65ada772463eae1fb76fa33/static/fonts/SpaceGrotesk.ttf -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | extensions: ['.svelte'], 7 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 8 | // for more information about preprocessors 9 | preprocess: [ vitePreprocess()], 10 | vitePlugin: { 11 | inspector: true, 12 | }, 13 | kit: { 14 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 15 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 16 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 17 | adapter: adapter(), 18 | csrf: { 19 | checkOrigin: false, 20 | }, 21 | paths: { 22 | base: process.env.ENDPOINT, 23 | }, 24 | } 25 | }; 26 | export default config; -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import type { Config } from 'tailwindcss' 3 | import forms from '@tailwindcss/forms'; 4 | import typography from '@tailwindcss/typography'; 5 | import { skeleton } from '@skeletonlabs/tw-plugin' 6 | 7 | export default { 8 | darkMode: 'class', 9 | content: ['./src/**/*.{html,js,svelte,ts}', join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')], 10 | theme: { 11 | extend: {}, 12 | }, 13 | plugins: [ 14 | forms, 15 | typography, 16 | skeleton({ 17 | themes: { 18 | preset: [ 19 | { 20 | name: 'skeleton', 21 | enhancements: true, 22 | }, 23 | { 24 | name: 'wintry', 25 | enhancements: true, 26 | }, 27 | { 28 | name: 'modern', 29 | enhancements: true, 30 | }, 31 | { 32 | name: 'hamlindigo', 33 | enhancements: true, 34 | }, 35 | { 36 | name: 'rocket', 37 | enhancements: true, 38 | }, 39 | { 40 | name: 'sahara', 41 | enhancements: true, 42 | }, 43 | { 44 | name: 'gold-nouveau', 45 | enhancements: true, 46 | }, 47 | { 48 | name: 'vintage', 49 | enhancements: true, 50 | }, 51 | { 52 | name: 'seafoam', 53 | enhancements: true, 54 | }, 55 | { 56 | name: 'crimson', 57 | enhancements: true, 58 | }, 59 | ], 60 | }, 61 | }), 62 | ], 63 | } satisfies Config; 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { purgeCss } from 'vite-plugin-tailwind-purgecss'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | import { defineConfig } from 'vite'; 4 | import Icons from 'unplugin-icons/vite' 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | sveltekit(), 9 | purgeCss(), 10 | Icons({ 11 | autoInstall: true, 12 | compiler: 'svelte', 13 | }), 14 | ], 15 | }); 16 | --------------------------------------------------------------------------------