Compare commits

..

No commits in common. "master" and "2022.08.02-beta" have entirely different histories.

64 changed files with 3851 additions and 3460 deletions

3
.github/FUNDING.yml vendored
View file

@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [gurucomputing]

View file

@ -1,20 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
** Supporting Details **
Provide the following:
* Browser Version:
* Headscale Version:
* Any Browser Errors (`control+shift+i` in chrome to see)
** Note **
No bug reports are currently being accepted against the alpha version of headscale. Test against the production/stable version.
**Describe the bug**
A clear and concise description of what the bug is. Screenshots if applicable

View file

@ -16,15 +16,15 @@ jobs:
id: gathervars
run: |
# get a current BUILD_DATE
echo "BUILD_DATE=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_ENV
echo "::set-output name=BUILD_DATE::$(date +%Y%m%d-%H%M%S)"
# set version based on BUILD_DATE
echo "VERSION=$(date +%Y.%m.%d)-development" >> $GITHUB_ENV
echo "::set-output name=VERSION::$(date +%Y.%m.%d)-development"
# setting tags
echo "::set-output name=TAG::development"
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: actions/checkout@v2
- name: Log in to the Container registry
uses: docker/login-action@v1
@ -34,13 +34,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker Image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v2
with:
build-args: |
BUILD_DATE=${{ env.BUILD_DATE }}
VERSION=${{ env.VERSION }}
BUILD_DATE=${{ steps.gathervars.outputs.BUILD_DATE }}
VERSION=${{ steps.gathervars.outputs.VERSION }}
context: ./docker/development
tags: |
ghcr.io/${{ github.repository }}-dev:latest
ghcr.io/${{ github.repository }}-dev:${{ env.VERSION }}
ghcr.io/${{ github.repository }}-dev:${{ steps.gathervars.outputs.VERSION }}
push: true

View file

@ -10,7 +10,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Variable Gathering
id: gathervars
@ -18,69 +18,89 @@ jobs:
NOT_PREVIOUSLY_PUBLISHED=0
# get a current BUILD_DATE
VERSION=$(jq -r '.version' ./package.json)
echo "BUILD_DATE=$(date +%Y%m%d-%H%M%S)" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "::set-output name=BUILD_DATE::$(date +%Y%m%d-%H%M%S)"
echo "::set-output name=VERSION::$VERSION"
# setting tags
if echo "$VERSION" | grep -q "beta"; then
echo "TAGS=ghcr.io/${{ github.repository }}:beta, ghcr.io/${{ github.repository }}:$VERSION, ghcr.io/${{ github.repository }}:latest" >> $GITHUB_ENV
TAGS="ghcr.io/${{ github.repository }}:beta, ghcr.io/${{ github.repository }}:$VERSION, ghcr.io/${{ github.repository }}:latest"
PRIMARY_TAG=latest
else
echo "TAGS=ghcr.io/${{ github.repository }}:release, ghcr.io/${{ github.repository }}:latest, ghcr.io/${{ github.repository }}:$VERSION" >> $GITHUB_ENV
TAGS="ghcr.io/${{ github.repository }}:release, ghcr.io/${{ github.repository }}:latest, ghcr.io/${{ github.repository }}:$VERSION"
PRIMARY_TAG=latest
fi
echo "PRIMARY_TAG=latest" >> $GITHUB_ENV
echo "::set-output name=TAG::$TAGS"
echo "::set-output name=PRIMARY_TAG::$PRIMARY_TAG"
# check if version has already been published
$(docker manifest inspect ghcr.io/${{ github.repository }}:$VERSION > /dev/null) || NOT_PREVIOUSLY_PUBLISHED=1
echo "NOT_PREVIOUSLY_PUBLISHED=$NOT_PREVIOUSLY_PUBLISHED" >> $GITHUB_ENV
echo "::set-output name=NOT_PREVIOUSLY_PUBLISHED::$NOT_PREVIOUSLY_PUBLISHED"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Update Version in Code
run: |
sed -i 's/insert-version/${{ steps.gathervars.outputs.VERSION }}/g' ./src/routes/settings.html.svelte
- name: Log in to the Container registry
uses: docker/login-action@v3
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
uses: docker/login-action@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker Image
uses: docker/build-push-action@v6
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
uses: docker/build-push-action@v2
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
with:
build-args: |
BUILD_DATE=${{ env.BUILD_DATE }}
VERSION=${{ env.VERSION }}
BUILD_DATE=${{ steps.gathervars.outputs.BUILD_DATE }}
VERSION=${{ steps.gathervars.outputs.VERSION }}
context: ./docker/production
tags: |
${{ env.TAGS }}
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
${{ steps.gathervars.outputs.TAG }}
push: true
- name: Extract build out of docker image
uses: shrink/actions-docker-extract@v3
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
uses: shrink/actions-docker-extract@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
id: extract
with:
image: ghcr.io/${{ github.repository }}:${{ env.PRIMARY_TAG }}
image: ghcr.io/${{ github.repository }}:${{ steps.gathervars.outputs.PRIMARY_TAG }}
path: web
- name: create release asset
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
run: |
cd "${{ steps.extract.outputs.destination }}"
7z a headscale-ui.zip web
- name: Create Release
uses: softprops/action-gh-release@v2
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
- name: Create Draft Release
id: create_release
uses: actions/create-release@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.VERSION }}
name: headscale-ui
files: ${{ steps.extract.outputs.destination }}/headscale-ui.zip
generate_release_notes: true
make_latest: true
tag_name: ${{ steps.gathervars.outputs.VERSION }}
release_name: headscale-ui
draft: true
prerelease: false
- name: upload asset to releases
uses: actions/upload-release-asset@v1.0.1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ steps.extract.outputs.destination }}/headscale-ui.zip
asset_name: headscale-ui.zip
asset_content_type: application/zip
- name: publish release
uses: eregon/publish-release@v1
if: ${{ steps.gathervars.outputs.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.create_release.outputs.id }}

View file

@ -1,7 +1,13 @@
:8080 {
redir / /web
uri strip_prefix /web
file_server {
root ./build
}
{
skip_install_trust
}
:443 {
redir / /web
uri strip_prefix /web
tls internal {
on_demand
}
file_server {
root ./build
}
}

View file

@ -1,29 +0,0 @@
BSD 3-Clause License
Copyright (c) 2022, gurucomputing
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

109
README.md
View file

@ -4,9 +4,6 @@ A web frontend for the [headscale](https://github.com/juanfont/headscale) Tailsc
![](documentation/assets/headscale-ui-demo.gif)
## Installation
> [!WARNING]
> The latest major release of headscale ui change the default container ports from `80` and `443` to `8080` and `8443` respectively. If you are using the `HTTP_PORT` or `HTTPS_PORT` environment variables this does not affect you, otherwise you need to change your ports in your docker-compose or kubernetes manifests.
Headscale-UI is currently released as a static site: just take the release and host with your favorite web server. Headscale-UI expects to be served from the `/web` path to avoid overlap with headscale on the same domain. Note that due to CORS (see https://github.com/juanfont/headscale/issues/623), headscale UI *must* be served on the same subdomain, or CORS headers injected via reverse proxy.
### Docker Installation
@ -16,111 +13,73 @@ If you are using docker, you can install `headscale` alongside `headscale-ui`, l
version: '3.5'
services:
headscale:
image: headscale/headscale:stable
image: headscale/headscale:latest-alpine
container_name: headscale
volumes:
- ./container-config:/etc/headscale
- ./container-data/data:/var/lib/headscale
# ports:
# - 27896:8080
command: serve
command: headscale serve
restart: unless-stopped
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
restart: unless-stopped
container_name: headscale-ui
# ports:
# - 8443:8443
# - 8080:8080
# - 9443:443
```
Headscale UI serves on port 8080/8443 and uses a self signed cert by default. You will need to add a `config.yaml` file under your `container-config` folder so that `headscale` has all of the required settings declared. An example from the official `headscale` repo is [here](https://github.com/juanfont/headscale/blob/main/config-example.yaml).
### Additional Docker Settings
The docker container lets you set the following settings:
| Variable | Description | Example |
|----|----|----|
| HTTP_PORT | Sets the HTTP port to an alternate value | `8080` |
| HTTPS_PORT | Sets the HTTPS port to an alternate value | `8443` |
Headscale UI serves on port 443 and uses a self signed cert by default.
### Proxy Settings
You will need a reverse proxy to install `headscale-ui` on your domain. Here is an example [Caddy Config](https://caddyserver.com/) to achieve this:
```
https://hs.yourdomain.com.au {
reverse_proxy /web* http://headscale-ui:8080
reverse_proxy /web* https://headscale-ui {
transport http {
tls_insecure_skip_verify
}
}
reverse_proxy * http://headscale:8080
}
```
### Cross Domain Installation
If you do not want to configure headscale-ui on the same subdomain as headscale, you must intercept headscale traffic via your reverse proxy to fix CORS (see https://github.com/juanfont/headscale/issues/623). Here is an example fix with Caddy, replacing your headscale UI domain with `hs-ui.yourdomain.com.au`:
```
https://hs.yourdomain.com.au {
@hs-options {
host hs.yourdomain.com.au
method OPTIONS
}
@hs-other {
host hs.yourdomain.com.au
}
handle @hs-options {
header {
Access-Control-Allow-Origin https://hs-ui.yourdomain.au
Access-Control-Allow-Headers *
Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"
}
respond 204
}
handle @hs-other {
reverse_proxy http://headscale:8080 {
header_down Access-Control-Allow-Origin https://hs-ui.yourdomain.com.au
header_down Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"
header_down Access-Control-Allow-Headers *
}
}
hs.yourdomain.com.au {
@hs-options {
host hs.yourdomain.com.au
method OPTIONS
}
@hs-other {
host hs.yourdomain.com.au
}
handle @hs-options {
header {
Access-Control-Allow-Origin https://hs-ui.yourdomain.au
Access-Control-Allow-Headers *
Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"
}
respond 204
}
handle @hs-other {
reverse_proxy http://headscale:8080 {
header_down Access-Control-Allow-Origin https://hs-ui.yourdomain.com.au
header_down Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"
header_down Access-Control-Allow-Headers *
}
}
}
```
### Other Configurations
See [Other Configurations](/documentation/configuration.md) for further proxy examples, such as Traefik
## Versioning
The following versions correspond to the appropriate headscale version
| Headscale Version | HS-UI Version |
|-------------------|---------------|
| 26+ | 2025-05-22+ |
| 25+ | 2025-03-14+ |
| 24+ | 2025-01-20+ |
| 23+ | 2024-10-01+ |
| 19+ | 2023-01-30+ |
| <19 | <2023-01-30 |
## Troubleshooting
Make sure you are using the latest version of headscale. Headscale-UI is only tested against:
* The current stable version of headscale
* Chrome/Chrome Mobile
* Firefox/Firefox Mobile
Note that while mobile is checked for functionality, the web experience is not mobile optimised.
If you are getting errors about preflight checks, it's probably CORS related. Make sure your UI sits on the same subdomain as headscale or inject CORS headers.
### Errors related to "Missing Bearer Prefix"
Your API key is either not saved or you haven't configured your reverse proxy. Create an API key in `headscale` (via command line) with `headscale apikeys create` or `docker exec <headscale container> headscale apikeys create` and save it in `settings`.
HS-UI *has* to be ran on the same subdomain as headscale or you need to configure CORS. Yes you need to use a reverse proxy to do this. Use a reverse proxy. If you are trying to use raw IPs and ports, it *will* not work.
## Security
see [security](/SECURITY.md) for details
## Development
see [development](/documentation/development.md) for details
## Style Guide
### Style Guide
see [style](/documentation/style.md) for details
## Architecture

View file

@ -1,12 +0,0 @@
### Authentication and Authorization
In the current client-only format, the headscale API secret is stored within the browser's `localStorage` area. While `localStorage` is not an ideal location for secrets storage, it is currently the *only* possible method of securing data to a browser without some sort of backend facilitation.
What this means to *you* is that your API credentials are tied to your browser profile. If you open an incognito window or another browser profile, your API key will *not* carry across.
`localStorage` secrets have the possibility of being exploited by XSS. This exploitation avenue is mitigated by the static nature of the site: all pages are protected by a hashsum CSP (content security protection) that prevent modifying or adding javascript from other sources.
The future state for `heascale-ui` is not to rely on `localStorage` at all, but due to the architecture, any other methods require tighter integration with the core `headscale` product. For now this is not on the headscale roadmap.
## Vulnerability Disclosure
If any method of bypassing or leaking the `localStorage` secrets is found, please contact myself directly at `chris@gurucomputing.com.au` rather than opening an issue.

View file

@ -1,19 +1,24 @@
FROM node:lts
FROM node:latest
# Arguments
ARG OPENVSCODE_VERSION="1.69.2"
# Volumes
VOLUME /data
# Ports
# openvscode server port. Note: Runs HTTP by default
EXPOSE 3000
# Dev Web Server port. Runs a self signed SSL certificate
EXPOSE 443
# System Environment Variables
ENV PATH="/opt/vscode:${PATH}"
ENV HOME="/data/home"
ENV SHELL="/bin/bash"
# User Set Environment Variables
# Set to false if you do not want to attempt to pull a repository on first load
ENV AUTOINITIALIZE=false
ENV AUTOINITIALIZE=true
# sets a connection token for VSCode Server. https://github.com/gitpod-io/openvscode-server#securing-access-to-your-ide
ENV USE_CONNECTION_TOKEN=true
#Set to a secret to have some measure of protection for vscode. Randomized if left blank
@ -23,7 +28,7 @@ ENV PROJECT_NAME="headscale-ui"
# URL for the github/git location
ENV PROJECT_URL="https://github.com/gurucomputing/headscale-ui"
# autostart the dev command on boot?
ENV AUTOSTART="false"
ENV AUTOSTART=true
# command to run in the background on startup
ENV DEV_COMMAND="npm run dev"
@ -44,8 +49,8 @@ RUN chmod -R 755 scripts
RUN /staging/scripts/1-image-build.sh
# set to the non-root user
USER 1000:1000
USER node
WORKDIR /data
ENTRYPOINT /bin/sh /staging/scripts/2-initialise.sh
ENTRYPOINT /bin/sh /staging/scripts/2-initialise.sh

View file

@ -1,22 +1,44 @@
#!/bin/sh
# script environment
# turn on bash logging, exit on error
set -ex
# turn on bash logging
set -x
# # create a non-root user. Not needed for node image
# useradd -m -d /data/home dev-user
# script variables
OPENVSCODE_URL="https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v$OPENVSCODE_VERSION/openvscode-server-v$OPENVSCODE_VERSION-linux-x64.tar.gz"
OPENVSCODE_RELEASE="openvscode-server-v$OPENVSCODE_VERSION-linux-x64"
CADDY_URL="https://caddyserver.com/api/download?os=linux&arch=amd64"
# set new home directory
mkdir -p /data/home
usermod -d /data/home node
# install dependencies
# tmux used for monitoring secondary processes
# sudo for running specific commands as root
apt-get update
apt-get install -y tmux sudo
# set the default shell to the chosen shell
usermod --shell ${SHELL} node
# Add the ability to set file permissions on /data to the non-privileged user
echo "ALL ALL=NOPASSWD: /bin/chown -R 1000\:1000 /data" >> /etc/sudoers
# install dependencies
/staging/scripts/install-container-dependencies.sh
/staging/scripts/install-openvscode-server.sh
# install openVSCode
cd /opt
### Download Open VSCode
curl -LJO "$OPENVSCODE_URL"
### Extract and move into directory
tar -xzf "$OPENVSCODE_RELEASE.tar.gz"
mv $OPENVSCODE_RELEASE openvscode-server
rm -f "$OPENVSCODE_RELEASE.tar.gz"
### download caddy
curl -LJO "$CADDY_URL"
chmod +x caddy_linux_amd64
mv caddy_linux_amd64 /usr/bin/caddy
# create data and home directories
mkdir -p /data/home
# set tmux to use mouse scroll
echo "set -g mouse on" > /data/home/.tmux.conf

View file

@ -14,7 +14,7 @@ then
echo "---- Forcing File Permissions to the node user ----"
sudo /bin/chown -R 1000:1000 /data
else
echo "---- You are not running as the default non-root user AND your file permissions don't match your user ---\n"
echo "---- You are not running as the node user AND your file permissions don't match your user ---\n"
echo "---- You may need to manually fix your file permissions ----"
fi
fi
@ -40,6 +40,7 @@ then
cd /data
git clone ${PROJECT_URL}
cd ${PROJECT_NAME}
npm install
else
cd /data/${PROJECT_NAME}
fi

View file

@ -1,8 +0,0 @@
# install dependencies
# tmux used for monitoring secondary processes
# sudo for running specific commands as root
# ncdu file navigation
# caddy web server
apt-get update
apt-get install -y --no-install-recommends tmux sudo git ncdu caddy
apt-get clean

View file

@ -1,15 +0,0 @@
# script variables
OPENVSCODE_VERSION="1.103.1"
OPENVSCODE_URL="https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v$OPENVSCODE_VERSION/openvscode-server-v$OPENVSCODE_VERSION-linux-x64.tar.gz"
OPENVSCODE_RELEASE="openvscode-server-v$OPENVSCODE_VERSION-linux-x64"
# install openVSCode
cd /opt
### Download Open VSCode
curl -LJO "$OPENVSCODE_URL"
### Extract and move into directory
tar -xzf "$OPENVSCODE_RELEASE.tar.gz"
mv $OPENVSCODE_RELEASE openvscode-server
rm -f "$OPENVSCODE_RELEASE.tar.gz"

View file

@ -1,25 +1,13 @@
{
skip_install_trust
auto_https disable_redirects
http_port {$HTTP_PORT}
https_port {$HTTPS_PORT}
}
:{$HTTP_PORT} {
redir / /web
uri strip_prefix /web
file_server {
root /web
}
}
:{$HTTPS_PORT} {
redir / /web
uri strip_prefix /web
tls internal {
on_demand
}
file_server {
root /web
}
skip_install_trust
}
:443 {
redir / /web
uri strip_prefix /web
tls internal {
on_demand
}
file_server {
root /web
}
}

View file

@ -1,9 +1,7 @@
FROM node:lts AS build
# arguments
ARG VERSION="master"
# Branch to check out
ARG CHECKOUT_BRANCH="master"
FROM node:latest AS build
#environment variables
ENV PROJECT_NAME="headscale-ui"
@ -26,19 +24,14 @@ RUN chmod -R 755 scripts
# Build the image. This build runs as root
RUN /staging/scripts/1-image-build.sh
#####
## Second Image
#####
FROM alpine:latest
#environment variables
ENV PROJECT_NAME="headscale-ui"
# URL for the github/git location
ENV PROJECT_URL="https://github.com/gurucomputing/headscale-ui"
# Ports that caddy will run on
ENV HTTP_PORT="8080"
ENV HTTPS_PORT="8443"
# Port that caddy will run on
ENV PORT="443"
# Production Web Server port. Runs a self signed SSL certificate
EXPOSE 443

View file

@ -1,20 +1,12 @@
#!/bin/sh
set -x
# add dependencies
# git for cloning the repository
apk add --no-cache git
#clone the project
git clone ${PROJECT_URL} ${PROJECT_NAME}
cd ${PROJECT_NAME}
git checkout ${CHECKOUT_BRANCH}
# install the project
cd ${PROJECT_NAME}
npm install
# inject the version number
sed -i "s/insert-version/${VERSION}/g" ./src/routes/settings.html/+page.svelte
# build the project
npm run build

View file

@ -12,5 +12,11 @@ then
cp /staging/Caddyfile /data/Caddyfile
fi
# replace port in Caddyfile if set
if [ "$PORT" != "443" ]
then
sed -i "s/:443/$PORT/g" /data/Caddyfile
fi
echo "Starting Caddy"
/usr/sbin/caddy run --adapter caddyfile --config /data/Caddyfile
/usr/sbin/caddy run --adapter caddyfile --config /data/Caddyfile

View file

@ -7,6 +7,11 @@ Headscale-UI uses the `static` adapter built into svelte-kit, meaning that sever
### Client Side Design
All Headscale-UI features and functions should be client side only. *Any* backend features should be considered to be implemented in a separate backend. This can be the [Headscale](https://github.com/juanfont/headscale) application itself (preferred), or potentially implementing a Backend-as-a-Service API such as [Supabase](https://supabase.com/).
### Authentication and Authorization
In the current alpha format, the headscale API secret is stored within the browser's `localStorage` area. This method of credential storage is not ideal as localStorage can potentially be exploited by XSS (cross-site scripting) vulnerabilities. The long term goal is to integrate Headscale-UI into Headscale's OIDC authentication capabilities, but discovery is required to implement this feature (as well as cooperation from the upstream project).
For now, it is recommended that credentials only be saved on trusted computers and to use short API key expiries where possible.
## Dependencies
Dependencies are kept to a minimum and kept to large, actively maintained repositories. Great care should be taken before suggesting or adding any additional dependencies: headscale is a sensitive tool and attack surfaces must be kept minimal.
@ -15,5 +20,4 @@ Dependencies are kept to a minimum and kept to large, actively maintained reposi
* [Tailwind CSS](https://tailwindcss.com/) - CSS Framework
* [DaisyUI](https://daisyui.com/) - CSS Theme and Components
* [Typescript](https://www.typescriptlang.org/) - for static type checking
* [Prettier](https://prettier.io/) - for Code Formatting
* [Fuse.js](https://fusejs.io/) - for intelligent searching
* [Prettier](https://prettier.io/) - for Code Formatting

View file

@ -1,133 +0,0 @@
# Traefik Configuration
(Thanks [DennisGaida](https://github.com/DennisGaida) and [Niek](https://github.com/Niek))
Below is a complete docker-compose example for bringing up Traefik + headscale + headscale-ui. Run with: `docker-compose up -d` and headscale-ui will be accessible at <http://localhost/web>.
```yaml
version: '3.9'
services:
headscale:
image: headscale/headscale:latest
pull_policy: always
container_name: headscale
restart: unless-stopped
command: serve
volumes:
- ./headscale/config:/etc/headscale
- ./headscale/data:/var/lib/headscale
labels:
- traefik.enable=true
- traefik.http.routers.headscale-rtr.rule=PathPrefix(`/`) # you might want to add: && Host(`your.domain.name`)"
- traefik.http.services.headscale-svc.loadbalancer.server.port=8080
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
pull_policy: always
container_name: headscale-ui
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.headscale-ui-rtr.rule=PathPrefix(`/web`) # you might want to add: && Host(`your.domain.name`)"
- traefik.http.services.headscale-ui-svc.loadbalancer.server.port=8080
traefik:
image: traefik:latest
pull_policy: always
restart: unless-stopped
container_name: traefik
command:
- --api.insecure=true # remove in production
- --providers.docker
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --global.sendAnonymousUsage=false
ports:
- 80:80
- 443:443
- 8080:8080 # web UI (enabled with api.insecure)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/certificates:/certificates
```
# NGINX Proxy Manager Configuration
If running Headscale and Headscale UI outside of a consolidated docker-compose file (as above), NGINX Proxy Manager is another easy way to run all three. NGINX Proxy Manager is an easy way to run Headscale and Headscale UI behind a reverse proxy that can manager SSL certs automatically. This assumes the following:
1. Headscale is set up on your Docker host (or another location you can route to) per the instructions [here](https://github.com/juanfont/headscale).
2. NGINX Proxy Manager is running and you can use it to generate SSL certificates. More information on NGINX Proxy Manager are [here](https://github.com/NginxProxyManager/nginx-proxy-manager).
Use this simplified docker-compose file to run headscale-ui:
```yaml
version: '3.5'
services:
headscale-ui:
image: ghcr.io/gurucomputing/headscale-ui:latest
restart: unless-stopped
container_name: headscale-ui
ports:
- 8443:443 # Use the port of your choice, but map it to 443 on the container
```
Once all three services are running, set up Headscale and Headscale UI _by creating a proxy host_:
1. Details: Enter the FQDN you will be using for Headscale and Headscale UI, and enable Websockets Support and Block Common Exploits.
2. SSL: Select or create the SSL certificate you'll be using for the entire FQDN where both will run. Make sure to enable Force SSL, HTTP/2 Support, HSTS and HSTS Subdomains.
3. Advanced: In the text box, add the following to manage the Headscale UI path properly:
```json
location /web/ {
proxy_pass https://XXX.XXX.XXX.XXXX:port/web/;
}
```
# Nginx Example Configuration
From https://github.com/gurucomputing/headscale-ui/issues/71
```
map $http_upgrade $connection_upgrade {
default keep-alive;
'websocket' upgrade;
'' close;
}
server {
server_name headscale-01.example.com;
location /web {
alias /usr/local/www/headscale-ui;
index index.html;
}
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $server_name;
proxy_redirect http:// https://;
proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
}
listen 443 ssl;
ssl_certificate fullchain.pem;
ssl_certificate_key privkey.pem;
[...]
}
server {
if ($host = headscale-01.example.com) {
return 301 https://$host$request_uri;
}
server_name headscale-01.example.com;
listen 80;
return 404;
}
```

View file

@ -4,7 +4,7 @@ Development can be done either by using the official development docker image, o
## Testing
All branches should undergo manual testing as specified in the [Testing](./testing.md) document. If someone is well versed in unit automation tests for browser front ends, please educate me! For now do it manually before making a pull request.
All branches should undergo manual testing as specified in the [System Integration Testing](./system-integration-testing.md) document. If someone is well versed in unit automation tests for browser front ends, please educate me! For now do it manually before making a pull request.
### Quick Start (Docker)
* `docker run -p 443:443 -p 3000:3000 -v "$(pwd)"/data:/data ghcr.io/gurucomputing/headscale-ui-dev:latest`

View file

@ -1,27 +0,0 @@
# Route Queries
Some routes offer additional behavior to a route when a `?` exists in the URL. These are called query string parameters or route queries. Route queries are used to modify the behavior of a route. Below are the available route queries.
## Devices
/devices.html
### Parameters
#### `?nodekey={nodekey of a pending device}`
When this parameter exists, it will automatically open the New Device form and pre-fill the Device Key input automatically. Everything right of the `=` is used as the value of the input.
Below is an example of how to set up a redirect in NGINX from the default headscale /register/{nodekey} URL to utilize this parameter:
```nginx
...
location /register/nodekey {
rewrite ^/register/(.*)$ /web/devices.html?nodekey=$1 redirect;
}
location /web {
...
```

View file

@ -0,0 +1,30 @@
## Tests Before Release
Eventually it would be nice to automate this, but I've found a front end is difficult to fully automate testing. Prove me wrong other users!
## User Testing
* Create a User
* Delete a User
* Rename a User
* Create a PreAuth Key
* Try all Sort Categories
## Device Testing
* Add a Device with a Preauth Key
* Add a Device with a machine key
* Add a Device with OIDC (if set up to do so)
* Rename a Device
* Try all sort categories
* Create a Tag
* Delete a Tag
* Delete a Device
* Add and approve a route (if set up to do so)
* Change the assigned user for a device
## Failure Test
* Test messages (both console and alerts) with failed apikey
* Test recovery once apikey is back
* Test messages (both console and alerts) with failed URL
* Test recovery once URL is back
## Settings Test
* Verify version comes across once released

View file

@ -1,48 +0,0 @@
## Using the Test Dockerfiles
The `/docker/test` folder contains a number of local test containers for testing before release. Specifically, the `docker-compose` creates a local environment with self signed keys, and the `docker-compose-workers` set up a bunch of clients that trust those keys.
To use this environment, do the following:
* Navigate to the `/docker/test/test-server` directory
* Create a test network: `docker network create headscale-ui-test-network`
* Stand up the `docker-compose` with `docker-compose up -d`. This will expose an HTTP (not https) portal on `8080`
* Generate an API key with `docker exec headscale-test-backend headscale apikeys create`
* Paste the api key into the UI at `http://<your-ip>:8080/web`
* Generate a pre-auth key that's reusable and ephemeral. save it into `.env` in the `test-workers` folder as the following:
* `PREAUTH_KEY=<Your Preauth Key>`
* move to the `test-workers` directory, and stand up the works with `docker-compose up -d`
* Run your tests in the UI
* Bring down the environment(s) with `docker-compose down`. By default nothing is persisted.
## Tests Before Release
Eventually it would be nice to automate this, but I've found a front end is difficult to fully automate testing. Prove me wrong other users!
## User Testing
* Create a User
* Delete a User
* Rename a User
* Create a PreAuth Key
* Try all Sort Categories
## Device Testing
* Add a Device with a Preauth Key
* Add a Device with a machine key
* Add a Device with OIDC (if set up to do so)
* Rename a Device
* Try all sort categories
* Create a Tag
* Delete a Tag
* Delete a Device
* Add and approve a route (if set up to do so)
* Change the assigned user for a device
## Failure Test
* Test messages (both console and alerts) with failed apikey
* Test recovery once apikey is back
* Test messages (both console and alerts) with failed URL
* Test recovery once URL is back
## Settings Test
* Verify version comes across once released
## Docker Test
* Verify setting a custom PORT environment variable does not break the image

5481
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"name": "headscale-ui",
"version": "2025.08.23",
"version": "2022.08.02-beta",
"scripts": {
"dev": "vite dev --port 8080 --host 0.0.0.0",
"dev": "vite dev --https --port 443 --host 0.0.0.0",
"build": "vite build",
"package": "vite package",
"preview": "vite preview --https --port 443 --host 0.0.0.0",
@ -13,24 +13,25 @@
"format": "prettier --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4",
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/vite-plugin-svelte": "^3",
"@tailwindcss/typography": "^0",
"@vitejs/plugin-basic-ssl": "^1",
"autoprefixer": "^10",
"daisyui": "^4",
"fuse.js": "^7",
"postcss": "^8",
"postcss-load-config": "^5",
"prettier": "^3",
"prettier-plugin-svelte": "^3",
"svelte": "^4",
"svelte-check": "^3",
"svelte-preprocess": "^5",
"tailwindcss": "^3",
"typescript": "^5"
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-static": "^1.0.0-next.34",
"@sveltejs/kit": "next",
"@tailwindcss/typography": "github:tailwindcss/typography",
"@vitejs/plugin-basic-ssl": "^0.1.1",
"autoprefixer": "^10.4.4",
"daisyui": "^2.19.0",
"fuse.js": "^6.6.2",
"postcss": "^8.4.12",
"postcss-load-config": "^3.1.4",
"prettier": "^2.6.2",
"prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-preprocess": "^4.10.7",
"tailwindcss": "^3.0.23",
"typescript": "^4.7.2"
},
"type": "module"
"type": "module",
"dependencies": {
}
}

View file

@ -8,7 +8,7 @@
}
.card-primary {
@apply grid grid-cols-1 divide-y p-2 max-w-screen-lg mx-4 border-base-content rounded-md text-sm text-base-content shadow
@apply grid grid-cols-1 divide-y p-2 max-w-screen-lg border mx-4 border-base-content rounded-md text-sm text-base-content shadow
}
.card-pending {

View file

@ -30,11 +30,10 @@
</script>
{#if visible}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
transition:slide|global
transition:slide
class="absolute alert text-lg left-1/2 transform -translate-x-1/2 justify-center shadow-lg max-w-lg"
on:keypress on:click={() => {
on:click={() => {
$alertStore = '';
}}
>

View file

@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { deviceSortStore, deviceSortDirectionStore, userSortStore, sortDirectionStore, themeStore, showACLPagesStore} from '$lib/common/stores.js';
import { deviceSortStore, deviceSortDirectionStore, userSortStore, sortDirectionStore, themeStore } from '$lib/common/stores.js';
import { URLStore } from '$lib/common/stores.js';
import { APIKeyStore } from '$lib/common/stores.js';
import { preAuthHideStore } from '$lib/common/stores.js';
@ -24,8 +24,7 @@
// stores URL and API key
URLStore.set(localStorage.getItem('headscaleURL') || '');
// remove trailing slashes when storing the URL
URLStore.subscribe((val) => localStorage.setItem('headscaleURL', val.replace(/\/+$/, '')));
URLStore.subscribe((val) => localStorage.setItem('headscaleURL', val));
APIKeyStore.set(localStorage.getItem('headscaleAPIKey') || '');
APIKeyStore.subscribe((val) => localStorage.setItem('headscaleAPIKey', val));
@ -33,9 +32,5 @@
preAuthHideStore.set((localStorage.getItem('headscalePreAuthHide') || 'false') == 'true');
preAuthHideStore.subscribe((val) => localStorage.setItem('headscalePreAuthHide', val ? 'true' : 'false'));
// dev setting stores
showACLPagesStore.set((localStorage.getItem('showACLPages') || 'false') == 'true');
showACLPagesStore.subscribe((val) => localStorage.setItem('showACLPages', val ? 'true' : 'false'));
});
</script>

View file

@ -1,16 +1,17 @@
<script context="module" lang="ts">
import { APIKey, Device, PreAuthKey, User } from '$lib/common/classes';
import { deviceStore, userStore, apiTestStore} from '$lib/common/stores.js';
import { sortDevices, sortUsers } from '$lib/common/sorting.svelte';
import { Device, PreAuthKey, Route, User } from '$lib/common/classes';
import { deviceStore, userStore, apiTestStore } from '$lib/common/stores.js';
import { filterDevices, filterUsers } from './searching.svelte';
export async function getUsers() {
export async function getUsers(): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
let sortKey = localStorage.getItem('headscaleUserSort') || '';
let sortDirection = localStorage.getItem('headscaleUserSortDirection') || '';
// endpoint url for getting users
let endpointURL = '/api/v1/user';
let endpointURL = '/api/v1/namespace';
//returning variables
let headscaleUsers = [new User()];
@ -40,9 +41,11 @@
});
await headscaleUsersResponse.json().then((data) => {
headscaleUsers = data.users;
// sort the users
headscaleUsers = sortUsers(headscaleUsers);
if (sortDirection == 'ascending') {
headscaleUsers = data.namespaces.sort((a: User, b: User) => (a[sortKey as keyof User] < b[sortKey as keyof User] ? -1 : 1));
} else {
headscaleUsers = data.namespaces.sort((a: User, b: User) => (a[sortKey as keyof User] > b[sortKey as keyof User] ? -1 : 1));
}
});
// Set the store
apiTestStore.set('succeeded');
@ -51,13 +54,13 @@
filterUsers();
}
export async function editUser(currentUserId: string, newUsername: string): Promise<any> {
export async function editUser(currentUsername: string, newUsername: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/user/' + currentUserId + '/rename/' + newUsername;
let endpointURL = '/api/v1/namespace/' + currentUsername + '/rename/' + newUsername;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -80,85 +83,13 @@
});
}
export async function newAPIKey(APIKeyExpiration: string): Promise<string> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/apikey';
let APIKeyResponse = new Response();
let APIKeyString = '';
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
expiration: APIKeyExpiration
})
})
.then((response) => {
if (response.ok) {
APIKeyResponse = response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
await APIKeyResponse.json().then((data) => {
APIKeyString = data.apiKey;
});
return APIKeyString;
}
export async function expireAPIKey(APIKeyPrefix: string) {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/apikey/expire';
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
prefix: APIKeyPrefix
})
})
.then((response) => {
if (response.ok) {
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
}
export async function updateTags(deviceID: string, tags: string[]): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = `/api/v1/node/${deviceID}/tags`;
let endpointURL = '/api/v1/machine/' + deviceID + '/tags';
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -184,13 +115,13 @@
});
}
export async function removeUser(currentUserId: string): Promise<any> {
export async function removeUser(currentUsername: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/user/' + currentUserId;
let endpointURL = '/api/v1/namespace/' + currentUsername;
await fetch(headscaleURL + endpointURL, {
method: 'DELETE',
@ -219,7 +150,7 @@
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/user';
let endpointURL = '/api/v1/namespace';
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -246,13 +177,14 @@
}
export async function getDevices(): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
let sortKey = localStorage.getItem('headscaleDeviceSort') || '';
let sortDirection = localStorage.getItem('headscaleDeviceSortDirection') || '';
// endpoint url for getting devices
let endpointURL = `/api/v1/node`;
// endpoint url for getting users
let endpointURL = '/api/v1/machine';
//returning variables
let headscaleDevices = [new Device()];
@ -283,8 +215,11 @@
});
await headscaleDeviceResponse.json().then((data) => {
headscaleDevices = data[`nodes`];
headscaleDevices = sortDevices(headscaleDevices);
if (sortDirection == 'ascending') {
headscaleDevices = data.machines.sort((a: Device, b: Device) => (a[sortKey as keyof Device] < b[sortKey as keyof Device] ? -1 : 1));
} else {
headscaleDevices = data.machines.sort((a: Device, b: Device) => (a[sortKey as keyof Device] > b[sortKey as keyof Device] ? -1 : 1));
}
});
// set the stores
apiTestStore.set('succeeded');
@ -293,15 +228,17 @@
filterDevices();
}
export async function getAPIKeys(): Promise<APIKey[]> {
export async function getDeviceRoutes(deviceID: string): Promise<Route> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = '/api/v1/apikey';
let apiKeysResponse = new Response();
let apiKeys = [new APIKey()];
// endpoint url for getting users
let endpointURL = '/api/v1/machine/' + deviceID + '/routes';
//returning variables
let headscaleRoute = new Route();
let headscaleDeviceResponse: Response = new Response();
await fetch(headscaleURL + endpointURL, {
method: 'GET',
@ -312,7 +249,8 @@
})
.then((response) => {
if (response.ok) {
apiKeysResponse = response;
// return the api data
headscaleDeviceResponse = response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
@ -323,13 +261,46 @@
throw error;
});
await apiKeysResponse.json().then((data) => {
apiKeys = data.apiKeys;
await headscaleDeviceResponse.json().then((data) => {
headscaleRoute = data.routes;
});
return apiKeys;
return headscaleRoute;
}
export async function getPreauthKeys(userID: string): Promise<PreAuthKey[]> {
export async function enableDeviceRoute(deviceID: string, route: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for getting users
let endpointURL = '/api/v1/machine/' + deviceID + '/routes?routes=' + route.replace('/', '%2F');
//returning variables
let headscaleDeviceResponse: Response = new Response();
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
}
})
.then((response) => {
if (response.ok) {
// return the api data
headscaleDeviceResponse = response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
}
export async function getPreauthKeys(userName: string): Promise<PreAuthKey[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -341,7 +312,7 @@
let headscalePreAuthKey = [new PreAuthKey()];
let headscalePreAuthKeyResponse: Response = new Response();
await fetch(headscaleURL + endpointURL + '?user=' + userID, {
await fetch(headscaleURL + endpointURL + '?namespace=' + userName, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -367,7 +338,7 @@
return headscalePreAuthKey;
}
export async function newPreAuthKey(userID: string, expiry: string, reusable: boolean, ephemeral: boolean): Promise<any> {
export async function newPreAuthKey(userName: string, expiry: string, reusable: boolean, ephemeral: boolean): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -381,7 +352,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userID,
namespace: userName,
expiration: expiry,
reusable: reusable,
ephemeral: ephemeral
@ -401,7 +372,7 @@
});
}
export async function removePreAuthKey(userID: string, preAuthKey: string): Promise<any> {
export async function removePreAuthKey(userName: string, preAuthKey: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -416,7 +387,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userID,
namespace: userName,
key: preAuthKey
})
})
@ -434,16 +405,15 @@
});
}
export async function newDevice(key: string, userId: string): Promise<any> {
export async function newDevice(key: string, userName: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = `/api/v1/node/register`;
let endpointURL = '/api/v1/machine/register';
await fetch(headscaleURL + endpointURL + '?user=' + userId + '&key=' + key, {
await fetch(headscaleURL + endpointURL + '?namespace=' + userName + '&key=' + key, {
method: 'POST',
headers: {
Accept: 'application/json',
@ -464,24 +434,20 @@
});
}
export async function moveDevice(deviceID: string, userID: string): Promise<any> {
export async function moveDevice(deviceID: string, user: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = `/api/v1/node/${deviceID}/user`;
let endpointURL = '/api/v1/machine/' + deviceID + '/namespace?namespace=' + user;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: parseInt(userID)
})
}
})
.then((response) => {
if (response.ok) {
@ -498,13 +464,12 @@
}
export async function renameDevice(deviceID: string, name: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for editing users
let endpointURL = `/api/v1/node/${deviceID}/rename/${name}`;
let endpointURL = '/api/v1/machine/' + deviceID + '/rename/' + name;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
@ -528,13 +493,12 @@
}
export async function removeDevice(deviceID: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for removing devices
let endpointURL = `/api/v1/node/${deviceID}`;
let endpointURL = '/api/v1/machine/' + deviceID;
await fetch(headscaleURL + endpointURL, {
method: 'DELETE',

View file

@ -1,64 +1,48 @@
export class Device {
public id: string = '';
public name: string = '';
public givenName: string = '';
public lastSeen: string = '';
public ipAddresses: string[] = [];
public forcedTags: string[] = [];
public validTags: string[] = [];
public invalidTags: string[] = [];
public approvedRoutes: string[] = [];
public availableRoutes: string[] = [];
public subnetRoutes: string[] = [];
public user: User = new User();
public online?: boolean;
public id: string = '';
public name: string = '';
public givenName: string = '';
public lastSeen: string = '';
public ipAddresses: string[] = []
public forcedTags: string[] = []
public validTags: string[] = []
public invalidTags: string[] = []
public namespace: { name: string } = { name: '' }
public constructor(init?: Partial<Device>) {
Object.assign(this, init);
}
public constructor(init?: Partial<Device>) {
Object.assign(this, init);
}
}
export class ACL {
public groups: { [key: string]: [string] } = {};
export class Route {
advertisedRoutes: string[] = [];
enabledRoutes: string[] = [];
public constructor(init?: Partial<ACL>) {
Object.assign(this, init);
}
}
export class APIKey {
id: string = '';
prefix: string = '';
expiration: string = '';
createdAt: string = '';
lastSeen: string = '';
public constructor(init?: Partial<APIKey>) {
Object.assign(this, init);
}
public constructor(init?: Partial<Route>) {
Object.assign(this, init);
}
}
export class PreAuthKey {
public user: string = '';
public id: string = '';
public key: string = '';
public createdAt: string = '';
public expiration: string = '';
public reusable: boolean = false;
public ephemeral: boolean = false;
public used: boolean = false;
public namespace: string = '';
public id: string = '';
public key: string = '';
public createdAt: string = '';
public expiration: string = '';
public reusable: boolean = false;
public ephemeral: boolean = false;
public used: boolean = false;
public constructor(init?: Partial<PreAuthKey>) {
Object.assign(this, init);
}
public constructor(init?: Partial<PreAuthKey>) {
Object.assign(this, init);
}
}
export class User {
public id: string = '';
public name: string = '';
public email: string = '';
public createdAt: string = '';
public constructor(init?: Partial<User>) {
Object.assign(this, init);
}
}
public id: string = '';
public name: string = '';
public createdAt: string = '';
public constructor(init?: Partial<User>) {
Object.assign(this, init);
}
}

View file

@ -3,7 +3,6 @@
import { fade } from 'svelte/transition';
import { writable } from 'svelte/store';
import { base } from '$app/paths';
import { showACLPagesStore } from './stores';
// navigation bar variables
let navExpanded = writable('');
@ -31,7 +30,7 @@
<!-- let the page initialize before showing the nav bar -->
{#if componentLoaded}
<nav class="bg-base-200 flex shadow-xl w-14 h-screen sticky top-0" class:navCollapsed={$navExpanded == 'collapsed'} class:navExpanded={$navExpanded == 'expanded'} transition:fade|global>
<nav class="bg-base-200 flex shadow-xl w-14 h-screen relative" class:navCollapsed={$navExpanded == 'collapsed'} class:navExpanded={$navExpanded == 'expanded'} transition:fade>
<!-- links on top of sidebar -->
<div class="absolute top-0 w-full">
<button class="w-full nav-item" on:click={() => ($navExpanded == 'collapsed' ? ($navExpanded = 'expanded') : ($navExpanded = 'collapsed'))}>
@ -47,18 +46,6 @@
</svg>
<span class="indent-4">User View</span>
</a>
{#if $showACLPagesStore}
<a href="{base}/groups.html" class="nav-item">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
<span class="indent-4">Group View</span>
</a>
{/if}
<a href="{base}/devices.html" class="nav-item">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />

View file

@ -7,9 +7,10 @@
export function filterUsers() {
// only run if we have search contents set
if (get(userSearchStore)) {
let searcher = new Fuse(get(userStore), {
let options: Fuse.IFuseOptions<User> = {
keys: ['id', 'name']
});
};
let searcher = new Fuse(get(userStore), options);
// search using the searchstore term, and take the resultant array contents and set it to userFilterStore
userFilterStore.set(searcher.search(get(userSearchStore)).map((a) => a.item));
@ -22,9 +23,10 @@
export function filterDevices() {
// only run if we have search contents set
if (get(deviceSearchStore)) {
let searcher = new Fuse(get(deviceStore), {
keys: ['id', 'givenName', 'name', 'forcedTags', 'validTags', 'user.name']
});
let options: Fuse.IFuseOptions<Device> = {
keys: ['id', 'givenName', 'name', 'forcedTags', 'validTags', 'namespace.name']
};
let searcher = new Fuse(get(deviceStore), options);
// search using the searchstore term, and take the resultant array contents and set it to userFilterStore
deviceFilterStore.set(searcher.search(get(deviceSearchStore)).map((a) => a.item));

View file

@ -1,75 +0,0 @@
<script context="module" lang="ts">
import type { Device, User } from './classes';
export function sortUsers(users: User[]): User[] {
let sortKey = localStorage.getItem('headscaleUserSort') || '';
let sortDirection = localStorage.getItem('headscaleUserSortDirection') || '';
let sortedUsers = users;
let collator = new Intl.Collator([], { numeric: true });
if (sortDirection == 'ascending') {
switch (sortKey) {
case 'id':
sortedUsers = users.sort((a: User, b: User) => collator.compare(a.id, b.id));
break;
case 'createdAt':
sortedUsers = users.sort((a: User, b: User) => -collator.compare(a.createdAt, b.createdAt));
break;
case 'name':
sortedUsers = users.sort((a: User, b: User) => collator.compare(a.name, b.name));
break;
}
}
if (sortDirection == 'descending') {
switch (sortKey) {
case 'id':
sortedUsers = users.sort((a: User, b: User) => -collator.compare(a.id, b.id));
break;
case 'createdAt':
sortedUsers = users.sort((a: User, b: User) => collator.compare(a.createdAt, b.createdAt));
break;
case 'name':
sortedUsers = users.sort((a: User, b: User) => -collator.compare(a.name, b.name));
break;
}
}
return sortedUsers;
}
export function sortDevices(devices: Device[]): Device[] {
let sortKey = localStorage.getItem('headscaleDeviceSort') || '';
let sortDirection = localStorage.getItem('headscaleDeviceSortDirection') || '';
let sortedDevices = devices;
let collator = new Intl.Collator([], { numeric: true });
if (sortDirection == 'ascending') {
switch (sortKey) {
case 'id':
sortedDevices = devices.sort((a: Device, b: Device) => collator.compare(a.id, b.id));
break;
case 'lastSeen':
sortedDevices = devices.sort((a: Device, b: Device) => -collator.compare(a.lastSeen, b.lastSeen));
break;
case 'givenName':
sortedDevices = devices.sort((a: Device, b: Device) => collator.compare(a.givenName, b.givenName));
break;
}
}
if (sortDirection == 'descending') {
switch (sortKey) {
case 'id':
sortedDevices = devices.sort((a: Device, b: Device) => -collator.compare(a.id, b.id));
break;
case 'lastSeen':
sortedDevices = devices.sort((a: Device, b: Device) => collator.compare(a.lastSeen, b.lastSeen));
break;
case 'givenName':
sortedDevices = devices.sort((a: Device, b: Device) => -collator.compare(a.givenName, b.givenName));
break;
}
}
return sortedDevices;
}
</script>

View file

@ -1,41 +1,27 @@
import { writable } from 'svelte/store';
import { Device, User, ACL } from '$lib/common/classes';
//
// localStorage Stores (global scope, saves to the browser)
//
import { Device, User } from '$lib/common/classes';
// used to store the value of an alert across all components
export const alertStore = writable('');
// used to determine if the API is functioning
export const apiTestStore = writable('');
// stores the theme
export const themeStore = writable('');
// stores URL and API Key
export const URLStore = writable('');
export const APIKeyStore = writable('');
// stores sorting preferences
export const deviceSortStore = writable('id');
export const deviceSortDirectionStore = writable('ascending');
export const userSortStore = writable('id');
export const sortDirectionStore = writable('ascending');
// stores preauth key preference
export const preAuthHideStore = writable(false);
// Dev Setting Stores
// Shows or Hides ACL Settings
export const showACLPagesStore = writable(false);
//
// Normal Stores (global scope, saves until refresh)
//
// stores user and device data
export const userStore = writable([new User()]);
export const userFilterStore = writable([new User()]);
export const deviceStore = writable([new Device()]);
export const deviceFilterStore = writable([new Device()]);
// stores ACL object
export const aclStore = writable(new ACL());
// used to store the value of an alert across all components
export const alertStore = writable('');
// used to determine if the API is functioning
export const apiTestStore = writable('');
// stores search state
export const userSearchStore = writable('');
export const deviceSearchStore = writable('');
export const deviceSearchStore = writable('');
// stores sorting preferences
export const deviceSortStore = writable('id');
export const deviceSortDirectionStore = writable('ascending');
export const userSortStore = writable('id');
export const sortDirectionStore = writable('ascending');

View file

@ -1,16 +1,14 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { userStore } from '$lib/common/stores';
import { userStore, deviceStore } from '$lib/common/stores';
import { getDevices, newDevice } from '$lib/common/apiFunctions.svelte';
import { alertStore } from '$lib/common/stores.js';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { goto } from "$app/navigation";
// whether the new card html element is visible
export let newDeviceCardVisible = false;
export let newDeviceKey = '';
let newDeviceForm: HTMLFormElement;
let newDeviceKey = '';
let selectedUser = '';
let tabs = ['Default Configuration', 'With Preauth Keys', 'With OIDC'];
@ -22,15 +20,8 @@
.then((response) => {
newDeviceCardVisible = false;
newDeviceKey = '';
// refresh devices after editing
getDevices();
// Clear device key in url
if ($page.url.searchParams.get('nodekey')) {
$page.url.searchParams.delete('nodekey');
goto(`?${$page.url.searchParams.toString()}`);
}
})
.catch((error) => {
$alertStore = error;
@ -44,7 +35,7 @@
<!-- html -->
{#if newDeviceCardVisible == true}
<div in:fade|global out:fade|global={{ duration: newDeviceCardVisible ? 0 : 500 }} class="p-2 max-w-screen-lg border border-dashed border-base-content mx-4 rounded-md text-sm text-base-content shadow mb-10">
<div in:fade out:fade={{ duration: newDeviceCardVisible ? 0 : 500 }} class="p-2 max-w-screen-lg border border-dashed border-base-content mx-4 rounded-md text-sm text-base-content shadow mb-10">
<div class="tabs">
{#each tabs as tab, index}
<button class="tab tab-bordered h-fit w-1/3" class:tab-active={activeTab == index} on:click={() => (activeTab = index)}>{tab}</button>
@ -52,20 +43,20 @@
</div>
<!-- Default Configuration -->
{#if activeTab == 0}
<div in:fade|global class="m-2">
<p>Install Tailscale with the client pointing to your domain (see <a target="_blank" rel="noreferrer" class="link link-primary" href="https://github.com/juanfont/headscale/tree/main/docs">headscale client documentation</a>). Log in using the tray icon, and your browser should give you instructions with a key.</p>
<div class="m-2"><code>headscale -u USER nodes register --key &lt;your device key&gt;</code></div>
<div in:fade class="m-2">
<p>Install Tailscale with the client pointing to your domain (see <a target="_blank" class="link link-primary" href="https://github.com/juanfont/headscale/tree/main/docs">headscale client documentation</a>). Log in using the tray icon, and your browser should give you instructions with a key.</p>
<div class="m-2"><code>headscale -n NAMESPACE nodes register --key &lt;your device key&gt;</code></div>
<div class="my-2"><p>Copy the key below:</p></div>
<form class="flex flex-wrap" bind:this={newDeviceForm} on:submit|preventDefault={newDeviceAction}>
<div class="flex-none mr-4">
<label class="block text-secondary text-sm font-bold mb-2" for="text">Device Key</label>
<input bind:value={newDeviceKey} class="card-input" type="text" required placeholder="******************" />
<input bind:value={newDeviceKey} minlength="54" class="card-input" type="text" required placeholder="******************" />
</div>
<div class="flex-none">
<label class="block text-secondary text-sm font-bold mb-2" for="select">Select User</label>
<select class="card-select mr-3" required bind:value={selectedUser}>
{#each $userStore as user}
<option>{user.name.length > 1 ? user.name : user.email}</option>
<option>{user.name}</option>
{/each}
</select>
</div>
@ -91,9 +82,9 @@
{/if}
<!-- With Preauth Keys -->
{#if activeTab == 1}
<div in:fade|global class="m-2">
<p>Preauth Keys provide the capability to install tailscale using a pre-registered key (see the <code class="bg-base-200 px-2 rounded">--authkey</code> flag in the <a target="_blank" rel="noreferrer" class="link link-primary" href="https://tailscale.com/kb/1080/cli/">tailscale command line documentation</a>)</p>
<p>Preauth Keys are especially useful for deploying headscale as an always-on VPN (see the <code class="bg-base-200 px-2 rounded">TS_UNATTENDEDMODE</code> install option in the <a target="_blank" rel="noreferrer" class="link link-primary" href="https://tailscale.com/kb/1189/install-windows-msi/">tailscale documentation</a>) or router-level VPN.</p>
<div in:fade class="m-2">
<p>Preauth Keys provide the capability to install tailscale using a pre-registered key (see the <code class="bg-base-200 px-2 rounded">--authkey</code> flag in the <a target="_blank" class="link link-primary" href="https://tailscale.com/kb/1080/cli/">tailscale command line documentation</a>)</p>
<p>Preauth Keys are especially useful for deploying headscale as an always-on VPN (see the <code class="bg-base-200 px-2 rounded">TS_UNATTENDEDMODE</code> install option in the <a target="_blank" class="link link-primary" href="https://tailscale.com/kb/1189/install-windows-msi/">tailscale documentation</a>) or router-level VPN.</p>
<div class="bg-base-200 p-4 m-2 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@ -104,10 +95,10 @@
{/if}
<!-- With OIDC -->
{#if activeTab == 2}
<div in:fade|global class="m-2">
<p>OIDC provides the ability to register an external authentication provider (such as <a target="_blank" rel="noreferrer" class="link link-primary" href="https://www.keycloak.org/">keycloak</a>) to authenticate devices to headscale.</p>
<div in:fade class="m-2">
<p>OIDC provides the ability to register an external authentication provider (such as <a target="_blank" class="link link-primary" href="https://www.keycloak.org/">keycloak</a>) to authenticate devices to headscale.</p>
<br />
<p>Configure Headscale to register with an authentication provider (see <a target="_blank" rel="noreferrer" class="link link-primary" href="https://github.com/juanfont/headscale/blob/main/config-example.yaml">headscale configuration documentation</a>). Once configured, successfully authenticated devices will automatically self-register</p>
<p>Configure Headscale to register with an authentication provider (see <a target="_blank" class="link link-primary" href="https://github.com/juanfont/headscale/blob/main/config-example.yaml">headscale configuration documentation</a>). Once configured, successfully authenticated devices will automatically self-register</p>
</div>
{/if}
</div>

View file

@ -10,78 +10,13 @@
export let device = new Device();
let cardExpanded = false;
let cardEditing = false;
// returns button colour based on time difference
function timeDifference(date: Date) {
let currentTime = new Date();
let timeDifference = Math.round((currentTime.getTime() - date.getTime()) / 1000);
if (timeDifference < 3600) {
return 'bg-success';
} else if (timeDifference < 86400) {
return 'bg-warning';
}
return 'bg-error';
}
// return button colour based on online status
function onlineBackground(online: boolean) {
return online ? 'bg-success' : 'bg-error';
}
function getBadgeColour(date: Date, online?: boolean) {
if (online !== undefined) {
return onlineBackground(online);
}
return timeDifference(date);
}
// returns time last seen in human readable format
function timeSince(date: Date) {
let currentTime = new Date();
// gets time difference in seconds
let timeDifference = Math.round((currentTime.getTime() - date.getTime()) / 1000);
let timeUnit = '';
if (timeDifference < 60) {
timeUnit = 'seconds';
} else if (timeDifference < 3600) {
timeDifference = Math.floor(timeDifference / 60);
if (timeDifference == 1) {
timeUnit = 'minute';
} else {
timeUnit = 'minutes';
}
} else if (timeDifference < 86400) {
timeDifference = Math.floor(timeDifference / (60 * 60));
if (timeDifference == 1) {
timeUnit = 'hour';
} else {
timeUnit = 'hours';
}
} else {
timeDifference = Math.floor(timeDifference / (60 * 60 * 24));
if (timeDifference == 1) {
timeUnit = 'day';
} else {
timeUnit = 'days';
}
}
return `Last seen ${timeDifference} ${timeUnit} ago`;
}
</script>
<div class="card-primary bg-base-200">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:keypress on:click={() => (cardExpanded = !cardExpanded)} class="flex items-center">
<div class="card-primary">
<div on:click={() => (cardExpanded = !cardExpanded)} class="flex">
<span class="min-w-64 w-1/2 font-bold">
{#if cardEditing == false}
{#if device.online}
<span class="badge badge-xs tooltip {getBadgeColour(new Date(device.lastSeen), device.online)}" data-tip=online /> {device.id}: {device.givenName}
{:else}
<span class="badge badge-xs tooltip {getBadgeColour(new Date(device.lastSeen), device.online)}" data-tip={timeSince(new Date(device.lastSeen))} /> {device.id}: {device.givenName}
{/if}
{device.id}: {device.givenName}
{/if}
<RenameDevice bind:cardEditing {device} />
</span>
@ -105,7 +40,7 @@
</div>
{#if cardExpanded}
<!-- we put a conditional on the outro transition so page changes do not trigger the animation -->
<div in:slide|global out:slide|global={{ duration: cardExpanded ? 0 : 500 }} class="mt-2 pt-2 pl-2">
<div in:slide out:slide={{ duration: cardExpanded ? 0 : 500 }} class="pt-2 pl-2">
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<tbody>

View file

@ -1,18 +1,48 @@
<script lang="ts">
import { Device } from '$lib/common/classes';
import DeviceRoute from './DeviceRoutes/DeviceRoute.svelte';
import { getDeviceRoutes, enableDeviceRoute } from '$lib/common/apiFunctions.svelte';
import { Device, Route } from '$lib/common/classes';
import { onMount } from 'svelte';
import { alertStore } from '$lib/common/stores';
export let device = new Device();
let routesList = new Route();
onMount(async () => {
getDeviceRoutesAction();
});
function getDeviceRoutesAction() {
getDeviceRoutes(device.id)
.then((routes) => {
routesList = routes;
})
.catch((error) => {
$alertStore = error;
});
}
function enableDeviceRouteAction(route: string) {
enableDeviceRoute(device.id, route)
.then((response) => {
getDeviceRoutesAction();
})
.catch((error) => {
$alertStore = error;
});
}
</script>
<th>Device Routes</th>
<td
><ul class="list-disc list-inside">
{#each device.availableRoutes as route}
{#each routesList.advertisedRoutes as route}
<li>
<DeviceRoute {route} {device}></DeviceRoute>
{route}
{#if routesList.enabledRoutes.includes(route)}
<div class="btn btn-xs capitalize bg-success text-success-content mx-1">active</div>
{:else}
<button on:click={() => {enableDeviceRouteAction(route)}} type="button" class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1" data-tip="press to enable route">pending</button>
{/if}
</li>
{/each}
</ul></td

View file

@ -1,60 +0,0 @@
<script>
import { getDevices } from '$lib/common/apiFunctions.svelte';
import { Device } from '$lib/common/classes';
import { alertStore } from '$lib/common/stores';
import { approveDeviceRoute } from './DeviceRouteAPI.svelte';
export let route = '';
export let device = new Device();
let routeDisabled = false;
function approveRouteAction() {
approveDeviceRoute(device.id, [...device.approvedRoutes, route])
.then(() => {
// refresh users after editing
getDevices();
})
.catch((error) => {
$alertStore = error;
});
}
function removeRouteAction() {
approveDeviceRoute(device.id, device.approvedRoutes.filter((r) => r !== route))
.then(() => {
// refresh users after editing
getDevices();
})
.catch((error) => {
$alertStore = error;
});
}
</script>
{route}
{#if device.approvedRoutes.includes(route)}
<button
on:click={() => {
routeDisabled = true;
removeRouteAction();
routeDisabled = false;
}}
type="button"
class="btn btn-xs tooltip capitalize bg-success text-success-content mx-1">active</button
>
{:else}
<button
on:click={() => {
routeDisabled = true;
approveRouteAction();
routeDisabled = false;
}}
type="button"
class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1"
class:disabled={routeDisabled}
data-tip="click to enable route">pending</button
>
{/if}
{#if device.subnetRoutes.includes(route)}
<button type="button" class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1">subnet</button>
{/if}

View file

@ -1,34 +0,0 @@
<script context="module" lang="ts">
export async function approveDeviceRoute(deviceID: string, routes: string[]): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
let endpointURL = `/api/v1/node/${deviceID}/approve_routes`;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
routes: routes
})
})
.then((response) => {
if (response.ok) {
// return the api data
return response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
}
</script>

View file

@ -20,19 +20,21 @@
}
</script>
<div class="flex gap-1">
<span><NewDeviceTag {device}/></span>
<span><NewDeviceTag {device}/></span>
{#each device.forcedTags as tag}
<span class="btn btn-xs btn-primary normal-case">{tag.replace("tag:","")}
<!-- Cancel symbol -->
<button on:click|stopPropagation={() => {updateTagsAction(tag)}}
class="ml-1"
><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
</span>
{/each}
</div>
{#each device.forcedTags as tag}
<span class="mb-1 mr-1 btn btn-xs btn-primary normal-case">{tag.replace("tag:","")}
<!-- Cancel symbol -->
<button on:click|stopPropagation={() => {updateTagsAction(tag)}}
class="ml-1"
><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
</span>
{/each}
{#each device.validTags as tag}
<span class="mb-1 mr-1 btn btn-xs btn-secondary normal-case">{tag.replace("tag:","")}</span>
{/each}

View file

@ -13,8 +13,6 @@
tagList.push(`tag:${newTag}`);
// remove duplicates
tagList = [...new Set(tagList)];
// force lowercase
tagList = tagList.map(str => str.toLowerCase());
updateTags(device.id, tagList)
.then((response) => {
@ -41,7 +39,7 @@
<!-- svelte-ignore a11y-autofocus -->
<form on:submit|preventDefault={updateTagsAction}>
<input bind:value={newTag} autofocus required class="bg-primary w-16" />
<button in:fade|global class="ml-1">
<button in:fade class="ml-1">
<!-- checkmark symbol -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
@ -50,7 +48,7 @@
<!-- Delete cancel symbol -->
<button
type="button"
in:fade|global
in:fade
on:click|stopPropagation={() => {
editingTag = false;
newTag = '';

View file

@ -6,7 +6,7 @@
export let device = new Device();
let deviceMoving = false;
let selectedUser = device.user.id;
let selectedUser = device.namespace.name;
function moveDeviceAction() {
moveDevice(device.id, selectedUser)
@ -23,7 +23,7 @@
<td>
{#if !deviceMoving}
{device.user.name}
{device.namespace.name}
<!-- edit symbol -->
<button
on:click={() => {
@ -39,17 +39,17 @@
<form on:submit|preventDefault={moveDeviceAction}>
<select class="card-select mr-3" required bind:value={selectedUser}>
{#each $userStore as user}
<option value={user.id}>{user.name.length > 1 ? user.name : user.email}</option>
<option>{user.name}</option>
{/each}
</select>
<!-- edit accept symbol -->
<button in:fade|global class=""
<button in:fade class=""
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<!-- edit cancel symbol -->
<button type="button" in:fade|global on:click|stopPropagation={() => (deviceMoving = false)}
<button type="button" in:fade on:click|stopPropagation={() => (deviceMoving = false)}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -29,16 +29,16 @@
>
{:else}
<!-- Delete Warning -->
<span in:fade|global class="font-bold text-red-400">Deleting {device.name}. Confirm </span>
<span in:fade class="font-bold text-red-400">Deleting {device.name}. Confirm </span>
<!-- Delete confirm symbol -->
<button in:fade|global on:click|stopPropagation={() => removeDeviceAction()}
<button in:fade on:click|stopPropagation={() => removeDeviceAction()}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<span in:fade|global class="font-bold text-red-400">or Cancel </span>
<span in:fade class="font-bold text-red-400">or Cancel </span>
<!-- Delete cancel symbol -->
<button in:fade|global on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
<button in:fade on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -41,15 +41,15 @@
{:else}
<form bind:this={editUserForm} on:submit|preventDefault={renameDeviceAction}>
<!-- Input has to be lower case, but we will force lower case on submit -->
<input in:slide|global on:click|stopPropagation bind:value={newDeviceName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z0-9\-\.]+" placeholder="name" />
<input in:slide on:click|stopPropagation bind:value={newDeviceName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z1-9\-\.]+" placeholder="name" />
<!-- edit accept symbol -->
<button in:fade|global on:click|stopPropagation class=""
<button in:fade on:click|stopPropagation class=""
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<!-- edit cancel symbol -->
<button type="button" in:fade|global on:click|stopPropagation={() => (cardEditing = false)}
<button type="button" in:fade on:click|stopPropagation={() => (cardEditing = false)}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -14,7 +14,7 @@
<span class="flex">
<button
on:keypress on:click={() => {
on:click={() => {
sortAction();
}}
class="mx-1"

View file

@ -1,13 +0,0 @@
<script lang="ts">
import { showACLPagesStore } from '$lib/common/stores';
import { fade } from 'svelte/transition';
let showDevSettings = false;
</script>
<div class="inline-block"><h1 class="text-2xl bold text-primary mb-4">Developer Flags<input type="checkbox" class="toggle toggle-sm tooltip ml-2 align-middle" data-tip="To enable development features. Only check this if you're a developer or like being confused" bind:checked={showDevSettings} /></h1></div>
{#if showDevSettings}
<div in:fade|global>
<h2 class="text-xl bold text-secondary mb-2 ml-2">ACL Pages <input bind:checked={$showACLPagesStore} type="checkbox" class="toggle toggle-sm ml-2 align-middle" /></h2>
</div>
{/if}

View file

@ -2,17 +2,16 @@
import { fly } from 'svelte/transition';
import { URLStore } from '$lib/common/stores.js';
import { APIKeyStore } from '$lib/common/stores.js';
import { getAPIKeys } from '$lib/common/apiFunctions.svelte';
import { onMount } from 'svelte';
import ApiKeyTimeLeft from './ServerSettings/APIKeyTimeLeft.svelte';
import RolloverApi from './ServerSettings/RolloverAPI.svelte';
import { getUsers } from '$lib/common/apiFunctions.svelte';
// Server Settings
let headscaleURL = $URLStore;
let headscaleAPIKey = $APIKeyStore;
let serverSettingsForm: HTMLFormElement;
let apiStatus = 'untested';
let apiKeyInputState = 'password';
function TestServerSettings() {
getAPIKeys()
getUsers()
.then(() => {
apiStatus = 'succeeded';
})
@ -21,70 +20,42 @@
});
}
function ClearServerSettings() {
$URLStore = '';
$APIKeyStore = '';
apiStatus = 'untested';
function SaveServerSettings(): void {
if (serverSettingsForm.reportValidity()) {
$URLStore = headscaleURL;
$APIKeyStore = headscaleAPIKey;
}
}
onMount(() => {
// test api settings on page load
TestServerSettings();
});
function ClearServerSettings() {
headscaleURL = '';
headscaleAPIKey = '';
$URLStore = headscaleURL;
$APIKeyStore = headscaleAPIKey;
}
</script>
<form>
<form bind:this={serverSettingsForm}>
<h1 class="text-2xl bold text-primary mb-4">Server Settings</h1>
<label class="block text-secondary text-sm font-bold mb-2" for="url"> Headscale URL </label>
<input bind:value={$URLStore} class="form-input" type="url" pattern={String.raw`https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)`} placeholder="https://hs.yourdomain.com.au" />
<input bind:value={headscaleURL} class="form-input" type="url" required pattern={String.raw`https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)`} placeholder="https://hs.yourdomain.com.au" />
<p class="text-xs text-base-content text-italics mb-8">URL for your headscale server instance</p>
<label class="block text-secondary text-sm font-bold mb-2" for="password">
Headscale API Key
{#if apiStatus == 'succeeded'}
{#key $APIKeyStore}
<ApiKeyTimeLeft />
{/key}
{/if}
</label>
<div class="flex relative">
<input bind:value={$APIKeyStore} {...{ type: apiKeyInputState }} class="form-input" disabled='{apiStatus == 'succeeded'}' required placeholder="******************" />
<button
type="button"
class="absolute right-40"
on:click={() => {
apiKeyInputState == 'text' ? (apiKeyInputState = 'password') : (apiKeyInputState = 'text');
}}
>
{#if apiKeyInputState == 'password'}
<!-- eye off -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 my-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
><path stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg
>
{:else}
<!-- eye on -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 my-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{/if}
</button>
<RolloverApi {apiStatus} />
</div>
<label class="block text-secondary text-sm font-bold mb-2" for="password"> Headscale API Key </label>
<input bind:value={headscaleAPIKey} minlength="54" maxlength="54" class="form-input" type="password" required placeholder="******************" />
<p class="text-xs text-base-content text-italics mb-8">Generate an API key for your headscale instance and place it here.</p>
{#if apiStatus != 'succeeded'}
<button on:click={() => {TestServerSettings()}} class="btn btn-sm btn-secondary capitalize" type="button">Save API Key</button>
{:else}
<button on:click={() => {apiStatus = 'untested'}} class="btn btn-sm btn-primary capitalize" type="button">Edit API Key</button>
{/if}
<!-- disable the SaveServerSettings button if nothing has changed from stored values, or the dependent inputs do not validate -->
<div class="tooltip z-10" data-tip="Note: API Key and URL currently save to localStorage (IE: Your Browser) Make sure you are using a trusted computer">
<button disabled={headscaleAPIKey === $APIKeyStore && headscaleURL === $URLStore} on:click={() => SaveServerSettings()} class="btn btn-sm btn-accent capitalize" type="button">Save Server Settings</button>
</div>
<button on:click={() => ClearServerSettings()} class="btn btn-sm btn-primary capitalize" type="button">Clear Server Settings</button>
<button on:click={() => TestServerSettings()} class="btn btn-sm btn-secondary capitalize" type="button">Test Server Settings</button>
{#if apiStatus === 'succeeded'}
<svg in:fly|global={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="green" stroke-width="2">
<svg in:fly={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="green" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{/if}
{#if apiStatus === 'failed'}
<svg in:fly|global={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="red" stroke-width="2">
<svg in:fly={{ x: 10, duration: 600 }} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24" stroke="red" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{/if}

View file

@ -1,46 +0,0 @@
<script lang="ts">
import { getAPIKeys } from '$lib/common/apiFunctions.svelte';
import { APIKey } from '$lib/common/classes';
import { alertStore, APIKeyStore } from '$lib/common/stores';
import { onMount } from 'svelte';
let keyList = [new APIKey()];
let timeLeftWarning = false;
let timeLeftTip = '';
function getAPIKeysAction(): void {
getAPIKeys()
.then((keys) => {
keyList = keys;
// match up the current apikey to the keylist
keyList.forEach(key => {
if($APIKeyStore.includes(key.prefix)) {
timeLeft(new Date(key.expiration));
}
});
})
.catch((error) => {
$alertStore = error;
});
}
// sets time expiry in human readable format
function timeLeft(date: Date): void {
let currentTime = new Date();
// gets time difference in seconds
let timeDifferenceDays = Math.round((date.getTime() - currentTime.getTime()) / 1000 / 60 / 60 / 24);
if(timeDifferenceDays < 30) {
$alertStore = `${timeDifferenceDays} days left before API Key expiry, consider rolling your key`
timeLeftWarning = true;
}
timeLeftTip = `${timeDifferenceDays} days left before expiry`;
}
onMount(() => {
getAPIKeysAction();
});
</script>
<button type="button" class="tooltip" data-tip={timeLeftTip}>
<!-- clock -->
<svg xmlns="http://www.w3.org/2000/svg" class:stroke-error="{timeLeftWarning}" class="h-5 w-5 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</button>

View file

@ -1,51 +0,0 @@
<script lang="ts">
import { expireAPIKey, getAPIKeys, newAPIKey } from '$lib/common/apiFunctions.svelte';
import { APIKey } from '$lib/common/classes';
import { alertStore, APIKeyStore } from '$lib/common/stores';
let keyList = [new APIKey()];
let currentKey = new APIKey();
export let apiStatus = '';
// get current API keys
// Match to current key
function getAPIKeysAction(): void {
getAPIKeys()
.then((keys) => {
keyList = keys;
// match up the current apikey to the keylist
keyList.forEach((key) => {
if ($APIKeyStore.includes(key.prefix)) {
currentKey = key;
// create the new key
newAPIKeyAction()
.then((data) => {
$APIKeyStore = data;
// expire the old key
expireAPIKey(currentKey.prefix);
})
.catch((error) => {
$alertStore = error;
});
}
});
})
.catch((error) => {
$alertStore = error;
});
}
// create new API key
function newAPIKeyAction() {
let event = new Date();
event.setDate(event.getDate() + 90);
return newAPIKey(event.toISOString());
}
</script>
<button
on:click={() => {
getAPIKeysAction();
}}
class="btn btn-sm btn-secondary capitalize ml-4"
type="button" disabled="{apiStatus != 'succeeded'}">Rollover API Key</button
>

View file

@ -30,10 +30,10 @@
<!-- html -->
{#if newUserCardVisible}
<div in:fade|global out:fade|global={{ duration: newUserCardVisible ? 0 : 500 }} class="card-pending">
<div in:fade out:fade={{ duration: newUserCardVisible ? 0 : 500 }} class="card-pending">
<form on:submit|preventDefault={newUserAction} class="relative" bind:this={newUserForm}>
<!-- Input has to be lower case, but we will force lower case on submit -->
<input bind:value={newUserName} class="card-input lowercase" required pattern="[a-zA-Z0-9\-\.]+" placeholder="name" />
<input bind:value={newUserName} class="card-input lowercase" required pattern="[a-zA-Z\-\.]+" placeholder="name" />
</form>
<div>
<button on:click={() => newUserAction()}

View file

@ -11,9 +11,8 @@
let cardExpanded = false;
</script>
<div in:fade|global class="card-primary bg-base-200">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:keypress on:click={() => (cardExpanded = !cardExpanded)} class="flex justify-between">
<div in:fade class="card-primary">
<div on:click={() => (cardExpanded = !cardExpanded)} class="flex justify-between">
<div>
<EditUser {user} />
</div>
@ -35,14 +34,10 @@
</div>
{#if cardExpanded}
<!-- we put a conditional on the outro transition so page changes do not trigger the animation -->
<div in:slide|global out:slide|global={{ duration: cardExpanded ? 0 : 500 }} class="mt-2 pt-2 pl-2">
<div in:slide out:slide={{ duration: cardExpanded ? 0 : 500 }} class="pt-2 pl-2">
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<tbody>
<tr>
<th>Email</th>
<td>{user.email}</td>
</tr>
<tr>
<th>User Creation Date</th>
<td>{new Date(user.createdAt)}</td>

View file

@ -22,7 +22,7 @@
}
function getPreauthKeysAction() {
getPreauthKeys(user.id)
getPreauthKeys(user.name)
.then((keys) => {
keyList = keys;
})
@ -40,7 +40,7 @@
<th>
<div>Preauth Keys
<button
on:keypress on:click={() => {
on:click={() => {
newPreAuthKeyShow = !newPreAuthKeyShow;
}}
>
@ -62,10 +62,8 @@
type="checkbox"
bind:checked={($preAuthHideStore)}
class="checkbox checkbox-xs text-base-content"
/>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
on:keypress on:click={() => {
/><span
on:click={() => {
$preAuthHideStore = !$preAuthHideStore
}}
class="font-normal ml-2">Hide Expired/Used Keys</span
@ -107,7 +105,7 @@
<!-- Allow ability to expire if not expired -->
{#if new Date(key.expiration).getTime() > new Date().getTime() && (!key.used || key.reusable)}
<!-- trash symbol -->
<button class="mr-2" on:click={() => {expirePreAuthKeyAction(user.id, key.key)}}
<button class="mr-2" on:click={() => {expirePreAuthKeyAction(user.name, key.key)}}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg></button

View file

@ -15,7 +15,7 @@
function NewPreAuthKeyAction() {
let formattedDate = new Date(expiry).toISOString();
newPreAuthKey(user.id, formattedDate, reusable, ephemeral)
newPreAuthKey(user.name, formattedDate, reusable, ephemeral)
.then(() => {
newPreAuthKeyShow = false;
getPreauthKeysAction();
@ -26,7 +26,7 @@
}
function getPreauthKeysAction() {
getPreauthKeys(user.id)
getPreauthKeys(user.name)
.then((keys) => {
keyList = keys;
})
@ -36,7 +36,7 @@
}
</script>
<div in:fade|global class="card-pending">
<div in:fade class="card-pending">
<form on:submit|preventDefault={NewPreAuthKeyAction}>
<table class="table table-compact w-full">
<tbody>

View file

@ -8,7 +8,7 @@
let cardDeleting = false;
function removeUserAction() {
removeUser(user.id)
removeUser(user.name)
.then((response) => {
cardDeleting = false;
// refresh users after editing
@ -29,16 +29,16 @@
>
{:else}
<!-- Delete Warning -->
<span in:fade|global class="font-bold text-red-400">Deleting {user.name}. Confirm </span>
<span in:fade class="font-bold text-red-400">Deleting {user.name}. Confirm </span>
<!-- Delete confirm symbol -->
<button in:fade|global on:click|stopPropagation={() => removeUserAction()}
<button in:fade on:click|stopPropagation={() => removeUserAction()}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<span in:fade|global class="font-bold text-red-400">or Cancel </span>
<span in:fade class="font-bold text-red-400">or Cancel </span>
<!-- Delete cancel symbol -->
<button in:fade|global on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
<button in:fade on:click|stopPropagation={() => (cardDeleting = false)} class="mr-4"
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -16,7 +16,7 @@
function renameUserAction() {
if (editUserForm.reportValidity()) {
editUser(user.id, newUserName)
editUser(user.name, newUserName)
.then((response) => {
cardEditing = false;
// refresh users after editing
@ -32,7 +32,7 @@
</script>
{#if !cardEditing}
<span class="font-bold">{user.id}: {user.name.length > 1 ? user.name : user.email}</span>
<span class="font-bold">{user.id}: {user.name}</span>
<!-- edit symbol -->
<button type="button" on:click|stopPropagation={() => editingUser()} class="ml-2"
><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@ -42,15 +42,15 @@
{:else}
<form bind:this={editUserForm} on:submit|preventDefault={renameUserAction}>
<!-- Input has to be lower case, but we will force lower case on submit -->
<input in:slide|global on:click|stopPropagation bind:value={newUserName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z0-9\-\.]+" placeholder="name" />
<input in:slide on:click|stopPropagation bind:value={newUserName} class="card-input mb-1 lowercase" required pattern="[a-zA-Z\-\.]+" placeholder="name" />
<!-- edit accept symbol -->
<button in:fade|global on:click|stopPropagation class=""
<button in:fade on:click|stopPropagation class=""
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button
>
<!-- edit cancel symbol -->
<button type="button" in:fade|global on:click|stopPropagation={() => (cardEditing = false)}
<button type="button" in:fade on:click|stopPropagation={() => (cardEditing = false)}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg></button

View file

@ -1 +0,0 @@
export const prerender = true;

View file

@ -12,7 +12,7 @@
<main data-theme={$themeStore} class="flex flex-col">
<!-- initialize localStorage -->
<Stores></Stores>
<div class="flex">
<div class="flex h-screen">
<!-- sidebar -->
<Nav />
<!-- main window -->

View file

@ -1,7 +1,6 @@
<!-- typescript -->
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { getDevices, getUsers } from '$lib/common/apiFunctions.svelte';
import { apiTestStore, deviceFilterStore, deviceStore } from '$lib/common/stores.js';
import CreateDevice from '$lib/devices/CreateDevice.svelte';
@ -11,8 +10,6 @@
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
let newDeviceKey = '';
let newDeviceCardVisible = false;
//
@ -25,11 +22,6 @@
// We define the meat of our script in onMount as doing so forces client side rendering.
// Doing so also does not perform any actions until components are initialized
onMount(async () => {
// Handle nodekey
newDeviceKey = $page.url.searchParams.get('nodekey') ?? ''
newDeviceCardVisible = newDeviceKey.length > 0 ? true : false
// update user list
getUsers();
// attempt to pull list of devices
@ -41,8 +33,8 @@
<!-- html -->
{#if componentLoaded}
<div in:fade|global>
<div in:fade|global class="px-4 pt-4">
<div in:fade>
<div in:fade class="px-4 pt-4">
<h1 class="text-2xl bold text-primary">Device View</h1>
</div>
{#if $apiTestStore === 'succeeded'}
@ -62,18 +54,16 @@
>
</table>
<CreateDevice bind:newDeviceCardVisible bind:newDeviceKey />
<CreateDevice bind:newDeviceCardVisible />
<div class="flex flex-col gap-2">
{#each $deviceStore as device}
{#if $deviceFilterStore.includes(device)}
<DeviceCard {device} />
{/if}
{/each}
</div>
{#each $deviceStore as device}
{#if $deviceFilterStore.includes(device)}
<DeviceCard {device} />
{/if}
{/each}
{/if}
{#if $apiTestStore === 'failed'}
<div in:fade|global class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<div in:fade class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<p>API test did not succeed.<br />Headscale might be down or API settings may need to be set<br />change server settings in the <a href="{base}/settings.html" class="link link-primary">settings</a> page</p>
</div>
{/if}

View file

@ -1,23 +0,0 @@
<script lang="ts">
//
// Imports
//
import { showACLPagesStore } from '$lib/common/stores';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
// Set to true once component is initialized
let componentLoaded = false;
onMount(async () => {
componentLoaded = true;
});
</script>
<body>
{#if showACLPagesStore}
<div hidden={!componentLoaded} in:fade|global class="px-4 py-4 w-4/5 max-w-screen-lg">
<h1 class="text-2xl bold text-primary">Group View</h1>
</div>
{/if}
</body>

View file

@ -2,7 +2,6 @@
//
// Imports
//
import DevSettings from '$lib/settings/DevSettings.svelte';
import ServerSettings from '$lib/settings/ServerSettings.svelte';
import ThemeSettings from '$lib/settings/ThemeSettings.svelte';
import { onMount } from 'svelte';
@ -13,21 +12,19 @@
onMount(async () => {
// Display component frontend
await new Promise(r => setTimeout(r, 200));
componentLoaded = true;
});
</script>
<!-- html -->
<body>
<div hidden={!componentLoaded} in:fade|global class="px-4 py-4 w-4/5 max-w-screen-lg">
<ServerSettings />
<div class="p-4" />
<ThemeSettings />
<div class="p-4" />
<h1 class="text-2xl bold text-primary mb-4">Version</h1>
<b>insert-version</b>
<div class ="p-4"></div>
<DevSettings></DevSettings>
</div>
{#if componentLoaded}
<div in:fade class="px-4 py-4 w-4/5">
<ServerSettings />
<div class="p-4"></div>
<ThemeSettings />
<div class="p-4"></div>
<h1 class="text-2xl bold text-primary mb-4">Version</h1><b>insert-version</b>
</div>
{/if}
</body>

View file

@ -31,7 +31,7 @@
<!-- html -->
{#if componentLoaded}
<div in:fade|global>
<div in:fade>
<div class="px-4 pt-4">
<h1 class="text-2xl bold text-primary">User View</h1>
</div>
@ -52,17 +52,14 @@
>
</table>
<CreateUser bind:newUserCardVisible />
<div class="flex flex-col gap-2">
{#each $userStore as user}
{#if $userFilterStore.includes(user)}
<UserCard {user} />
{/if}
{/each}
</div>
{#each $userStore as user}
{#if $userFilterStore.includes(user)}
<UserCard {user} />
{/if}
{/each}
{/if}
{#if $apiTestStore === 'failed'}
<div in:fade|global class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<div in:fade class="max-w-lg mx-auto p-4 border-4 text-sm text-base-content shadow-lg text-center">
<p>API test did not succeed.<br />Headscale might be down or API settings may need to be set<br />change server settings in the <a href="{base}/settings.html" class="link link-primary">settings</a> page</p>
</div>
{/if}

View file

@ -4,6 +4,9 @@ import preprocess from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
prerender: {
default: true
},
adapter: adapter({
fallback: 'index.html',
precompress: false

View file

@ -1,10 +1,11 @@
// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import basicSsl from '@vitejs/plugin-basic-ssl'
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit()],
plugins: [sveltekit(), basicSsl()],
};
export default config;