mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
chore: reorganize go code
This commit is contained in:
+2
-2
@@ -4,5 +4,5 @@ node_modules
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
public/hp_ssh.wasm
|
||||
public/wasm_exec.js
|
||||
app/hp_ssh.wasm
|
||||
app/wasm_exec.js
|
||||
|
||||
+1
-1
@@ -10,6 +10,7 @@ export default [
|
||||
route('/logout', 'routes/auth/logout.ts'),
|
||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||
route('/ssh', 'routes/ssh/console.tsx'),
|
||||
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
@@ -25,7 +26,6 @@ export default [
|
||||
route('/users', 'routes/users/overview.tsx'),
|
||||
route('/acls', 'routes/acls/overview.tsx'),
|
||||
route('/dns', 'routes/dns/overview.tsx'),
|
||||
route('/ssh', 'routes/ssh/overview.tsx'),
|
||||
|
||||
...prefix('/settings', [
|
||||
index('routes/settings/overview.tsx'),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { useState } from 'react';
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import '~/wasm_exec';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
// const session = await context.sessions.auth(request);
|
||||
// const user = session.get('user');
|
||||
// if (!user) {
|
||||
// throw data('Unauthorized', 401);
|
||||
// }
|
||||
// if (user.subject === 'unknown-non-oauth') {
|
||||
// throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||
// }
|
||||
// const { users } = await context.client.get<{ users: User[] }>(
|
||||
// 'v1/user',
|
||||
// session.get('api_key')!,
|
||||
// );
|
||||
// // MARK: This assumes that a user has authenticated with Headscale first
|
||||
// // Since the only way to enforce permissions via ACLs is to generate a
|
||||
// // pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||
// // authenticated with Headscale first.
|
||||
// const lookup = users.find((u) => {
|
||||
// const subject = u.providerId?.split('/').pop();
|
||||
// if (!subject) {
|
||||
// return false;
|
||||
// }
|
||||
// return subject === user.subject;
|
||||
// });
|
||||
// if (!lookup) {
|
||||
// throw data(
|
||||
// `User with subject ${user.subject} not found within Headscale`,
|
||||
// 404,
|
||||
// );
|
||||
// }
|
||||
// const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
// 'v1/preauthkey',
|
||||
// session.get('api_key')!,
|
||||
// {
|
||||
// user: lookup.id,
|
||||
// reusable: false,
|
||||
// ephemeral: true,
|
||||
// expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
||||
// },
|
||||
// );
|
||||
// // TODO: Enable config to enforce generate_authkeys capability
|
||||
// // For now, any user is capable of WebSSH connections
|
||||
// // const check = await context.sessions.check(
|
||||
// // request,
|
||||
// // Capabilities.generate_authkeys,
|
||||
// // );
|
||||
// const qp = new URL(request.url).searchParams;
|
||||
// const username = qp.get('username') || undefined;
|
||||
// const hostname = qp.get('hostname') || undefined;
|
||||
// const port = qp.get('port')
|
||||
// ? Number.parseInt(qp.get('port')!, 10)
|
||||
// : undefined;
|
||||
// if (!username || !hostname || !port) {
|
||||
// throw data('Missing required parameters: username, hostname, port', 400);
|
||||
// }
|
||||
// // TODO: Verify the Host headers to ensure CORS friendly
|
||||
// // TODO: Check if the URL actually resolves correctly
|
||||
// // TODO: Keep track of hostname since ephemeral nodes are broken atm
|
||||
// return {
|
||||
// PreAuthKey: preAuthKey.key,
|
||||
// ControlURL:
|
||||
// context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
// Hostname: generateHostname(username),
|
||||
// ssh: {
|
||||
// username,
|
||||
// hostname,
|
||||
// },
|
||||
// };
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
const adjective = faker.word.adjective({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const noun = faker.word.noun({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
// const { pause } = useLiveData();
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
// const { PreAuthKey, ControlURL, Hostname, ssh } =
|
||||
// useLoaderData<typeof loader>();
|
||||
|
||||
// useEffect(() => {
|
||||
// pause();
|
||||
// const go = new Go(); // Go is defined by wasm_exec.js
|
||||
// WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
|
||||
// (value) => {
|
||||
// go.run(value.instance);
|
||||
// const handle = TsWasmNet(
|
||||
// {
|
||||
// PreAuthKey,
|
||||
// ControlURL,
|
||||
// Hostname,
|
||||
// },
|
||||
// {
|
||||
// NotifyState: (state) => {
|
||||
// console.log('State changed:', state);
|
||||
// if (state === 'Running') {
|
||||
// setIpn(handle);
|
||||
// }
|
||||
// },
|
||||
// NotifyNetMap: (netmap) => {
|
||||
// console.log('NetMap updated:', netmap);
|
||||
// },
|
||||
// NotifyBrowseToURL: (url) => {
|
||||
// console.log('Browse to URL:', url);
|
||||
// },
|
||||
// NotifyPanicRecover: (message) => {
|
||||
// console.error('Panic recover:', message);
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
|
||||
// handle.Start();
|
||||
// },
|
||||
// );
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-headplane-900">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto h-screen flex items-center justify-center">
|
||||
<Loader2 className="animate-spin size-10" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-screen">
|
||||
{/* <h1>Session ID: {sessionId}</h1>
|
||||
{queue.current.length > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{queue.current.length} frames queued
|
||||
</p>
|
||||
)} */}
|
||||
|
||||
{/* <XTerm ipn={ipn} /> */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Render your terminal component here */}
|
||||
{/* <Terminal ipn={ipn} /> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
declare function newIPN(config: NewIPNConfig): IPNHandle;
|
||||
declare function TsWasmNet(
|
||||
options: TsWasmNetOptions,
|
||||
callbacks: TsWasmNetCallbacks,
|
||||
): TsWasmNet;
|
||||
|
||||
interface TsWasmNetOptions {
|
||||
ControlURL: string;
|
||||
PreAuthKey: string;
|
||||
Hostname: string;
|
||||
}
|
||||
|
||||
interface TsWasmNetCallbacks {
|
||||
NotifyState: (state: IPNState) => void;
|
||||
NotifyNetMap: (netMapJson: string) => void;
|
||||
NotifyBrowseToURL: (url: string) => void;
|
||||
NotifyPanicRecover: (err: string) => void;
|
||||
}
|
||||
|
||||
interface TsWasmNet {
|
||||
Start: () => void;
|
||||
}
|
||||
|
||||
type IPNState =
|
||||
| 'NoState'
|
||||
| 'InUseOtherUser'
|
||||
| 'NeedsLogin'
|
||||
| 'NeedsMachineAuth'
|
||||
| 'Stopped'
|
||||
| 'Starting'
|
||||
| 'Running';
|
||||
|
||||
interface SSHTerminalConfig {
|
||||
writeFn: (data: string) => void;
|
||||
writeErrorFn: (error: string) => void;
|
||||
setReadFn: (cb: (input: string) => void) => void;
|
||||
rows: number;
|
||||
cols: number;
|
||||
timeoutSeconds?: number;
|
||||
onConnectionProgress: (message: string) => void;
|
||||
onConnected: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
interface SSHSession {
|
||||
close(): boolean;
|
||||
resize(rows: number, cols: number): boolean;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { decode } from 'cborg';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { LoaderFunctionArgs, data, useLoaderData } from 'react-router';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import {
|
||||
Command,
|
||||
SSHConnectData,
|
||||
SSHConnectFailedData,
|
||||
} from '~/server/agent/dispatcher';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import toast from '~/utils/toast';
|
||||
import XTerm from './xterm.client';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const username = qp.get('username') || undefined;
|
||||
const hostname = qp.get('hostname') || undefined;
|
||||
const port = qp.get('port')
|
||||
? Number.parseInt(qp.get('port')!, 10)
|
||||
: undefined;
|
||||
|
||||
if (!username || !hostname || !port) {
|
||||
throw data('Missing required parameters: username, hostname, port', 400);
|
||||
}
|
||||
|
||||
const baseUrl = new URL(request.url).origin;
|
||||
const wsUrl = new URL('/_ssh_plexer', baseUrl);
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl.searchParams.set('username', username);
|
||||
wsUrl.searchParams.set('hostname', hostname);
|
||||
wsUrl.searchParams.set('port', port.toString());
|
||||
|
||||
return {
|
||||
socketUrl: wsUrl.toString(),
|
||||
};
|
||||
}
|
||||
type SessionStatus = 'loading' | 'connected' | 'error';
|
||||
|
||||
export default function Page() {
|
||||
const { pause } = useLiveData();
|
||||
const { socketUrl } = useLoaderData<typeof loader>();
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null);
|
||||
const [status, setStatus] = useState<SessionStatus>('loading');
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
const queue = useRef<Array<Uint8Array>>([]);
|
||||
const validated = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// SSH connections should not use stale while revalidate logic.
|
||||
pause();
|
||||
|
||||
const ws = new WebSocket(socketUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
setSocket(ws);
|
||||
setStatus('loading');
|
||||
};
|
||||
|
||||
// We need to wait for the WebSocket to open and respond with the
|
||||
// connection ID. Without a session ID, we do not have a mux.
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
toast('Invalid message received from server');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
const obj = decode(data) as Command;
|
||||
|
||||
if (obj.op === 'ssh_conn_successful') {
|
||||
const data = obj as SSHConnectData;
|
||||
if (!validated.current) {
|
||||
validated.current = true;
|
||||
toast(
|
||||
`SSH connection established with session ID: ${data.payload.sessionId}`,
|
||||
);
|
||||
|
||||
setStatus('connected');
|
||||
setSessionId(data.payload.sessionId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.op === 'ssh_conn_failed') {
|
||||
const data = obj as SSHConnectFailedData;
|
||||
if (!validated.current) {
|
||||
validated.current = true;
|
||||
toast(`SSH connection failed: ${data.payload.reason}`);
|
||||
setStatus('error');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.op === 'ssh_frame') {
|
||||
queue.current.push(new Uint8Array(event.data));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener('message', messageHandler);
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setStatus('error');
|
||||
toast(`WebSocket error: ${error}`);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (status !== 'error') {
|
||||
toast('SSH connection closed');
|
||||
}
|
||||
|
||||
setSocket(null);
|
||||
setStatus('error');
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.removeEventListener('message', messageHandler);
|
||||
ws.close();
|
||||
};
|
||||
}, [socketUrl]);
|
||||
|
||||
if (socket === null || !sessionId || status === 'loading') {
|
||||
return (
|
||||
<Loader2 className="animate-spin text-gray-500 dark:text-gray-400 w-6 h-6 mx-auto mt-4" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<h1>Session ID: {sessionId}</h1>
|
||||
{queue.current.length > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{queue.current.length} frames queued
|
||||
</p>
|
||||
)}
|
||||
|
||||
<XTerm ws={socket} sessionId={sessionId} queue={queue.current} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare class Go {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
@@ -114,6 +114,9 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = event.data.byteLength;
|
||||
console.log(`[WS] Received ${size} bytes at ${Date.now()}`);
|
||||
|
||||
const data = new Uint8Array(event.data);
|
||||
handleFrame(data);
|
||||
};
|
||||
|
||||
+30
-4
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"syscall/js"
|
||||
|
||||
@@ -10,14 +11,39 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 {
|
||||
log.Fatal("Usage: newIPN(config)")
|
||||
log.Printf("Loading WASM Headplane SSH module")
|
||||
js.Global().Set("TsWasmNet", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 2 {
|
||||
log.Fatal("Usage: TsWasmNet(config, callbacks)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return hp_ipn.NewIPN(args[0])
|
||||
options, err := hp_ipn.ParseTsWasmNetOptions(args[0])
|
||||
if err != nil {
|
||||
log.Fatal("Error parsing options:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
callbacks, err := hp_ipn.ParseTsWasmNetCallbacks(args[1])
|
||||
if err != nil {
|
||||
log.Fatal("Error parsing callbacks:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
ipn, err := hp_ipn.NewTsWasmIpn(options, callbacks)
|
||||
if err != nil {
|
||||
log.Fatal("Error creating TsWasmIpn:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"Start": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
ipn.Start(context.Background())
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}))
|
||||
|
||||
log.Printf("WASM Headplane SSH module loaded successfully")
|
||||
<-make(chan bool)
|
||||
}
|
||||
|
||||
+4
-32
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -31,10 +30,10 @@ import (
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/netstack"
|
||||
"tailscale.com/words"
|
||||
)
|
||||
|
||||
// ControlURL defines the URL to be used for connection to Control.
|
||||
@@ -59,7 +58,7 @@ func NewIPN(jsConfig js.Value) map[string]any {
|
||||
if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString {
|
||||
hostname = jsHostname.String()
|
||||
} else {
|
||||
hostname = generateHostname()
|
||||
hostname = "blah"
|
||||
}
|
||||
|
||||
lpc := getOrCreateLogPolicyConfig(store)
|
||||
@@ -126,7 +125,8 @@ func NewIPN(jsConfig js.Value) map[string]any {
|
||||
sys.NetstackRouter.Set(true)
|
||||
sys.Tun.Get().Start()
|
||||
|
||||
logid := lpc.PublicID
|
||||
logid := logid.PublicID{}
|
||||
|
||||
srv := ipnserver.New(logf, logid, sys.NetMon.Get())
|
||||
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginDefault)
|
||||
if err != nil {
|
||||
@@ -595,34 +595,6 @@ func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M {
|
||||
return n
|
||||
}
|
||||
|
||||
func filterSlice[T any](a []T, f func(T) bool) []T {
|
||||
n := make([]T, 0, len(a))
|
||||
for _, e := range a {
|
||||
if f(e) {
|
||||
n = append(n, e)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func generateHostname() string {
|
||||
tails := words.Tails()
|
||||
scales := words.Scales()
|
||||
if rand.IntN(2) == 0 {
|
||||
// JavaScript
|
||||
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") })
|
||||
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") })
|
||||
} else {
|
||||
// WebAssembly
|
||||
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") })
|
||||
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") })
|
||||
}
|
||||
|
||||
tail := tails[rand.IntN(len(tails))]
|
||||
scale := scales[rand.IntN(len(scales))]
|
||||
return fmt.Sprintf("%s-%s", tail, scale)
|
||||
}
|
||||
|
||||
const logPolicyStateKey = "log-policy"
|
||||
|
||||
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/ipnserver"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/netstack"
|
||||
)
|
||||
|
||||
// Represents an in-state Tailscale backend that is WASM friendly.
|
||||
// The bare minimum to have userspace Wireguard networking is a dialer,
|
||||
// a server, and a backend.
|
||||
type TsWasmIpn struct {
|
||||
// The options used to initialize the TsWasmNet module.
|
||||
options *TsWasmNetOptions
|
||||
|
||||
// The Tailscale dialer, which is used to establish connections.
|
||||
dialer *tsdial.Dialer
|
||||
|
||||
// The Tailscale server, which handles incoming connections and requests.
|
||||
server *ipnserver.Server
|
||||
|
||||
// The Tailscale backend, which manages the local state and operations.
|
||||
backend *ipnlocal.LocalBackend
|
||||
}
|
||||
|
||||
// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options.
|
||||
// This intentionally does not initialize Logtail, as it is only available in
|
||||
// the Tailscale SaaS and not on self-hosted instances.
|
||||
func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) {
|
||||
logf := log.Printf // TODO: Update
|
||||
|
||||
netns.SetEnabled(false) // netns is a separate process (not WASM friendly)
|
||||
|
||||
// Base system (NewSystem() creates a bus automatically)
|
||||
// We supply an in-memory store
|
||||
sys := tsd.NewSystem()
|
||||
bus := sys.Bus.Get()
|
||||
sys.Set(new(mem.Store))
|
||||
|
||||
dialer := &tsdial.Dialer{Logf: logf}
|
||||
netmon, err := netmon.New(bus, logf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Userspace Wireguard engine
|
||||
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
||||
Dialer: dialer,
|
||||
NetMon: netmon,
|
||||
SetSubsystem: sys.Set,
|
||||
ControlKnobs: sys.ControlKnobs(),
|
||||
HealthTracker: sys.HealthTracker(),
|
||||
Metrics: sys.UserMetricsRegistry(),
|
||||
})
|
||||
|
||||
sys.Set(engine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tun := sys.Tun.Get()
|
||||
msock := sys.MagicSock.Get()
|
||||
dnsman := sys.DNSManager.Get()
|
||||
proxymap := sys.ProxyMapper()
|
||||
wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap)
|
||||
|
||||
sys.Set(wgstack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Configure the local Netstack and Dialer
|
||||
wgstack.ProcessLocalIPs = true
|
||||
wgstack.ProcessSubnets = true
|
||||
sys.NetstackRouter.Set(true)
|
||||
|
||||
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||
return wgstack.DialContextTCP(ctx, dst)
|
||||
}
|
||||
|
||||
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||
return wgstack.DialContextUDP(ctx, dst)
|
||||
}
|
||||
|
||||
// Dummy logid for the Tailscale backend
|
||||
logid := logid.PublicID{}
|
||||
tun.Start()
|
||||
|
||||
server := ipnserver.New(logf, logid, sys.NetMon.Get())
|
||||
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
|
||||
|
||||
backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = wgstack.Start(backend)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server.SetLocalBackend(backend)
|
||||
registerNotifyCallback(callbacks, backend)
|
||||
|
||||
return &TsWasmIpn{
|
||||
options: options,
|
||||
dialer: dialer,
|
||||
server: server,
|
||||
backend: backend,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Starts the WASM backend which will connect to the Tailscale tailnet and
|
||||
// register an ephemeral node viewable in the Tailscale admin console.
|
||||
func (t *TsWasmIpn) Start(ctx context.Context) error {
|
||||
// Blank "socket" is a requirement for WASM
|
||||
// This NEEDS to happen before the LocalBackend is started,
|
||||
listener, err := safesocket.Listen("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create safesocket listener: %w", err)
|
||||
}
|
||||
|
||||
// Start the server BEFORE the LocalBackend is started
|
||||
go func() {
|
||||
err := t.server.Run(ctx, listener)
|
||||
if err != nil {
|
||||
// TODO: Handle this dispatch using a chan
|
||||
log.Printf("Failed to run Tailscale server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the LocalBackend
|
||||
err = t.backend.Start(ipn.Options{
|
||||
AuthKey: t.options.PreAuthKey,
|
||||
UpdatePrefs: &ipn.Prefs{
|
||||
ControlURL: t.options.ControlURL,
|
||||
Hostname: t.options.Hostname,
|
||||
WantRunning: true,
|
||||
RunWebClient: false,
|
||||
LoggedOut: false,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start Tailscale backend: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"syscall/js"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// Maps ipn.State values to their string representations for the frontend.
|
||||
var BackendState = map[ipn.State]string{
|
||||
ipn.NoState: "NoState",
|
||||
ipn.Stopped: "Stopped",
|
||||
ipn.Starting: "Starting",
|
||||
ipn.Running: "Running",
|
||||
ipn.InUseOtherUser: "InUseOtherUser",
|
||||
ipn.NeedsMachineAuth: "NeedsMachineAuth",
|
||||
ipn.NeedsLogin: "NeedsLogin",
|
||||
}
|
||||
|
||||
// Represents the callbacks that the TsWasmNet module can invoke to register
|
||||
// data retrieval and notifications on the frontend.
|
||||
type TsWasmNetCallbacks struct {
|
||||
// Changes in the backend state.
|
||||
NotifyState func(ipn.State)
|
||||
|
||||
// Updates to the backend's network map.
|
||||
NotifyNetMap func(*netmap.NetworkMap)
|
||||
|
||||
// If interactive login is required, this passes a login URL.
|
||||
NotifyBrowseToURL func(string)
|
||||
|
||||
// If the process panics, this function is called in go.recover.
|
||||
NotifyPanicRecover func(string)
|
||||
}
|
||||
|
||||
// Parses a JavaScript object containing the necessary callbacks for the
|
||||
// TsWasmNet module to properly interact with the frontend.
|
||||
func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) {
|
||||
if obj.IsUndefined() || obj.IsNull() {
|
||||
return nil, errors.New("callbacks object is undefined or null")
|
||||
}
|
||||
|
||||
state, err := validateCallback("NotifyState", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyState: %w", err)
|
||||
}
|
||||
|
||||
// TODO: This is complicated, as the NetworkMap is a complex type.
|
||||
_, err = validateCallback("NotifyNetMap", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyNetMap: %w", err)
|
||||
}
|
||||
|
||||
browseURL, err := validateCallback("NotifyBrowseToURL", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyBrowseToURL: %w", err)
|
||||
}
|
||||
|
||||
panicRecover, err := validateCallback("NotifyPanicRecover", obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid callback NotifyPanicRecover: %w", err)
|
||||
}
|
||||
|
||||
return &TsWasmNetCallbacks{
|
||||
NotifyState: func(ipnState ipn.State) {
|
||||
state.Invoke(BackendState[ipnState])
|
||||
},
|
||||
|
||||
NotifyNetMap: func(nm *netmap.NetworkMap) {
|
||||
// TODO: This is complicated
|
||||
},
|
||||
|
||||
NotifyBrowseToURL: func(url string) {
|
||||
browseURL.Invoke(url)
|
||||
},
|
||||
|
||||
NotifyPanicRecover: func(msg string) {
|
||||
panicRecover.Invoke(msg)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validates the specified key is a JS function and returns it.
|
||||
func validateCallback(key string, obj js.Value) (*js.Value, error) {
|
||||
val := obj.Get(key)
|
||||
if val.IsUndefined() || val.IsNull() {
|
||||
return nil, errors.New("callback is undefined or null")
|
||||
}
|
||||
|
||||
if val.Type() != js.TypeFunction {
|
||||
return nil, errors.New("callback is not a function")
|
||||
}
|
||||
|
||||
return &val, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
// Represents the options needed to initialize the TsWasmNet module.
|
||||
type TsWasmNetOptions struct {
|
||||
// Tailscale control URL, e.g., "https://controlplane.tailscale.com"
|
||||
ControlURL string
|
||||
|
||||
// Pre-authentication key for the Tailnet
|
||||
PreAuthKey string
|
||||
|
||||
// Optional hostname, autogenerated if not provided.
|
||||
Hostname string
|
||||
}
|
||||
|
||||
// Parses the provided JS object to validate and extract the TsWasmNetOptions.
|
||||
func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
|
||||
if obj.IsUndefined() || obj.IsNull() {
|
||||
return nil, errors.New("TsWasmNetOptions cannot be undefined or null")
|
||||
}
|
||||
|
||||
cUrl := safeString("ControlURL", obj)
|
||||
preAuthKey := safeString("PreAuthKey", obj)
|
||||
hostname := safeString("Hostname", obj)
|
||||
|
||||
if cUrl == "" || preAuthKey == "" || hostname == "" {
|
||||
return nil, errors.New("missing required fields in TsWasmNetOptions")
|
||||
}
|
||||
|
||||
return &TsWasmNetOptions{
|
||||
ControlURL: cUrl,
|
||||
PreAuthKey: preAuthKey,
|
||||
Hostname: hostname,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Retrieves a string value from a JS object safely.
|
||||
func safeString(key string, obj js.Value) string {
|
||||
if obj.IsUndefined() || obj.IsNull() {
|
||||
return ""
|
||||
}
|
||||
|
||||
val := obj.Get(key)
|
||||
if val.IsUndefined() || val.IsNull() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return val.String()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
)
|
||||
|
||||
func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) {
|
||||
lb.SetNotifyCallback(func(n ipn.Notify) {
|
||||
// Panics should be treated with care in a JS/wasm environment.
|
||||
// If a panic occurs, notify the user and either automatically reload
|
||||
// or give the option to reload.
|
||||
|
||||
defer func() {
|
||||
rec := recover()
|
||||
if rec != nil {
|
||||
callbacks.NotifyPanicRecover(fmt.Sprint(rec))
|
||||
}
|
||||
}()
|
||||
|
||||
if n.State != nil {
|
||||
callbacks.NotifyState(*n.State)
|
||||
|
||||
if *n.State == ipn.NeedsLogin {
|
||||
// If the state is NeedsLogin, we need to force an interactive login.
|
||||
go forceInteractiveLogin(lb)
|
||||
}
|
||||
}
|
||||
|
||||
if n.BrowseToURL != nil {
|
||||
callbacks.NotifyBrowseToURL(*n.BrowseToURL)
|
||||
}
|
||||
|
||||
log.Printf("NOTIFY: %+v", n)
|
||||
|
||||
// if nm := n.NetMap; nm != nil {
|
||||
// jsNetMap := jsNetMap{
|
||||
// Self: jsNetMapSelfNode{
|
||||
// jsNetMapNode: jsNetMapNode{
|
||||
// Name: nm.Name,
|
||||
// Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
|
||||
// NodeKey: nm.NodeKey.String(),
|
||||
// MachineKey: nm.MachineKey.String(),
|
||||
// },
|
||||
// MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
|
||||
// },
|
||||
// Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
|
||||
// name := p.Name()
|
||||
// if name == "" {
|
||||
// // In practice this should only happen for Hello.
|
||||
// name = p.Hostinfo().Hostname()
|
||||
// }
|
||||
// addrs := make([]string, p.Addresses().Len())
|
||||
// for i, ap := range p.Addresses().All() {
|
||||
// addrs[i] = ap.Addr().String()
|
||||
// }
|
||||
// return jsNetMapPeerNode{
|
||||
// jsNetMapNode: jsNetMapNode{
|
||||
// Name: name,
|
||||
// Addresses: addrs,
|
||||
// MachineKey: p.Machine().String(),
|
||||
// NodeKey: p.Key().String(),
|
||||
// },
|
||||
// Online: p.Online().Clone(),
|
||||
// TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
|
||||
// }
|
||||
// }),
|
||||
// LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
|
||||
// }
|
||||
// if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
|
||||
// jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
|
||||
// } else {
|
||||
// log.Printf("Could not generate JSON netmap: %v", err)
|
||||
// }
|
||||
// }
|
||||
// if n.BrowseToURL != nil {
|
||||
// jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
|
||||
// }
|
||||
})
|
||||
}
|
||||
|
||||
// To get auth to work, even with a pre-auth key, we need to
|
||||
// force an interactive login on the NeedsLogin state.
|
||||
func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
|
||||
err := lb.StartLoginInteractive(context.Background())
|
||||
if err != nil {
|
||||
fmt.Printf("Error starting interactive login: %v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -49,38 +49,38 @@ func FollowMaster(agent *tsnet.TSAgent) {
|
||||
|
||||
log.Debug("Received message from master: %s", msg)
|
||||
switch msg.Op {
|
||||
case "ssh_conn":
|
||||
var sshPayload sshutil.SSHConnectPayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH connect payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.StartWebSSH(agent, sshPayload)
|
||||
case "ssh_conn":
|
||||
var sshPayload sshutil.SSHConnectPayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH connect payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
case "ssh_term":
|
||||
var sshPayload sshutil.SSHClosePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH close payload: %s", err)
|
||||
continue
|
||||
}
|
||||
sshutil.StartWebSSH(agent, sshPayload)
|
||||
continue
|
||||
|
||||
sshutil.CloseWebSSH(agent, sshPayload)
|
||||
case "ssh_term":
|
||||
var sshPayload sshutil.SSHClosePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH close payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
case "ssh_resize":
|
||||
var sshPayload sshutil.SSHResizePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH resize payload: %s", err)
|
||||
continue
|
||||
}
|
||||
sshutil.CloseWebSSH(agent, sshPayload)
|
||||
continue
|
||||
|
||||
sshutil.ResizeWebSSH(agent, sshPayload)
|
||||
case "ssh_resize":
|
||||
var sshPayload sshutil.SSHResizePayload
|
||||
err = cbor.Unmarshal(msg.Payload, &sshPayload)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal SSH resize payload: %s", err)
|
||||
continue
|
||||
}
|
||||
|
||||
sshutil.ResizeWebSSH(agent, sshPayload)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
|
||||
type FrameBatcher struct {
|
||||
mu sync.Mutex
|
||||
buffer []byte
|
||||
writer io.Writer
|
||||
timer *time.Timer
|
||||
interval time.Duration
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewFrameBatcher(writer io.Writer, interval time.Duration) *FrameBatcher {
|
||||
return &FrameBatcher{
|
||||
writer: writer,
|
||||
interval: interval,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *FrameBatcher) QueueMsg(msg []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.buffer = append(b.buffer, msg...)
|
||||
if b.timer != nil {
|
||||
b.timer.Stop()
|
||||
}
|
||||
|
||||
b.timer = time.AfterFunc(b.interval, b.flush)
|
||||
}
|
||||
|
||||
func (b *FrameBatcher) flush() {
|
||||
log := util.GetLogger()
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if len(b.buffer) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := b.writer.Write(b.buffer)
|
||||
if err != nil {
|
||||
log.Error("Failed to write batched message: %v", err)
|
||||
}
|
||||
|
||||
b.buffer = nil
|
||||
}
|
||||
|
||||
func (b *FrameBatcher) Close() {
|
||||
close(b.done)
|
||||
b.flush()
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package sshutil
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
@@ -92,17 +93,19 @@ func dispatchSSHStdout(id string, stdout io.Reader, stderr io.Reader) {
|
||||
return
|
||||
}
|
||||
|
||||
batcher := NewFrameBatcher(fd, 10*time.Millisecond) // Roughly 60fps
|
||||
|
||||
go readerStreamRoutine(StreamRoutine{
|
||||
SessionID: id,
|
||||
Reader: stdout,
|
||||
Writer: fd,
|
||||
Writer: batcher,
|
||||
ChannelType: ChannelTypeStdout,
|
||||
})
|
||||
|
||||
go readerStreamRoutine(StreamRoutine{
|
||||
SessionID: id,
|
||||
Reader: stderr,
|
||||
Writer: fd,
|
||||
Writer: batcher,
|
||||
ChannelType: ChannelTypeStderr,
|
||||
})
|
||||
}
|
||||
@@ -110,13 +113,13 @@ func dispatchSSHStdout(id string, stdout io.Reader, stderr io.Reader) {
|
||||
type StreamRoutine struct {
|
||||
SessionID string
|
||||
Reader io.Reader
|
||||
Writer io.Writer
|
||||
Writer *FrameBatcher
|
||||
ChannelType ChannelType
|
||||
}
|
||||
|
||||
func readerStreamRoutine(routine StreamRoutine) {
|
||||
hpls1 := HPLSFrame1{}
|
||||
buf := make([]byte, 1024)
|
||||
buf := make([]byte, 16384) // 16 KiB buffer
|
||||
for {
|
||||
byteCount, err := routine.Reader.Read(buf)
|
||||
if err != nil {
|
||||
@@ -138,9 +141,12 @@ func readerStreamRoutine(routine StreamRoutine) {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := routine.Writer.Write(frame); err != nil {
|
||||
util.GetLogger().Error("Failed to write frame to writer: %v", err)
|
||||
break
|
||||
}
|
||||
// if _, err := routine.Writer.Write(frame); err != nil {
|
||||
// util.GetLogger().Error("Failed to write frame to writer: %v", err)
|
||||
// break
|
||||
// }
|
||||
//
|
||||
|
||||
routine.Writer.QueueMsg(frame)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
alias = ["gojs"]
|
||||
description = "Copies Go's wasm_exec.js to the public directory"
|
||||
run = [
|
||||
"echo $(go version) > public/wasm_exec.js",
|
||||
"cat $(go env GOROOT)/lib/wasm/wasm_exec.js >> public/wasm_exec.js",
|
||||
"echo // $(go version) > app/wasm_exec.js",
|
||||
"cat $(go env GOROOT)/lib/wasm/wasm_exec.js >> app/wasm_exec.js",
|
||||
]
|
||||
|
||||
[tasks.build-go-wasm]
|
||||
@@ -15,7 +15,7 @@ alias = ["wasm"]
|
||||
depends = ["copy-wasm-shim"]
|
||||
description = "Builds the Go WebAssembly module for Tailscale SSH"
|
||||
env = { GOOS = "js", GOARCH = "wasm" }
|
||||
run = "go build -o public/hp_ssh.wasm ./cmd/hp_ssh"
|
||||
run = "go build -o app/hp_ssh.wasm ./cmd/hp_ssh"
|
||||
|
||||
[tasks.generate-caddy-certs]
|
||||
alias = ["mkcert"]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^9.8.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@kubernetes/client-node": "^1.3.0",
|
||||
"@primer/octicons-react": "^19.15.2",
|
||||
|
||||
Generated
+9
@@ -25,6 +25,9 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.1.0)
|
||||
'@faker-js/faker':
|
||||
specifier: ^9.8.0
|
||||
version: 9.8.0
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.2.5
|
||||
version: 5.2.5
|
||||
@@ -611,6 +614,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@faker-js/faker@9.8.0':
|
||||
resolution: {integrity: sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=9.0.0'}
|
||||
|
||||
'@fontsource-variable/inter@5.2.5':
|
||||
resolution: {integrity: sha512-TrWffUAFOnT8zroE9YmGybagoOgM/HjRqMQ8k9R0vVgXlnUh/vnpbGPAS/Caz1KIlOPnPGh6fvJbb7DHbFCncA==}
|
||||
|
||||
@@ -3306,6 +3313,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@faker-js/faker@9.8.0': {}
|
||||
|
||||
'@fontsource-variable/inter@5.2.5': {}
|
||||
|
||||
'@formatjs/ecma402-abstract@2.3.4':
|
||||
|
||||
Reference in New Issue
Block a user