Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 100 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,64 +1,124 @@
# HeadscaleUi
# Headscale UI
[![main](https://github.com/simcu/headscale-ui/actions/workflows/main.yml/badge.svg)](https://github.com/simcu/headscale-ui/actions/workflows/main.yml)

This is a static headscale admin ui, no backend enviroment required
Headscale UI is a static admin interface for Headscale. It does not require its own backend service and can be served by any static web server.

The current release is backward compatible with headscale v0.22 as well as compatible with the future headscale v0.23;
## Compatibility

## Thanks
This fork has been updated for Headscale `v0.28`.

Headscale - https://github.com/juanfont/headscale version: v0.21.0
Current support status:

UI - https://github.com/NG-ZORRO/ng-zorro-antd version: v15.0.3
- `v0.28`: supported
- `v0.23` to `v0.27`: login option kept for compatibility, but this release is primarily validated against `v0.28`
- `v0.22` and below: legacy option still exists in the login screen, but not covered by the latest validation

BaseFramework - https://angular.io/ version: 15.2.4
## What Changed For Headscale v0.28

Headscale `v0.28` changed several API behaviors compared with older releases. This update aligns the UI with the newer API by:

## ScreenShots
- switching machine operations to the `/api/v1/node` endpoints
- updating user operations to use user IDs where required by newer API handlers
- updating pre-auth key operations to use the current request and response shapes
- updating route approval actions to use node route approval APIs instead of the old route management flow
- adjusting login defaults to prefer `v0.28`
- improving request handling and error messages during login

<img width="1371" alt="image" src="https://user-images.githubusercontent.com/11401602/227347953-130835c6-7f58-4227-9a83-c6b40b9c2427.png">
<img width="1371" alt="image" src="https://user-images.githubusercontent.com/11401602/227347584-905dd1e8-8b99-4292-8424-2f0e1399a031.png">
<img width="1371" alt="image" src="https://user-images.githubusercontent.com/11401602/227347766-1d9d81c2-5e5a-43fb-ac3d-80d3a04c2f8c.png">
## API Compatibility Note

## Pre Requirement
1. You need to generate a apikey use headscale-cli
> on your headscale server run: headscale apikeys create -e 9999d
2. You need a static web server space, or use docker
3. (optional) If you deploy it on other domain, you need set headscale api's cors.
This update removes the old version-switching logic for the legacy machine endpoints in the Angular API service.

## Deploy Guide
### A: use static web space
emmm.... I don't know how to describe this action.... it is really very easy...
In practice:

### B: use docker (k8s also, it's a nginx static webserver, no other required)
- the UI now uses the Headscale `v0.28` node APIs as the primary implementation
- legacy `/api/v1/machine/...` request branches were removed from the client service
- legacy standalone route management calls were also removed from the client service
- route updates are now performed through node approved-route APIs

1. run command on your docker enviroment
To reduce UI churn, several Angular service method names still keep the historical `machine*` naming, but they now call `/api/v1/node/...` endpoints internally.

> docker run -d --name headscale-ui -p 8888:80 simcu/headscale-ui
This means the current codebase is no longer a full dual-path implementation for both old `machine` APIs and newer `node` APIs.

2. open http://127.0.0.1:8888/manager/
## Current Functional Notes

3. (optional) if run it not on same domain with headscale,the system will error, don't warried, only click "Exit" on the top menu, you will redirect to login page.
The `v0.28` flow was validated for:

4. if you deploy this standalone, on login page click "Change Server" then, input server url.
5. input apikey , click "Login"
- login
- user listing, rename, and delete
- node listing
- node registration
- node rename
- node expiry and delete
- route approval and removal through approved routes
- pre-auth key list, create, and expire

### C: use caddy
Known limitation in `v0.28` mode:

- tag editing is currently shown as read-only in the machine view

## Requirements

1. Generate an API key with the Headscale CLI:

```bash
headscale apikeys create -e 9999d
```

2. Host the built UI on a static web server, or run it with Docker
3. If the UI is hosted on a different origin from Headscale, configure CORS on the Headscale side

## Deployment

### Static Hosting

Build the application and serve the output directory with any static web server.

### Docker

```bash
docker run -d --name headscale-ui -p 8888:80 simcu/headscale-ui
```

Then open:

```text
http://127.0.0.1:8888/manager/
```

If the UI is not hosted on the same origin as Headscale:

1. open the login page
2. click `Change Server`
3. enter the Headscale server URL
4. enter the API key
5. log in

### Caddy Example

```caddy
domain.com {
@ui {
path_regexp (/$)|(\.)
}
handle @ui {
root * /www/headscale-ui
try_files {path} /index.html
file_server
}

reverse_proxy 127.0.0.1:7070
@ui {
path_regexp (/$)|(\.)
}

handle @ui {
root * /www/headscale-ui
try_files {path} /index.html
file_server
}

reverse_proxy 127.0.0.1:7070
}
```

## Notice
1. ServerUrl and ApiKey are saved in localstorage in plain text.
2. you will only need login once.if apikey is invalid , system will require login again.
## Security Notice

- `serverUrl` and `apiKey` are stored in `localStorage` in plain text
- users usually only need to log in once
- if the API key becomes invalid, the UI will require login again

## Credits

- Headscale: https://github.com/juanfont/headscale
- NG-ZORRO: https://github.com/NG-ZORRO/ng-zorro-antd
- Angular: https://angular.io/
114 changes: 20 additions & 94 deletions src/app/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,84 +12,36 @@ export class ApiService {

///Machine api start
machineList(user: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.get(`/api/v1/node?user=${user}`)
}else{
return this.http.get(`/api/v1/machine?user=${user}`)
}
const url = user ? `/api/v1/node?user=${encodeURIComponent(user)}` : '/api/v1/node';
return this.http.get(url);
}

machineRegister(user: string, key: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.post(`/api/v1/node/register?user=${user}&key=${key}`, null);
}else{
return this.http.post(`/api/v1/machine/register?user=${user}&key=${key}`, null);
}
return this.http.post(`/api/v1/node/register?user=${encodeURIComponent(user)}&key=${encodeURIComponent(key)}`, null);
}

machineDetail(machineId: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.get(`/api/v1/node/${machineId}`);
}else{
return this.http.get(`/api/v1/machine/${machineId}`);
}
return this.http.get(`/api/v1/node/${machineId}`);
}

machineExpire(machineId: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.post(`/api/v1/node/${machineId}/expire`, null);
}else{
return this.http.post(`/api/v1/machine/${machineId}/expire`, null);
}
return this.http.post(`/api/v1/node/${machineId}/expire`, null);
}

machineDelete(machineId: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.delete(`/api/v1/node/${machineId}`);
}else{
return this.http.delete(`/api/v1/machine/${machineId}`);
}
return this.http.delete(`/api/v1/node/${machineId}`);
}

machineRename(machineId: string, name: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.post(`/api/v1/node/${machineId}/rename/${name}`, null);
}else{
return this.http.post(`/api/v1/machine/${machineId}/rename/${name}`, null);
}
}

machineRoutes(machineId: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.get(`/api/v1/node/${machineId}/routes`);
}else{
return this.http.get(`/api/v1/machine/${machineId}/routes`);
}
return this.http.post(`/api/v1/node/${machineId}/rename/${encodeURIComponent(name)}`, null);
}

machineTag(machineId: string, tags: Array<string>): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.post(`/api/v1/node/${machineId}/tags`, {tags});
}else{
return this.http.post(`/api/v1/machine/${machineId}/tags`, {tags});
}
return this.http.post(`/api/v1/node/${machineId}/tags`, {nodeId: machineId, tags});
}

machineChangeUser(machineId: string, user: string): Observable<any> {
let version = localStorage.getItem('hsVersion') ?? 'v0.23';
if (version == 'v0.23'){
return this.http.post(`/api/v1/node/${machineId}/user?user=${user}`, null);
}else{
return this.http.post(`/api/v1/machine/${machineId}/user?user=${user}`, null);
}
machineSetApprovedRoutes(machineId: string, routes: Array<string>): Observable<any> {
return this.http.post(`/api/v1/node/${machineId}/approve_routes`, {nodeId: machineId, routes});
}


Expand All @@ -102,51 +54,25 @@ export class ApiService {
return this.http.post('/api/v1/user', {name});
}

userDetail(name: string): Observable<any> {
return this.http.get(`/api/v1/user/${name}`);
}

userDelete(name: string): Observable<any> {
return this.http.delete(`/api/v1/user/${name}`);
}

userRename(old: string, name: string): Observable<any> {
return this.http.post(`/api/v1/user/${old}/rename/${name}`, {});
userDelete(id: string): Observable<any> {
return this.http.delete(`/api/v1/user/${id}`);
}


///route api start
routeList(): Observable<any> {
return this.http.get(`/api/v1/routes`);
}

routeDelete(id: string): Observable<any> {
return this.http.delete(`/api/v1/routes/${id}`);
}

routeEnable(id: string): Observable<any> {
return this.http.post(`/api/v1/routes/${id}/enable`, null);
}

routeDisable(id: string): Observable<any> {
return this.http.post(`/api/v1/routes/${id}/disable`, null);
userRename(id: string, name: string): Observable<any> {
return this.http.post(`/api/v1/user/${id}/rename/${encodeURIComponent(name)}`, {});
}

///preauth key start
preAuthKeyList(user: string): Observable<any> {
var url = `/api/v1/preauthkey`
if (user) {
url = `/api/v1/preauthkey?user=${user}`;
}
return this.http.get(url);
preAuthKeyList(): Observable<any> {
return this.http.get('/api/v1/preauthkey');
}

preAuthKeyAdd(user: string, expiration: string, aclTags: Array<string> = [], reusable = false, ephemeral = false): Observable<any> {
return this.http.post('/api/v1/preauthkey', {user, reusable, ephemeral, aclTags, expiration})
preAuthKeyAdd(userId: string, expiration: string, aclTags: Array<string> = [], reusable = false, ephemeral = false): Observable<any> {
return this.http.post('/api/v1/preauthkey', {user: userId, reusable, ephemeral, aclTags, expiration});
}

preAuthKeyExpire(user: string, key: string): Observable<any> {
return this.http.post('/api/v1/preauthkey/expire', {user, key});
preAuthKeyExpire(id: string): Observable<any> {
return this.http.post('/api/v1/preauthkey/expire', {id});
}

///api key start
Expand Down
11 changes: 6 additions & 5 deletions src/app/http-api.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@ import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor, HttpResponse
HttpInterceptor
} from '@angular/common/http';
import {catchError, mergeMap, Observable, of} from 'rxjs';
import {ActivatedRoute, Router} from '@angular/router';
import {catchError, Observable, of} from 'rxjs';
import {Router} from '@angular/router';
import {NzMessageService} from 'ng-zorro-antd/message';

@Injectable()
export class HttpApiInterceptor implements HttpInterceptor {

constructor(private router: Router, private msg: NzMessageService, private route: ActivatedRoute) {
constructor(private router: Router, private msg: NzMessageService) {
}

intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const serverKey = localStorage.getItem('serverKey') ?? '';
const resetReq = request.clone({
setHeaders: {
'Authorization': 'Bearer ' + localStorage.getItem('serverKey') ?? '',
Authorization: serverKey ? `Bearer ${serverKey}` : '',
'Content-Type': 'application/json'
},
url: (localStorage.getItem('serverUrl') ?? '') + request.url
Expand Down
3 changes: 2 additions & 1 deletion src/app/views/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
nzPlaceHolder="Select Version"
style="width: 100%"
>
<nz-option nzValue="v0.23" nzLabel="Versio v0.23 and above"></nz-option>
<nz-option nzValue="v0.28" nzLabel="Version v0.28"></nz-option>
<nz-option nzValue="v0.23" nzLabel="Version v0.23-v0.27"></nz-option>
<nz-option nzValue="v0.22" nzLabel="Version v0.22 and lower"></nz-option>
</nz-select>
</ng-container>
Expand Down
Loading