Compare commits

..

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

58 changed files with 4245 additions and 2768 deletions

View file

@ -13,8 +13,5 @@ Provide the following:
* 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,85 @@ 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
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
echo "::set-output name=NOT_PREVIOUSLY_PUBLISHED::$NOT_PREVIOUSLY_PUBLISHED"
- 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,6 +1,12 @@
:8080 {
{
skip_install_trust
}
:443 {
redir / /web
uri strip_prefix /web
tls internal {
on_demand
}
file_server {
root ./build
}

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,38 +13,42 @@ 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).
Headscale UI serves on port 443 and uses a self signed cert by default.
### 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` |
| HTTP_PORT | Sets the HTTP port to an alternate value | `80` |
| HTTPS_PORT | Sets the HTTPS port to an alternate value | `443` |
### 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
}
@ -87,17 +88,6 @@ https://hs.yourdomain.com.au {
### 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:
@ -110,12 +100,9 @@ Note that while mobile is checked for functionality, the web experience is not m
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`.
Your API key is either not saved. 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
Alternatively, you haven't fixed your domain. HS-UI *has* to be ran on the same subdomain or you need to configure CORS. Yes you need to use a reverse proxy to do this. Use a reverse proxy.
## Development
see [development](/documentation/development.md) for details

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,10 +1,10 @@
FROM node:lts AS build
# arguments
ARG VERSION="master"
# Branch to check out
ARG CHECKOUT_BRANCH="master"
FROM node:current-alpine AS build
#environment variables
ENV PROJECT_NAME="headscale-ui"
# URL for the github/git location
@ -37,8 +37,8 @@ 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"
ENV HTTP_PORT="80"
ENV HTTPS_PORT="443"
# Production Web Server port. Runs a self signed SSL certificate
EXPOSE 443

View file

@ -2,8 +2,9 @@
set -x
# add dependencies
# jq for parsing version information
# git for cloning the repository
apk add --no-cache git
apk add --no-cache jq git
#clone the project
git clone ${PROJECT_URL} ${PROJECT_NAME}
@ -14,6 +15,7 @@ git checkout ${CHECKOUT_BRANCH}
npm install
# inject the version number
VERSION=$(jq -r '.version' package.json)
sed -i "s/insert-version/${VERSION}/g" ./src/routes/settings.html/+page.svelte
# build the project

View file

@ -0,0 +1,25 @@
{
http_port 80
https_port 443
}
https://headscale-test.local {
tls internal
reverse_proxy /web* https://headscale-test-frontend {
transport http {
tls_insecure_skip_verify
}
}
reverse_proxy * http://headscale-test-backend:8080
}
:80 {
reverse_proxy /web* https://headscale-test-frontend {
transport http {
tls_insecure_skip_verify
}
}
reverse_proxy * http://headscale-test-backend:8080
}

View file

@ -0,0 +1,42 @@
services:
headscale-test-backend:
image: headscale/headscale:latest-alpine
container_name: headscale-test-backend
security_opt:
- label:disable
# volumes:
# - ./container-config:/etc/headscale
# - ./container-data/data:/var/lib/headscale
entrypoint: |
sh -c "mkdir -p /var/lib/headscale;
mkdir -p /etc/headscale;
touch /var/lib/headscale/db.sqlite;
wget --output-document /etc/headscale/config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
sed -i 's|http://127.0.0.1:8080|https://headscale-test.local|g' /etc/headscale/config.yaml;
headscale serve"
restart: unless-stopped
networks:
headscale-ui-test-network:
headscale-test-frontend:
image: ghcr.io/gurucomputing/headscale-ui:latest
container_name: headscale-test-frontend
restart: unless-stopped
networks:
headscale-ui-test-network:
headscale-test-proxy:
image: headscale-test-proxy:latest
build: .
container_name: headscale-test-proxy
ports:
- 8080:80
restart: unless-stopped
networks:
headscale-ui-test-network:
aliases:
- headscale-test.local
networks:
headscale-ui-test-network:
external: true

View file

@ -0,0 +1,27 @@
FROM alpine:latest
# environment variables
ENV XDG_DATA_HOME=/data/
# Set the staging environment
WORKDIR /staging/scripts
WORKDIR /staging
# Copy across the scripts folder
COPY scripts/* ./scripts/
# Copy default caddy config from project root
COPY ./Caddyfile /staging/Caddyfile
# Set permissions for all scripts. We do not want normal users to have write
# access to the scripts
RUN chown -R 0:0 scripts
RUN chmod -R 755 scripts
# Build the image. This build runs as root
RUN /staging/scripts/1-image-build.sh
# Tell docker that all future commands should run as the appuser user
# USER appuser
WORKDIR /data
ENTRYPOINT /bin/sh /staging/scripts/2-initialise.sh

View file

@ -0,0 +1,25 @@
#!/bin/sh
set -x
# temporarily set the caddy home to staging
export XDG_DATA_HOME=/staging
# create the group and user
addgroup -S appgroup && adduser -D appuser -G appgroup
# install caddy plus dependencies
apk add --no-cache caddy nss-tools
# install tailscale
echo http://dl-2.alpinelinux.org/alpine/edge/community/ >> /etc/apk/repositories
apk add -U --no-cache tailscale
rc-update add tailscale
# do a dry run of caddy to install the certificates
caddy start
caddy trust -adapter caddyfile -config /staging/Caddyfile
caddy stop
# set the caddy directory to the non-root user
# commented out for now as we need root anyway for tailscale
# chown -R 1000:1000 /staging/caddy

View file

@ -0,0 +1,23 @@
#!/bin/sh
#----#
# placeholder for testing
# while true; do sleep 1; done
#----#
# copy everything from staging
if [ ! -f /data/Caddyfile ];
then
echo "no Caddyfile detected, copying across default config"
cp /staging/Caddyfile /data/Caddyfile
fi
if [ ! -f /data/caddy ];
then
echo "no caddy directory detected, copying across default config"
cp -r /staging/caddy /data/caddy
fi
# start caddy
echo "Starting Caddy"
/usr/sbin/caddy run --adapter caddyfile --config /data/Caddyfile

View file

@ -0,0 +1,38 @@
services:
headscale-worker-1:
image: headscale-test-proxy:latest
container_name: headscale-worker-1
restart: unless-stopped
networks:
headscale-ui-test-network:
entrypoint: |
sh -c "tailscaled --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055 &
tailscale up --authkey=$PREAUTH_KEY --login-server=https://headscale-test.local;
/etc/init.d/tailscale start
while true; do sleep 1; done"
headscale-worker-2:
image: headscale-test-proxy:latest
container_name: headscale-worker-2
restart: unless-stopped
networks:
headscale-ui-test-network:
entrypoint: |
sh -c "tailscaled --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055 &
tailscale up --authkey=$PREAUTH_KEY --login-server=https://headscale-test.local;
/etc/init.d/tailscale start
while true; do sleep 1; done"
headscale-worker-3:
image: headscale-test-proxy:latest
container_name: headscale-worker-3
restart: unless-stopped
networks:
headscale-ui-test-network:
entrypoint: |
sh -c "tailscaled --tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1055 &
tailscale up --authkey=$PREAUTH_KEY --login-server=https://headscale-test.local --advertise-routes=10.30.10.1/32,10.30.10.2/32,10.30.10.3/32;
/etc/init.d/tailscale start
while true; do sleep 1; done"
networks:
headscale-ui-test-network:
external: true

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.

View file

@ -1,133 +1,50 @@
# 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>.
## Traefik Configuration
(Thanks @DennisGaida)
```yaml
version: '3.9'
services:
headscale:
image: headscale/headscale:latest
pull_policy: always
container_name: headscale
restart: unless-stopped
command: serve
networks:
- traefik_proxy
command: headscale serve
volumes:
- ./headscale/config:/etc/headscale
- ./headscale/data:/var/lib/headscale
- $DOCKERDIR/headscale/config:/etc/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
- "traefik.enable=true"
## HTTP Routers
- "traefik.http.routers.headscale-rtr.entrypoints=https"
- "traefik.http.routers.headscale-rtr.rule=Host(`hs.${DOMAIN_PUBLIC}`)"
## Middlewares
- "traefik.http.routers.headscale-rtr.middlewares=chain-no-auth@file"
## HTTP Services
- "traefik.http.routers.headscale-rtr.service=headscale-svc"
- "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
networks:
- traefik_proxy
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
- "traefik.enable=true"
## HTTP Routers
- "traefik.http.routers.headscale_ui-rtr.entrypoints=https"
- "traefik.http.routers.headscale_ui-rtr.rule=Host(`hs.${DOMAIN_PUBLIC}`) && PathPrefix(`/web`)"
## Middlewares
- "traefik.http.routers.headscale_ui-rtr.middlewares=chain-no-auth@file"
## HTTP Services
- "traefik.http.routers.headscale_ui-rtr.service=headscale_ui-svc"
- "traefik.http.services.headscale_ui-svc.loadbalancer.server.port=443"
- "traefik.http.services.headscale_ui-svc.loadbalancer.server.scheme=https"
- "traefik.http.services.headscale_ui-svc.loadbalancer.serversTransport=disableSSLCheck@file"
```
# 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:
and `traefik.yaml`
```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;
}
```
http:
serversTransports:
disableSSLCheck:
insecureSkipVerify: true
```

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 {
...
```

5696
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.09.13-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, showACLPagesStore } 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';

View file

@ -1,6 +1,6 @@
<script context="module" lang="ts">
import { APIKey, Device, PreAuthKey, User } from '$lib/common/classes';
import { deviceStore, userStore, apiTestStore} from '$lib/common/stores.js';
import { APIKey, Device, PreAuthKey, Route, User } from '$lib/common/classes';
import { deviceStore, userStore, apiTestStore } from '$lib/common/stores.js';
import { sortDevices, sortUsers } from '$lib/common/sorting.svelte';
import { filterDevices, filterUsers } from './searching.svelte';
@ -10,7 +10,7 @@
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for getting users
let endpointURL = '/api/v1/user';
let endpointURL = '/api/v1/namespace';
//returning variables
let headscaleUsers = [new User()];
@ -40,7 +40,7 @@
});
await headscaleUsersResponse.json().then((data) => {
headscaleUsers = data.users;
headscaleUsers = data.namespaces
// sort the users
headscaleUsers = sortUsers(headscaleUsers);
});
@ -51,13 +51,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',
@ -152,13 +152,12 @@
}
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 +183,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 +218,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 +245,12 @@
}
export async function getDevices(): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// 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 +281,8 @@
});
await headscaleDeviceResponse.json().then((data) => {
headscaleDevices = data[`nodes`];
headscaleDevices = sortDevices(headscaleDevices);
headscaleDevices = data.machines
headscaleDevices = sortDevices(headscaleDevices)
});
// set the stores
apiTestStore.set('succeeded');
@ -293,6 +291,85 @@
filterDevices();
}
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 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',
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;
});
await headscaleDeviceResponse.json().then((data) => {
headscaleRoute = data.routes;
});
return headscaleRoute;
}
export async function enableDeviceRoute(deviceID: string, routes: 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
.map(encodeURIComponent)
.map((route) => `routes=${route}`)
.join('&');
//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 getAPIKeys(): Promise<APIKey[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
@ -329,7 +406,7 @@
return apiKeys;
}
export async function getPreauthKeys(userID: string): Promise<PreAuthKey[]> {
export async function getPreauthKeys(userName: string): Promise<PreAuthKey[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -341,7 +418,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 +444,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 +458,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userID,
namespace: userName,
expiration: expiry,
reusable: reusable,
ephemeral: ephemeral
@ -401,7 +478,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 +493,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userID,
namespace: userName,
key: preAuthKey
})
})
@ -434,16 +511,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 +540,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 +570,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 +599,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,68 @@
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] } = {};
public groups: {[key: string]: [string]} = {}
public constructor(init?: Partial<ACL>) {
Object.assign(this, init);
}
public constructor(init?: Partial<Route>) {
Object.assign(this, init);
}
}
export class Route {
advertisedRoutes: string[] = [];
enabledRoutes: string[] = [];
public constructor(init?: Partial<Route>) {
Object.assign(this, init);
}
}
export class APIKey {
id: string = '';
prefix: string = '';
expiration: string = '';
createdAt: string = '';
lastSeen: string = '';
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

@ -31,7 +31,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 sticky top-0" 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'))}>

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

@ -25,6 +25,7 @@ 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()]);

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

@ -20,21 +20,6 @@
} 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
@ -72,16 +57,11 @@
}
</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}
<span class="badge badge-xs tooltip {timeDifference(new Date(device.lastSeen))}" data-tip={timeSince(new Date(device.lastSeen))} /> {device.id}: {device.givenName}
{/if}
<RenameDevice bind:cardEditing {device} />
</span>
@ -105,7 +85,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,58 @@
<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,...routesList.enabledRoutes])
.then((response) => {
getDeviceRoutesAction();
})
.catch((error) => {
$alertStore = error;
});
}
function disableDeviceRouteAction(route: string) {
enableDeviceRoute(device.id, [...routesList.enabledRoutes].filter(v=>v!=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)}
<button on:click={() => {disableDeviceRouteAction(route)}} type="button" class="btn btn-xs tooltip capitalize bg-success text-success-content mx-1" data-tip="press to disable route">active</button>
{: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-Z0-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

@ -7,7 +7,7 @@
<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>
<div in:fade>
<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

@ -47,7 +47,7 @@
{/if}
</label>
<div class="flex relative">
<input bind:value={$APIKeyStore} {...{ type: apiKeyInputState }} class="form-input" disabled='{apiStatus == 'succeeded'}' required placeholder="******************" />
<input bind:value={$APIKeyStore} {...{ type: apiKeyInputState }} minlength="54" maxlength="54" class="form-input" disabled='{apiStatus == 'succeeded'}' required placeholder="******************" />
<button
type="button"
class="absolute right-40"
@ -79,12 +79,12 @@
<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

@ -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

@ -4,6 +4,7 @@
import Alert from '$lib/common/Alert.svelte';
import Stores from '$lib/common/Stores.svelte';
import { themeStore } from '$lib/common/stores.js'
export const prerender = true;
// NOTE: the element that is using one of the theme attributes must be in the DOM on mount

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

@ -16,7 +16,7 @@
<body>
{#if showACLPagesStore}
<div hidden={!componentLoaded} in:fade|global class="px-4 py-4 w-4/5 max-w-screen-lg">
<div hidden={!componentLoaded} in:fade class="px-4 py-4 w-4/5 max-w-screen-lg">
<h1 class="text-2xl bold text-primary">Group View</h1>
</div>
{/if}

View file

@ -20,7 +20,7 @@
<!-- html -->
<body>
<div hidden={!componentLoaded} in:fade|global class="px-4 py-4 w-4/5 max-w-screen-lg">
<div hidden={!componentLoaded} in:fade class="px-4 py-4 w-4/5 max-w-screen-lg">
<ServerSettings />
<div class="p-4" />
<ThemeSettings />

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

@ -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;