Compare commits
20
Commits
webnet
..
f1904a0f7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1904a0f7d | ||
|
|
c0acbfc399 | ||
|
|
75965f86e3 | ||
|
|
308312cbc6 | ||
|
|
62368af810 | ||
|
|
a1aa13da24 | ||
|
|
f1708334a3 | ||
|
|
90a32450ee | ||
|
|
4e00cfff66 | ||
|
|
6c630c6abf | ||
|
|
d36f64bea2 | ||
|
|
10caf83326 | ||
|
|
d73f4278c7 | ||
|
|
33289574ef | ||
|
|
0c6b23f427 | ||
|
|
22e68fe5d9 | ||
|
|
343b3fe583 | ||
|
|
1a13f1cdb3 | ||
|
|
dec5041157 | ||
|
|
434acfdf03 |
@@ -5,7 +5,6 @@ import "../wasm_exec"
|
|||||||
import wasmUrl from "./main.wasm"
|
import wasmUrl from "./main.wasm"
|
||||||
import { sessionStateStorage } from "../lib/js-state-store"
|
import { sessionStateStorage } from "../lib/js-state-store"
|
||||||
import { renderApp } from "./app"
|
import { renderApp } from "./app"
|
||||||
import { startIPN } from "../lib/start-ipn"
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const app = await renderApp()
|
const app = await renderApp()
|
||||||
@@ -14,25 +13,23 @@ async function main() {
|
|||||||
fetch(`./dist/${wasmUrl}`),
|
fetch(`./dist/${wasmUrl}`),
|
||||||
go.importObject
|
go.importObject
|
||||||
)
|
)
|
||||||
|
// The Go process should never exit, if it does then it's an unhandled panic.
|
||||||
|
go.run(wasmInstance.instance).then(() =>
|
||||||
|
app.handleGoPanic("Unexpected shutdown")
|
||||||
|
)
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const authKey = params.get("authkey") ?? undefined
|
const authKey = params.get("authkey") ?? undefined
|
||||||
|
|
||||||
// The Go process should never exit, if it does then it's an unhandled panic.
|
const ipn = newIPN({
|
||||||
const ipn = await startIPN(
|
// Persist IPN state in sessionStorage in development, so that we don't need
|
||||||
go,
|
// to re-authorize every time we reload the page.
|
||||||
wasmInstance.instance,
|
|
||||||
{
|
|
||||||
// Persist IPN state in sessionStorage in development, so that we don't
|
|
||||||
// need to re-authorize every time we reload the page.
|
|
||||||
stateStorage: DEBUG ? sessionStateStorage : undefined,
|
stateStorage: DEBUG ? sessionStateStorage : undefined,
|
||||||
// authKey allows for an auth key to be
|
// authKey allows for an auth key to be
|
||||||
// specified as a url param which automatically
|
// specified as a url param which automatically
|
||||||
// authorizes the client for use.
|
// authorizes the client for use.
|
||||||
authKey: DEBUG ? authKey : undefined,
|
authKey: DEBUG ? authKey : undefined,
|
||||||
},
|
})
|
||||||
(reason) => app.handleGoPanic(reason)
|
|
||||||
)
|
|
||||||
app.runWithIPN(ipn)
|
app.runWithIPN(ipn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a Go runtime and returns the single IPN it owns.
|
|
||||||
*
|
|
||||||
* The runtime does not publish its bridge on a global. It reads the name of a
|
|
||||||
* callback from its environment and invokes it once the bridge is ready, so
|
|
||||||
* this resolves on an explicit signal rather than on the Go scheduler having
|
|
||||||
* run far enough. The name is generated per runtime, so several runtimes can
|
|
||||||
* start in one page without racing each other.
|
|
||||||
*
|
|
||||||
* The returned IPN replaces the bridge's shutdown() with one that resolves when
|
|
||||||
* the runtime has actually exited. The raw promise cannot be awaited: it races
|
|
||||||
* with the runtime tearing itself down, so it may never settle.
|
|
||||||
*/
|
|
||||||
export async function startIPN(
|
|
||||||
go: Go,
|
|
||||||
instance: WebAssembly.Instance,
|
|
||||||
config: IPNConfig,
|
|
||||||
onExit: (reason: string) => void
|
|
||||||
): Promise<IPN> {
|
|
||||||
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
|
|
||||||
const globals = globalThis as Record<string, unknown>
|
|
||||||
|
|
||||||
const ready = new Promise<[NewIPN, Terminate]>((resolve) => {
|
|
||||||
globals[name] = (newIPN: NewIPN, terminate: Terminate) => {
|
|
||||||
delete globals[name]
|
|
||||||
resolve([newIPN, terminate])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
go.env[INIT_CALLBACK_ENV] = name
|
|
||||||
|
|
||||||
// Only an exit the caller did not ask for is worth reporting. Before the
|
|
||||||
// handover every exit is a startup failure and rejecting hands it back as an
|
|
||||||
// error; afterwards, only an exit that shutdown() did not cause is a panic.
|
|
||||||
let stopping = false
|
|
||||||
const exited: Promise<void> = go.run(instance).then(() => {
|
|
||||||
delete globals[name]
|
|
||||||
if (!stopping) onExit("Unexpected shutdown")
|
|
||||||
})
|
|
||||||
// Reject alongside it, so an exit during startup fails the awaits below
|
|
||||||
// instead of leaving them pending forever.
|
|
||||||
const failed: Promise<never> = exited.then(() => {
|
|
||||||
throw new Error("Go runtime exited before the IPN was ready")
|
|
||||||
})
|
|
||||||
|
|
||||||
const [newIPN, terminate] = await Promise.race([ready, failed])
|
|
||||||
let ipn: IPN
|
|
||||||
try {
|
|
||||||
// Keep racing the runtime: building the backend runs in a Go goroutine, and
|
|
||||||
// if the runtime dies partway that goroutine dies with it and its promise
|
|
||||||
// never settles.
|
|
||||||
ipn = await Promise.race([newIPN(config), failed])
|
|
||||||
} catch (err) {
|
|
||||||
// Nothing was built, so nothing can shut the runtime down. Exit it here and
|
|
||||||
// wait for it, or the page keeps a blocked runtime for a failed startup.
|
|
||||||
// Calling terminate on an already-exited runtime does nothing.
|
|
||||||
stopping = true
|
|
||||||
terminate()
|
|
||||||
await exited
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace shutdown in place rather than wrapping the object: the bridge hands
|
|
||||||
// back a plain map of Go-backed functions, and copying it would leave the
|
|
||||||
// caller with something that only looks like the IPN.
|
|
||||||
const rawShutdown = ipn.shutdown.bind(ipn)
|
|
||||||
ipn.shutdown = async () => {
|
|
||||||
stopping = true
|
|
||||||
try {
|
|
||||||
void rawShutdown()
|
|
||||||
} catch {
|
|
||||||
// The runtime may already be gone, in which case there is nothing to ask
|
|
||||||
// and the await below returns immediately.
|
|
||||||
}
|
|
||||||
await exited
|
|
||||||
}
|
|
||||||
return ipn
|
|
||||||
}
|
|
||||||
|
|
||||||
type NewIPN = (config: IPNConfig) => Promise<IPN>
|
|
||||||
type Terminate = () => void
|
|
||||||
|
|
||||||
/** Must match initCallbackEnv in wasm_js.go. */
|
|
||||||
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
/// <reference path="../types/wasm_js.d.ts" />
|
/// <reference path="../types/wasm_js.d.ts" />
|
||||||
|
|
||||||
import "../wasm_exec"
|
import "../wasm_exec"
|
||||||
import { startIPN } from "../lib/start-ipn"
|
|
||||||
import wasmURL from "./main.wasm"
|
import wasmURL from "./main.wasm"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +30,11 @@ export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
|
|||||||
go.importObject
|
go.importObject
|
||||||
)
|
)
|
||||||
// The Go process should never exit, if it does then it's an unhandled panic.
|
// The Go process should never exit, if it does then it's an unhandled panic.
|
||||||
return startIPN(go, wasmInstance.instance, config, config.panicHandler)
|
go.run(wasmInstance.instance).then(() =>
|
||||||
|
config.panicHandler("Unexpected shutdown")
|
||||||
|
)
|
||||||
|
|
||||||
|
return newIPN(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { runSSHSession } from "../lib/ssh"
|
export { runSSHSession } from "../lib/ssh"
|
||||||
|
|||||||
Vendored
+2
-8
@@ -7,18 +7,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
function newIPN(config: IPNConfig): IPN
|
||||||
|
|
||||||
interface IPN {
|
interface IPN {
|
||||||
run(callbacks: IPNCallbacks): void
|
run(callbacks: IPNCallbacks): void
|
||||||
login(): void
|
login(): void
|
||||||
logout(): void
|
logout(): void
|
||||||
/**
|
|
||||||
* Tears down the backend and exits the Go runtime that owns this IPN.
|
|
||||||
*
|
|
||||||
* The promise the bridge returns races with the runtime exiting and may
|
|
||||||
* never settle; startIPN replaces it with one that resolves when the
|
|
||||||
* runtime has actually gone.
|
|
||||||
*/
|
|
||||||
shutdown(): Promise<void>
|
|
||||||
ssh(
|
ssh(
|
||||||
host: string,
|
host: string,
|
||||||
username: string,
|
username: string,
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
//go:build !ts_omit_drive
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"syscall/js"
|
|
||||||
|
|
||||||
"tailscale.com/drive"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/tsd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote.
|
|
||||||
var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil)
|
|
||||||
|
|
||||||
// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging
|
|
||||||
// incoming WebDAV requests to a JS handler function. Auth and permission
|
|
||||||
// parsing are handled upstream by handleServeDrive before this is called.
|
|
||||||
type jsFileSystemForRemote struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
fn js.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
|
|
||||||
fs.mu.Lock()
|
|
||||||
fs.fn = fn
|
|
||||||
fs.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFileServerAddr is a no-op: the JS handler owns its own storage.
|
|
||||||
func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {}
|
|
||||||
|
|
||||||
// SetShares is a no-op: the JS handler controls which shares it exposes.
|
|
||||||
func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {}
|
|
||||||
|
|
||||||
// Close is a no-op.
|
|
||||||
func (fs *jsFileSystemForRemote) Close() error { return nil }
|
|
||||||
|
|
||||||
// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler.
|
|
||||||
// It streams the request body to JS via readBodyChunk() and streams the
|
|
||||||
// response body back via write()/end() callbacks, so no full-body buffering
|
|
||||||
// occurs regardless of file size.
|
|
||||||
//
|
|
||||||
// The call blocks until JS calls end() (or a write error occurs).
|
|
||||||
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
|
||||||
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
|
|
||||||
) {
|
|
||||||
fs.mu.RLock()
|
|
||||||
fn := fs.fn
|
|
||||||
fs.mu.RUnlock()
|
|
||||||
|
|
||||||
if fn.IsUndefined() || fn.IsNull() {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// readBodyChunk is exposed to JS as req.readBodyChunk().
|
|
||||||
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
|
|
||||||
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
|
||||||
return makePromise(func() (any, error) {
|
|
||||||
buf := make([]byte, 65536)
|
|
||||||
n, err := r.Body.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
arr := js.Global().Get("Uint8Array").New(n)
|
|
||||||
js.CopyBytesToJS(arr, buf[:n])
|
|
||||||
return arr, nil
|
|
||||||
}
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return js.Null(), nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// doneCh receives nil when JS calls end(), or a write error if Write fails.
|
|
||||||
doneCh := make(chan error, 1)
|
|
||||||
|
|
||||||
// writeHead sets response headers and status code. Must be called before write().
|
|
||||||
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
|
||||||
if len(args) < 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
status := args[0].Int()
|
|
||||||
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
|
|
||||||
for k, vs := range jsHeadersToGo(args[1]) {
|
|
||||||
for _, v := range vs {
|
|
||||||
w.Header().Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.WriteHeader(status)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// write streams a single response body chunk to the client.
|
|
||||||
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
|
||||||
if len(args) < 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
data := args[0]
|
|
||||||
buf := make([]byte, data.Get("length").Int())
|
|
||||||
js.CopyBytesToGo(buf, data)
|
|
||||||
if _, werr := w.Write(buf); werr != nil {
|
|
||||||
select {
|
|
||||||
case doneCh <- werr:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if f, ok := w.(http.Flusher); ok {
|
|
||||||
f.Flush()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// end signals that the response is complete.
|
|
||||||
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
|
||||||
select {
|
|
||||||
case doneCh <- nil:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
readBodyChunk.Release()
|
|
||||||
writeHead.Release()
|
|
||||||
write.Release()
|
|
||||||
end.Release()
|
|
||||||
}()
|
|
||||||
|
|
||||||
jsReq := map[string]any{
|
|
||||||
"method": r.Method,
|
|
||||||
"path": r.URL.Path,
|
|
||||||
"rawQuery": r.URL.RawQuery,
|
|
||||||
"headers": goHeadersToJS(r.Header),
|
|
||||||
"readBodyChunk": readBodyChunk,
|
|
||||||
}
|
|
||||||
jsRes := map[string]any{
|
|
||||||
"writeHead": writeHead,
|
|
||||||
"write": write,
|
|
||||||
"end": end,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
|
|
||||||
|
|
||||||
// Block this goroutine until JS calls end() or a write error occurs.
|
|
||||||
// The Go WASM scheduler yields back to JS while we wait.
|
|
||||||
<-doneCh
|
|
||||||
}
|
|
||||||
|
|
||||||
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
|
|
||||||
// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write.
|
|
||||||
// The wildcard share name "*" is included if present.
|
|
||||||
func drivePermsToJS(p drive.Permissions) map[string]any {
|
|
||||||
result := make(map[string]any, len(p))
|
|
||||||
for name, perm := range p {
|
|
||||||
result[name] = int(perm)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// goHeadersToJS converts an http.Header to a map[string]any suitable for JS.
|
|
||||||
// Single-value headers become a string; multi-value headers become a []any.
|
|
||||||
func goHeadersToJS(h http.Header) map[string]any {
|
|
||||||
result := make(map[string]any, len(h))
|
|
||||||
for k, vs := range h {
|
|
||||||
if len(vs) == 1 {
|
|
||||||
result[k] = vs[0]
|
|
||||||
} else {
|
|
||||||
arr := make([]any, len(vs))
|
|
||||||
for i, v := range vs {
|
|
||||||
arr[i] = v
|
|
||||||
}
|
|
||||||
result[k] = arr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsHeadersToGo parses a JS headers object into an http.Header map.
|
|
||||||
// Values may be a string or an array of strings.
|
|
||||||
func jsHeadersToGo(jsHeaders js.Value) http.Header {
|
|
||||||
h := make(http.Header)
|
|
||||||
keys := js.Global().Get("Object").Call("keys", jsHeaders)
|
|
||||||
for i := 0; i < keys.Length(); i++ {
|
|
||||||
key := keys.Index(i).String()
|
|
||||||
val := jsHeaders.Get(key)
|
|
||||||
switch val.Type() {
|
|
||||||
case js.TypeString:
|
|
||||||
h.Set(key, val.String())
|
|
||||||
case js.TypeObject:
|
|
||||||
if val.InstanceOf(js.Global().Get("Array")) {
|
|
||||||
for j := 0; j < val.Length(); j++ {
|
|
||||||
h.Add(key, val.Index(j).String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// initDriveForRemote creates the JS-backed FileSystemForRemote and registers
|
|
||||||
// it with sys. Must be called before NewLocalBackend (SubSystem is set-once).
|
|
||||||
func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote {
|
|
||||||
driveFS := &jsFileSystemForRemote{}
|
|
||||||
sys.Set(driveFS)
|
|
||||||
return driveFS
|
|
||||||
}
|
|
||||||
|
|
||||||
// wireDriveJS adds drive-related methods to the IPN JS methods map.
|
|
||||||
// driveFS must be the value returned by initDriveForRemote.
|
|
||||||
func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
|
|
||||||
m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
|
|
||||||
if len(args) < 1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
driveFS.setHandler(args[0])
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
|
||||||
return i.listDrivePeers()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsDrivePeer struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
PeerAPIURL string `json:"peerAPIURL"`
|
|
||||||
StableNodeID string `json:"stableNodeID"`
|
|
||||||
Online *bool `json:"online,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// listDrivePeers returns a JSON array of peers that are online, have a
|
|
||||||
// reachable peerAPI and carry PeerCapabilityTaildriveSharer. Returns an empty
|
|
||||||
// array if the local node does not have drive:access in its ACL
|
|
||||||
// (DriveAccessEnabled). This mirrors the filtering in
|
|
||||||
// LocalBackend.driveRemotesFromPeers.
|
|
||||||
//
|
|
||||||
// The cap means a peer is allowed to share with us, not that it currently
|
|
||||||
// exposes any share, so the result is a superset of the peers with shares.
|
|
||||||
func (i *jsIPN) listDrivePeers() js.Value {
|
|
||||||
return makePromise(func() (any, error) {
|
|
||||||
if !i.lb.DriveAccessEnabled() {
|
|
||||||
return "[]", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
nm := i.lb.NetMap()
|
|
||||||
if nm == nil {
|
|
||||||
return nil, errors.New("listDrivePeers: no network map available")
|
|
||||||
}
|
|
||||||
|
|
||||||
var selfHave4, selfHave6 bool
|
|
||||||
for _, a := range nm.GetAddresses().All() {
|
|
||||||
if !a.IsSingleIP() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if a.Addr().Is4() {
|
|
||||||
selfHave4 = true
|
|
||||||
} else if a.Addr().Is6() {
|
|
||||||
selfHave6 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
peers := make([]jsDrivePeer, 0)
|
|
||||||
for _, p := range nm.Peers {
|
|
||||||
if !p.Online().Get() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
|
|
||||||
if peerURL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Check PeerCapabilityTaildriveSharer via the live PeerCaps map
|
|
||||||
// (derived from ACL rules), mirroring driveRemotesFromPeers.
|
|
||||||
hasCap := false
|
|
||||||
for _, a := range p.Addresses().All() {
|
|
||||||
if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
|
|
||||||
hasCap = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasCap {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
online := p.Online().Clone()
|
|
||||||
peers = append(peers, jsDrivePeer{
|
|
||||||
Name: p.DisplayName(false),
|
|
||||||
PeerAPIURL: peerURL,
|
|
||||||
StableNodeID: string(p.StableID()),
|
|
||||||
Online: online,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(peers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
//go:build ts_omit_drive
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"syscall/js"
|
|
||||||
|
|
||||||
"tailscale.com/tsd"
|
|
||||||
)
|
|
||||||
|
|
||||||
type jsFileSystemForRemote struct{}
|
|
||||||
|
|
||||||
// initDriveForRemote is a no-op when the drive feature is omitted.
|
|
||||||
func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
|
|
||||||
|
|
||||||
// wireDriveJS is a no-op when the drive feature is omitted.
|
|
||||||
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
|
|
||||||
|
|
||||||
// listDrivePeers returns an empty list when the drive feature is omitted.
|
|
||||||
func (i *jsIPN) listDrivePeers() js.Value {
|
|
||||||
return makePromise(func() (any, error) {
|
|
||||||
return "[]", nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/netip"
|
|
||||||
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildPeerAPIURL returns the HTTP base URL for a peer's peerAPI server,
|
|
||||||
// selecting IPv4 when available and falling back to IPv6. Returns an empty
|
|
||||||
// string if the peer advertises no reachable peerAPI port.
|
|
||||||
func buildPeerAPIURL(p tailcfg.NodeView, selfHave4, selfHave6 bool) string {
|
|
||||||
var pp4, pp6 uint16
|
|
||||||
for _, s := range p.Hostinfo().Services().All() {
|
|
||||||
switch s.Proto {
|
|
||||||
case tailcfg.PeerAPI4:
|
|
||||||
pp4 = s.Port
|
|
||||||
case tailcfg.PeerAPI6:
|
|
||||||
pp6 = s.Port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if selfHave4 && pp4 != 0 {
|
|
||||||
for _, a := range p.Addresses().All() {
|
|
||||||
if a.IsSingleIP() && a.Addr().Is4() {
|
|
||||||
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if selfHave6 && pp6 != 0 {
|
|
||||||
for _, a := range p.Addresses().All() {
|
|
||||||
if a.IsSingleIP() && a.Addr().Is6() {
|
|
||||||
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
+312
-204
@@ -6,10 +6,11 @@
|
|||||||
//
|
//
|
||||||
// When run in the browser, a newIPN(config) function is added to the global JS
|
// When run in the browser, a newIPN(config) function is added to the global JS
|
||||||
// namespace. When called it returns an ipn object with the methods
|
// namespace. When called it returns an ipn object with the methods
|
||||||
// run(callbacks), login(), and logout().
|
// run(callbacks), login(), logout(), and ssh(...).
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
@@ -24,14 +25,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/net/dns/dnsmessage"
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
"gvisor.dev/gvisor/pkg/tcpip"
|
"gvisor.dev/gvisor/pkg/tcpip"
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||||
@@ -40,10 +40,10 @@ import (
|
|||||||
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
|
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
|
||||||
"gvisor.dev/gvisor/pkg/waiter"
|
"gvisor.dev/gvisor/pkg/waiter"
|
||||||
"tailscale.com/control/controlclient"
|
"tailscale.com/control/controlclient"
|
||||||
_ "tailscale.com/feature/condregister"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnauth"
|
"tailscale.com/ipn/ipnauth"
|
||||||
"tailscale.com/ipn/ipnlocal"
|
"tailscale.com/ipn/ipnlocal"
|
||||||
|
"tailscale.com/ipn/ipnserver"
|
||||||
"tailscale.com/ipn/localapi"
|
"tailscale.com/ipn/localapi"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
"tailscale.com/logpolicy"
|
"tailscale.com/logpolicy"
|
||||||
@@ -52,6 +52,7 @@ import (
|
|||||||
"tailscale.com/net/netns"
|
"tailscale.com/net/netns"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/net/tsdial"
|
"tailscale.com/net/tsdial"
|
||||||
|
"tailscale.com/safesocket"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tsd"
|
"tailscale.com/tsd"
|
||||||
"tailscale.com/types/logid"
|
"tailscale.com/types/logid"
|
||||||
@@ -64,62 +65,21 @@ import (
|
|||||||
// ControlURL defines the URL to be used for connection to Control.
|
// ControlURL defines the URL to be used for connection to Control.
|
||||||
var ControlURL = ipn.DefaultControlURL
|
var ControlURL = ipn.DefaultControlURL
|
||||||
|
|
||||||
// initCallbackEnv names the JS global holding the callback that this runtime
|
|
||||||
// hands its bridge to. The loader generates a name, installs the callback under
|
|
||||||
// it, and passes the name in through go.env before starting the runtime.
|
|
||||||
//
|
|
||||||
// Publishing the bridge through a caller-supplied callback rather than a fixed
|
|
||||||
// global means the loader never has to guess when the Go scheduler has run far
|
|
||||||
// enough to expose it, and two runtimes in one JS realm cannot collide on the
|
|
||||||
// name.
|
|
||||||
const initCallbackEnv = "TSCONNECT_INIT_CALLBACK"
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
name := os.Getenv(initCallbackEnv)
|
|
||||||
if name == "" {
|
|
||||||
log.Fatalf("%s is not set; this module must be loaded by @webnet/tsconnect", initCallbackEnv)
|
|
||||||
}
|
|
||||||
callback := js.Global().Get(name)
|
|
||||||
if callback.Type() != js.TypeFunction {
|
|
||||||
log.Fatalf("globalThis[%q] is not a function", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdownCh := make(chan struct{})
|
shutdownCh := make(chan struct{})
|
||||||
var terminateOnce sync.Once
|
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
|
|
||||||
|
|
||||||
var claimed atomic.Bool
|
|
||||||
newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
return makePromise(func() (any, error) {
|
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
return nil, errors.New("newIPN takes exactly one argument")
|
log.Fatal("Usage: newIPN(config)")
|
||||||
}
|
|
||||||
// One IPN per runtime: shutdown exits the shared Go runtime, so a
|
|
||||||
// second IPN here would be torn down by the first one's shutdown.
|
|
||||||
if !claimed.CompareAndSwap(false, true) {
|
|
||||||
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
|
|
||||||
}
|
|
||||||
return newIPN(args[0], terminate)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// A failed newIPN leaves the runtime blocked below with nothing to shut it
|
|
||||||
// down, so the loader gets a way to exit it. Leaving that to the loader
|
|
||||||
// keeps the ordering right: closing shutdownCh here would let main return
|
|
||||||
// before makePromise had delivered the rejection.
|
|
||||||
terminateFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
terminate()
|
|
||||||
return nil
|
return nil
|
||||||
})
|
}
|
||||||
|
return newIPN(args[0], shutdownCh)
|
||||||
callback.Invoke(newIPNFn, terminateFn)
|
}))
|
||||||
|
|
||||||
// Block until shutdown() is called on the IPN, then let main return so the
|
// Block until shutdown() is called on the IPN, then let main return so the
|
||||||
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
||||||
<-shutdownCh
|
<-shutdownCh
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||||
netns.SetEnabled(false)
|
netns.SetEnabled(false)
|
||||||
|
|
||||||
var store ipn.StateStore
|
var store ipn.StateStore
|
||||||
@@ -174,13 +134,13 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
|||||||
EventBus: sys.Bus.Get(),
|
EventBus: sys.Bus.Get(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %w", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
sys.Set(eng)
|
sys.Set(eng)
|
||||||
|
|
||||||
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("netstack.Create: %w", err)
|
log.Fatalf("netstack.Create: %v", err)
|
||||||
}
|
}
|
||||||
sys.Set(ns)
|
sys.Set(ns)
|
||||||
ns.ProcessLocalIPs = true
|
ns.ProcessLocalIPs = true
|
||||||
@@ -213,21 +173,20 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
|||||||
sys.Tun.Get().Start()
|
sys.Tun.Get().Start()
|
||||||
|
|
||||||
logid := lpc.PublicID
|
logid := lpc.PublicID
|
||||||
|
srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
|
||||||
// initDriveForRemote must be called before NewLocalBackend (SubSystem is set-once).
|
|
||||||
driveFS := initDriveForRemote(sys)
|
|
||||||
|
|
||||||
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
|
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ipnlocal.NewLocalBackend: %w", err)
|
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
|
||||||
}
|
}
|
||||||
if err := ns.Start(lb); err != nil {
|
if err := ns.Start(lb); err != nil {
|
||||||
return nil, fmt.Errorf("starting netstack: %w", err)
|
log.Fatalf("failed to start netstack: %v", err)
|
||||||
}
|
}
|
||||||
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
|
wireTaildropFileOps(lb, jsConfig.Get("fileOps"))
|
||||||
|
srv.SetLocalBackend(lb)
|
||||||
|
|
||||||
jsIPN := &jsIPN{
|
jsIPN := &jsIPN{
|
||||||
dialer: dialer,
|
dialer: dialer,
|
||||||
|
srv: srv,
|
||||||
lb: lb,
|
lb: lb,
|
||||||
ns: ns,
|
ns: ns,
|
||||||
controlURL: controlURL,
|
controlURL: controlURL,
|
||||||
@@ -235,11 +194,11 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
|||||||
hostname: hostname,
|
hostname: hostname,
|
||||||
logID: logid,
|
logID: logid,
|
||||||
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
||||||
terminate: terminate,
|
shutdownCh: shutdownCh,
|
||||||
}
|
}
|
||||||
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
||||||
|
|
||||||
m := map[string]any{
|
return map[string]any{
|
||||||
"run": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"run": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
log.Fatal(`Usage: run({
|
log.Fatal(`Usage: run({
|
||||||
@@ -269,6 +228,25 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
|||||||
jsIPN.logout()
|
jsIPN.logout()
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
|
"ssh": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 3 {
|
||||||
|
log.Printf("Usage: ssh(hostname, userName, termConfig)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return jsIPN.ssh(
|
||||||
|
args[0].String(),
|
||||||
|
args[1].String(),
|
||||||
|
args[2])
|
||||||
|
}),
|
||||||
|
"fetch": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 1 {
|
||||||
|
log.Printf("Usage: fetch(url)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url := args[0].String()
|
||||||
|
return jsIPN.fetch(url)
|
||||||
|
}),
|
||||||
"dial": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"dial": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
log.Printf("Usage: dial(network, addr)")
|
log.Printf("Usage: dial(network, addr)")
|
||||||
@@ -308,6 +286,13 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
return jsIPN.setExitNode(args[0].String())
|
return jsIPN.setExitNode(args[0].String())
|
||||||
}),
|
}),
|
||||||
|
"setExitNodeEnabled": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 1 {
|
||||||
|
log.Printf("Usage: setExitNodeEnabled(enabled)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return jsIPN.setExitNodeEnabled(args[0].Bool())
|
||||||
|
}),
|
||||||
"listFileTargets": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"listFileTargets": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
return jsIPN.listFileTargets()
|
return jsIPN.listFileTargets()
|
||||||
}),
|
}),
|
||||||
@@ -395,13 +380,6 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
|||||||
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
return jsIPN.shutdown()
|
return jsIPN.shutdown()
|
||||||
}),
|
}),
|
||||||
"setServices": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
if len(args) != 1 {
|
|
||||||
log.Printf("Usage: setServices(services)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return jsIPN.setServices(args[0])
|
|
||||||
}),
|
|
||||||
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
log.Printf("Usage: localAPI(method, path[, body])")
|
log.Printf("Usage: localAPI(method, path[, body])")
|
||||||
@@ -414,12 +392,11 @@ func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
|
|||||||
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
|
return jsIPN.localAPI(args[0].String(), args[1].String(), body)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
wireDriveJS(jsIPN, driveFS, m)
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsIPN struct {
|
type jsIPN struct {
|
||||||
dialer *tsdial.Dialer
|
dialer *tsdial.Dialer
|
||||||
|
srv *ipnserver.Server
|
||||||
lb *ipnlocal.LocalBackend
|
lb *ipnlocal.LocalBackend
|
||||||
ns *netstack.Impl
|
ns *netstack.Impl
|
||||||
controlURL string
|
controlURL string
|
||||||
@@ -430,7 +407,10 @@ type jsIPN struct {
|
|||||||
funnelMu sync.Mutex
|
funnelMu sync.Mutex
|
||||||
funnelPorts map[uint16]*funnelListenerEntry
|
funnelPorts map[uint16]*funnelListenerEntry
|
||||||
|
|
||||||
terminate func() // unblocks main() so the Go runtime can exit
|
// ln is the safesocket listener created by run(); stored here so shutdown
|
||||||
|
// can close it and unblock srv.Run.
|
||||||
|
ln net.Listener
|
||||||
|
shutdownCh chan struct{} // closed by shutdown() to unblock main()
|
||||||
shutdownOnce sync.Once
|
shutdownOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +497,6 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
NodeKey: nm.NodeKey.String(),
|
NodeKey: nm.NodeKey.String(),
|
||||||
MachineKey: nm.MachineKey.String(),
|
MachineKey: nm.MachineKey.String(),
|
||||||
PeerAPIURL: selfPeerAPIURL,
|
PeerAPIURL: selfPeerAPIURL,
|
||||||
Services: userServicesFromView(nm.SelfNode.Hostinfo().Services()),
|
|
||||||
},
|
},
|
||||||
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
|
MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
|
||||||
},
|
},
|
||||||
@@ -533,7 +512,32 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Peer peerAPI URL from the peer's advertised Services.
|
// Peer peerAPI URL from the peer's advertised Services.
|
||||||
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
|
peerURL := ""
|
||||||
|
var pp4, pp6 uint16
|
||||||
|
for _, s := range p.Hostinfo().Services().All() {
|
||||||
|
switch s.Proto {
|
||||||
|
case tailcfg.PeerAPI4:
|
||||||
|
pp4 = s.Port
|
||||||
|
case tailcfg.PeerAPI6:
|
||||||
|
pp6 = s.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selfHave4 && pp4 != 0 {
|
||||||
|
for _, a := range p.Addresses().All() {
|
||||||
|
if a.IsSingleIP() && a.Addr().Is4() {
|
||||||
|
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if peerURL == "" && selfHave6 && pp6 != 0 {
|
||||||
|
for _, a := range p.Addresses().All() {
|
||||||
|
if a.IsSingleIP() && a.Addr().Is6() {
|
||||||
|
peerURL = fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return jsNetMapPeerNode{
|
return jsNetMapPeerNode{
|
||||||
jsNetMapNode: jsNetMapNode{
|
jsNetMapNode: jsNetMapNode{
|
||||||
@@ -542,7 +546,6 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
MachineKey: p.Machine().String(),
|
MachineKey: p.Machine().String(),
|
||||||
NodeKey: p.Key().String(),
|
NodeKey: p.Key().String(),
|
||||||
PeerAPIURL: peerURL,
|
PeerAPIURL: peerURL,
|
||||||
Services: userServicesFromView(p.Hostinfo().Services()),
|
|
||||||
},
|
},
|
||||||
Online: p.Online().Clone(),
|
Online: p.Online().Clone(),
|
||||||
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
|
TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
|
||||||
@@ -622,6 +625,18 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
ln, err := safesocket.Listen("")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("safesocket.Listen: %v", err)
|
||||||
|
}
|
||||||
|
i.ln = ln
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := i.srv.Run(context.Background(), ln)
|
||||||
|
if err != nil && !errors.Is(err, net.ErrClosed) {
|
||||||
|
log.Fatalf("ipnserver.Run exited: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *jsIPN) login() {
|
func (i *jsIPN) login() {
|
||||||
@@ -639,22 +654,204 @@ func (i *jsIPN) logout() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// shutdown tears down the backend and lets main return, which exits the whole
|
|
||||||
// Go runtime. Callers should await the runtime's exit rather than the promise
|
|
||||||
// returned here: terminating races with makePromise resolving, so the promise
|
|
||||||
// may never settle.
|
|
||||||
func (i *jsIPN) shutdown() js.Value {
|
func (i *jsIPN) shutdown() js.Value {
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
i.shutdownOnce.Do(func() {
|
i.shutdownOnce.Do(func() {
|
||||||
if i.lb != nil {
|
|
||||||
i.lb.Shutdown()
|
i.lb.Shutdown()
|
||||||
}
|
i.ln.Close()
|
||||||
i.terminate()
|
close(i.shutdownCh)
|
||||||
})
|
})
|
||||||
return nil, nil
|
return nil, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
|
||||||
|
jsSSHSession := &jsSSHSession{
|
||||||
|
jsIPN: i,
|
||||||
|
host: host,
|
||||||
|
username: username,
|
||||||
|
termConfig: termConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
go jsSSHSession.Run()
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
return jsSSHSession.Close() != nil
|
||||||
|
}),
|
||||||
|
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
rows := args[0].Int()
|
||||||
|
cols := args[1].Int()
|
||||||
|
return jsSSHSession.Resize(rows, cols) != nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsSSHSession struct {
|
||||||
|
jsIPN *jsIPN
|
||||||
|
host string
|
||||||
|
username string
|
||||||
|
termConfig js.Value
|
||||||
|
session *ssh.Session
|
||||||
|
|
||||||
|
pendingResizeRows int
|
||||||
|
pendingResizeCols int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jsSSHSession) Run() {
|
||||||
|
writeFn := s.termConfig.Get("writeFn")
|
||||||
|
writeErrorFn := s.termConfig.Get("writeErrorFn")
|
||||||
|
setReadFn := s.termConfig.Get("setReadFn")
|
||||||
|
rows := s.termConfig.Get("rows").Int()
|
||||||
|
cols := s.termConfig.Get("cols").Int()
|
||||||
|
timeoutSeconds := 5.0
|
||||||
|
if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber {
|
||||||
|
timeoutSeconds = jsTimeoutSeconds.Float()
|
||||||
|
}
|
||||||
|
onConnectionProgress := s.termConfig.Get("onConnectionProgress")
|
||||||
|
onConnected := s.termConfig.Get("onConnected")
|
||||||
|
onDone := s.termConfig.Get("onDone")
|
||||||
|
defer onDone.Invoke()
|
||||||
|
|
||||||
|
writeError := func(label string, err error) {
|
||||||
|
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
|
||||||
|
}
|
||||||
|
reportProgress := func(message string) {
|
||||||
|
onConnectionProgress.Invoke(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second)))
|
||||||
|
defer cancel()
|
||||||
|
reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0]))
|
||||||
|
c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22"))
|
||||||
|
if err != nil {
|
||||||
|
writeError("Dial", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
// Host keys are not used with Tailscale SSH, but we can use this
|
||||||
|
// callback to know that the connection has been established.
|
||||||
|
reportProgress("SSH connection established…")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
User: s.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
reportProgress("Starting SSH client…")
|
||||||
|
sshConn, _, _, err := ssh.NewClientConn(c, s.host, config)
|
||||||
|
if err != nil {
|
||||||
|
writeError("SSH Connection", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sshConn.Close()
|
||||||
|
|
||||||
|
sshClient := ssh.NewClient(sshConn, nil, nil)
|
||||||
|
defer sshClient.Close()
|
||||||
|
|
||||||
|
session, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
writeError("SSH Session", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.session = session
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
writeError("SSH Stdin", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Stdout = termWriter{writeFn}
|
||||||
|
session.Stderr = termWriter{writeFn}
|
||||||
|
|
||||||
|
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
input := args[0].String()
|
||||||
|
_, err := stdin.Write([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
writeError("Write Input", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
// We might have gotten a resize notification since we started opening the
|
||||||
|
// session, pick up the latest size.
|
||||||
|
if s.pendingResizeRows != 0 {
|
||||||
|
rows = s.pendingResizeRows
|
||||||
|
}
|
||||||
|
if s.pendingResizeCols != 0 {
|
||||||
|
cols = s.pendingResizeCols
|
||||||
|
}
|
||||||
|
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
|
||||||
|
if err != nil {
|
||||||
|
writeError("Pseudo Terminal", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = session.Shell()
|
||||||
|
if err != nil {
|
||||||
|
writeError("Shell", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnected.Invoke()
|
||||||
|
err = session.Wait()
|
||||||
|
if err != nil {
|
||||||
|
writeError("Wait", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jsSSHSession) Close() error {
|
||||||
|
if s.session == nil {
|
||||||
|
// We never had a chance to open the session, ignore the close request.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.session.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jsSSHSession) Resize(rows, cols int) error {
|
||||||
|
if s.session == nil {
|
||||||
|
s.pendingResizeRows = rows
|
||||||
|
s.pendingResizeCols = cols
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.session.WindowChange(rows, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *jsIPN) fetch(url string) js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
c := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: i.dialer.UserDial,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := c.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"status": res.StatusCode,
|
||||||
|
"statusText": res.Status,
|
||||||
|
"text": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
defer res.Body.Close()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if _, err := buf.ReadFrom(res.Body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
// TODO: populate a more complete JS Response object
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
|
func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
mp := &ipn.MaskedPrefs{
|
mp := &ipn.MaskedPrefs{
|
||||||
@@ -666,6 +863,13 @@ func (i *jsIPN) setExitNode(stableNodeID string) js.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *jsIPN) setExitNodeEnabled(enabled bool) js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
_, err := i.lb.SetUseExitNodeEnabled(ipnauth.Self, enabled)
|
||||||
|
return nil, err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (i *jsIPN) dial(network, addr string) js.Value {
|
func (i *jsIPN) dial(network, addr string) js.Value {
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
@@ -689,11 +893,6 @@ func (i *jsIPN) listen(network, addr string) js.Value {
|
|||||||
if n == "tcp" {
|
if n == "tcp" {
|
||||||
n = "tcp4"
|
n = "tcp4"
|
||||||
}
|
}
|
||||||
// netstack.ListenTCP requires a full host:port; normalise the
|
|
||||||
// standard net.Listen form ":port" that omits the host.
|
|
||||||
if strings.HasPrefix(addr, ":") {
|
|
||||||
addr = "0.0.0.0" + addr
|
|
||||||
}
|
|
||||||
ln, err := i.ns.ListenTCP(n, addr)
|
ln, err := i.ns.ListenTCP(n, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -711,18 +910,22 @@ func (i *jsIPN) listen(network, addr string) js.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// tlsClientConfigFromJS builds a client tls.Config from optional JS options
|
func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
|
||||||
// (serverName, insecureSkipVerify, caCerts). defaultServerName may be empty
|
return makePromise(func() (any, error) {
|
||||||
// (STARTTLS upgrade case), in which case serverName must be provided unless
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
// insecureSkipVerify is set.
|
defer cancel()
|
||||||
//
|
|
||||||
// On wasm there's no system root pool, so default to the baked-in
|
host, _, err := net.SplitHostPort(addr)
|
||||||
// LetsEncrypt roots (which is what `tailscale cert` uses for tailnet
|
if err != nil {
|
||||||
// HTTPS endpoints). Callers can override with caCerts (PEM) or bypass
|
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
|
||||||
// entirely with insecureSkipVerify.
|
}
|
||||||
func tlsClientConfigFromJS(defaultServerName string, opts js.Value) (*tls.Config, error) {
|
|
||||||
|
// On wasm there's no system root pool, so default to the
|
||||||
|
// baked-in LetsEncrypt roots (which is what `tailscale cert`
|
||||||
|
// uses for tailnet HTTPS endpoints). Callers can override with
|
||||||
|
// caCerts (PEM) or bypass entirely with insecureSkipVerify.
|
||||||
cfg := &tls.Config{
|
cfg := &tls.Config{
|
||||||
ServerName: defaultServerName,
|
ServerName: host,
|
||||||
RootCAs: bakedroots.Get(),
|
RootCAs: bakedroots.Get(),
|
||||||
}
|
}
|
||||||
if !opts.IsUndefined() && !opts.IsNull() {
|
if !opts.IsUndefined() && !opts.IsNull() {
|
||||||
@@ -740,26 +943,6 @@ func tlsClientConfigFromJS(defaultServerName string, opts js.Value) (*tls.Config
|
|||||||
cfg.RootCAs = pool
|
cfg.RootCAs = pool
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.ServerName == "" && !cfg.InsecureSkipVerify {
|
|
||||||
return nil, fmt.Errorf("serverName is required unless insecureSkipVerify is set")
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *jsIPN) dialTLS(addr string, opts js.Value) js.Value {
|
|
||||||
return makePromise(func() (any, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
host, _, err := net.SplitHostPort(addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := tlsClientConfigFromJS(host, opts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rawConn, err := i.dialer.UserDial(ctx, "tcp", addr)
|
rawConn, err := i.dialer.UserDial(ctx, "tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1137,10 +1320,10 @@ func (i *jsIPN) ping(ip string, pingType string, size int) js.Value {
|
|||||||
return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err)
|
return nil, fmt.Errorf("ping: invalid IP %q: %w", ip, err)
|
||||||
}
|
}
|
||||||
switch tailcfg.PingType(pingType) {
|
switch tailcfg.PingType(pingType) {
|
||||||
case tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
case tailcfg.PingDisco, tailcfg.PingTSMP, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
||||||
// valid
|
// valid
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("ping: unknown type %q, must be one of: TSMP, ICMP, peerapi", pingType)
|
return nil, fmt.Errorf("ping: unknown type %q, must be one of: disco, TSMP, ICMP, peerapi", pingType)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1190,39 +1373,6 @@ func (i *jsIPN) suggestExitNode() js.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *jsIPN) setServices(jsServices js.Value) js.Value {
|
|
||||||
return makePromise(func() (any, error) {
|
|
||||||
n := jsServices.Length()
|
|
||||||
svcs := make([]tailcfg.Service, 0, n)
|
|
||||||
for idx := range n {
|
|
||||||
s := jsServices.Index(idx)
|
|
||||||
proto := tailcfg.ServiceProto(s.Get("proto").String())
|
|
||||||
port := uint16(s.Get("port").Int())
|
|
||||||
var desc string
|
|
||||||
if d := s.Get("description"); d.Type() == js.TypeString {
|
|
||||||
desc = d.String()
|
|
||||||
}
|
|
||||||
svcs = append(svcs, tailcfg.Service{Proto: proto, Port: port, Description: desc})
|
|
||||||
}
|
|
||||||
i.lb.SetExplicitServices(svcs)
|
|
||||||
return nil, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// userServicesFromView converts a hostinfo services slice to jsService entries,
|
|
||||||
// filtering out internal peerapi protocol entries (already reflected in peerAPIURL).
|
|
||||||
func userServicesFromView(svcs views.Slice[tailcfg.Service]) []jsService {
|
|
||||||
out := make([]jsService, 0, svcs.Len())
|
|
||||||
for _, s := range svcs.All() {
|
|
||||||
switch s.Proto {
|
|
||||||
case tailcfg.PeerAPI4, tailcfg.PeerAPI6, tailcfg.PeerAPIDNS:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, jsService{Proto: string(s.Proto), Port: s.Port, Description: s.Description})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *jsIPN) localAPI(method, path, body string) js.Value {
|
func (i *jsIPN) localAPI(method, path, body string) js.Value {
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
h := localapi.NewHandler(localapi.HandlerConfig{
|
h := localapi.NewHandler(localapi.HandlerConfig{
|
||||||
@@ -1302,51 +1452,6 @@ func wrapConn(conn net.Conn) map[string]any {
|
|||||||
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"remoteAddr": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
return conn.RemoteAddr().String()
|
return conn.RemoteAddr().String()
|
||||||
}),
|
}),
|
||||||
// upgradeTLS wraps the conn in TLS in place (STARTTLS-style) and
|
|
||||||
// returns a new wrapped conn sharing the same underlying net.Conn;
|
|
||||||
// the old handle must not be used afterward. On any failure —
|
|
||||||
// configuration or handshake — the underlying conn is closed, so
|
|
||||||
// callers can treat every rejection as fatal to the connection.
|
|
||||||
// With isServer, certPem and keyPem are required; otherwise client
|
|
||||||
// options as in dialTLS apply.
|
|
||||||
"upgradeTLS": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
opts := js.Undefined()
|
|
||||||
if len(args) > 0 {
|
|
||||||
opts = args[0]
|
|
||||||
}
|
|
||||||
return makePromise(func() (any, error) {
|
|
||||||
var tlsConn *tls.Conn
|
|
||||||
hasOpts := !opts.IsUndefined() && !opts.IsNull()
|
|
||||||
if hasOpts && opts.Get("isServer").Type() == js.TypeBoolean && opts.Get("isServer").Bool() {
|
|
||||||
certPem := opts.Get("certPem")
|
|
||||||
keyPem := opts.Get("keyPem")
|
|
||||||
if certPem.Type() != js.TypeString || keyPem.Type() != js.TypeString {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("upgradeTLS: certPem and keyPem are required when isServer is set")
|
|
||||||
}
|
|
||||||
cert, err := tls.X509KeyPair([]byte(certPem.String()), []byte(keyPem.String()))
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, fmt.Errorf("upgradeTLS: parsing cert/key: %w", err)
|
|
||||||
}
|
|
||||||
tlsConn = tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
|
|
||||||
} else {
|
|
||||||
cfg, err := tlsClientConfigFromJS("", opts)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tlsConn = tls.Client(conn, cfg)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return wrapConn(tlsConn), nil
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1465,6 +1570,16 @@ func resolveUDPAddr(s string) (*net.UDPAddr, error) {
|
|||||||
return &net.UDPAddr{IP: ip, Port: port}, nil
|
return &net.UDPAddr{IP: ip, Port: port}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type termWriter struct {
|
||||||
|
f js.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w termWriter) Write(p []byte) (n int, err error) {
|
||||||
|
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
|
||||||
|
w.f.Invoke(string(r))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
// jsIncomingFile is the JSON representation of an in-progress inbound file
|
// jsIncomingFile is the JSON representation of an in-progress inbound file
|
||||||
// transfer sent to the notifyIncomingFiles callback.
|
// transfer sent to the notifyIncomingFiles callback.
|
||||||
type jsIncomingFile struct {
|
type jsIncomingFile struct {
|
||||||
@@ -1494,19 +1609,12 @@ type jsNetMap struct {
|
|||||||
LockedOut bool `json:"lockedOut"`
|
LockedOut bool `json:"lockedOut"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsService struct {
|
|
||||||
Proto string `json:"proto"`
|
|
||||||
Port uint16 `json:"port"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsNetMapNode struct {
|
type jsNetMapNode struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Addresses []string `json:"addresses"`
|
Addresses []string `json:"addresses"`
|
||||||
MachineKey string `json:"machineKey"`
|
MachineKey string `json:"machineKey"`
|
||||||
NodeKey string `json:"nodeKey"`
|
NodeKey string `json:"nodeKey"`
|
||||||
PeerAPIURL string `json:"peerAPIURL,omitempty"`
|
PeerAPIURL string `json:"peerAPIURL,omitempty"`
|
||||||
Services []jsService `json:"services"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsNetMapSelfNode struct {
|
type jsNetMapSelfNode struct {
|
||||||
|
|||||||
@@ -36,36 +36,49 @@ var baseTags = []string{
|
|||||||
"omitpemdecrypt",
|
"omitpemdecrypt",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Omit is the set of feature/featuretags tags excluded from the
|
// Keep is the set of feature/featuretags tags the cmd/tsconnect/wasm
|
||||||
// cmd/tsconnect/wasm build via their ts_omit_ build tag (computed by
|
// build needs LINKED. Every other feature in [featuretags.Features] is
|
||||||
// [Tags]). Everything else in [featuretags.Features] stays linked.
|
// excluded via its ts_omit_ build tag (computed by [Tags]).
|
||||||
//
|
// Transitive dependencies of entries in Keep are pulled in
|
||||||
// Upstream uses the opposite polarity here — a small allow-list — because
|
// automatically via [featuretags.Requires].
|
||||||
// its wasm client is only an SSH/fetch-in-browser tool. This fork's JS
|
|
||||||
// bridge exposes Taildrop, Taildrive, Funnel/serve, ACME certs, exit node
|
|
||||||
// selection, service advertisement and the peerAPI, so an allow-list is
|
|
||||||
// the wrong default: a missing entry is not a compile error, it is a
|
|
||||||
// feature that silently stops working at runtime (an omitted extension
|
|
||||||
// simply never registers its hooks). Linking everything also matches how
|
|
||||||
// this build behaved before upstream introduced featuretags.
|
|
||||||
//
|
//
|
||||||
|
// Adding an entry here grows the wasm bundle. Removing one strips it.
|
||||||
// The init() below panics if any entry is unknown to feature/featuretags,
|
// The init() below panics if any entry is unknown to feature/featuretags,
|
||||||
// so a rename / removal in that registry fails loudly here.
|
// so a rename / removal in that registry fails loudly here.
|
||||||
//
|
//
|
||||||
// Trimming the bundle by omitting more features is worthwhile but should
|
// Notably absent (server-only or otherwise meaningless in a browser):
|
||||||
// be done with measurements and per-feature runtime verification, not by
|
// - "ssh": controls the SSH *server* (feature/ssh registers
|
||||||
// assuming a feature is unreachable from the browser.
|
// ssh/tailssh). The wasm acts as an SSH *client* using
|
||||||
var Omit = []featuretags.FeatureTag{
|
// golang.org/x/crypto/ssh directly; no featuretag gates that.
|
||||||
// feature/ace does not compile for GOOS=js: control/controlhttp only
|
// - "portmapper", "debugportmapper": js/wasm has no UDP sockets,
|
||||||
// installs HookMakeACEDialer on non-js platforms, so feature/ace's
|
// can't speak NAT-PMP / PCP / UPnP.
|
||||||
// reference to it is undefined here.
|
// - "captiveportal": the browser handles captive portal detection
|
||||||
"ace",
|
// in front of us.
|
||||||
|
// - "syspolicy": no MDM in a browser.
|
||||||
|
// - "drive", "taildrop", "peerapi*": no local filesystem.
|
||||||
|
// - "clientupdate": no binary self-update.
|
||||||
|
// - "dbus", "resolved", "networkmanager", "iptables", "linkspeed",
|
||||||
|
// "linuxdnsfight", "listenrawdisco", "osrouter", "synology",
|
||||||
|
// "systray", "tundevstats", "wakeonlan": OS integrations not
|
||||||
|
// applicable to a browser-hosted client.
|
||||||
|
// - "aws", "cloud", "kube", "bird", "appconnectors", "conn25",
|
||||||
|
// "relayserver", "serve", "acme", "tap", "tpm", "doctor",
|
||||||
|
// "advertiseroutes", "advertiseexitnode", "useroutes",
|
||||||
|
// "useexitnode": server-side or otherwise out of scope for the
|
||||||
|
// SSH-in-browser / fetch-in-browser use case.
|
||||||
|
var Keep = []featuretags.FeatureTag{
|
||||||
|
"c2n", // control-to-node mechanism the control client invokes
|
||||||
|
"dns", // MagicDNS resolution in-process
|
||||||
|
"health", // ipnstate/ipnlocal reference health warnables pervasively
|
||||||
|
"ipnbus", // notification bus for state/netmap callbacks
|
||||||
|
"logtail", // log upload (browser console + remote)
|
||||||
|
"netstack", // userspace networking; wasm has no kernel TUN
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
for _, ft := range Omit {
|
for _, ft := range Keep {
|
||||||
if _, ok := featuretags.Features[ft]; !ok {
|
if _, ok := featuretags.Features[ft]; !ok {
|
||||||
panic(fmt.Sprintf("wasmbuild.Omit references unknown feature tag %q; "+
|
panic(fmt.Sprintf("wasmbuild.Keep references unknown feature tag %q; "+
|
||||||
"did feature/featuretags rename or remove it?", ft))
|
"did feature/featuretags rename or remove it?", ft))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,22 +103,25 @@ type BuildInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tags returns the joined -tags value for the wasm build: [baseTags]
|
// Tags returns the joined -tags value for the wasm build: [baseTags]
|
||||||
// plus a ts_omit_<feature> for every entry in [Omit].
|
// plus a ts_omit_<feature> for every entry in [featuretags.Features]
|
||||||
|
// that is not transitively required by [Keep].
|
||||||
//
|
//
|
||||||
// The result is sorted so that the same source tree always produces
|
// The result is sorted so that the same source tree always produces
|
||||||
// the same string (and therefore the same wasm bytes, given identical
|
// the same string (and therefore the same wasm bytes, given identical
|
||||||
// inputs to `go build`).
|
// inputs to `go build`).
|
||||||
func Tags() string {
|
func Tags() string {
|
||||||
omit := map[featuretags.FeatureTag]bool{}
|
keep := map[featuretags.FeatureTag]bool{}
|
||||||
for _, ft := range Omit {
|
for _, ft := range Keep {
|
||||||
omit[ft] = true
|
for dep := range featuretags.Requires(ft) {
|
||||||
|
keep[dep] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tags := slices.Clone(baseTags)
|
tags := slices.Clone(baseTags)
|
||||||
for ft := range featuretags.Features {
|
for ft := range featuretags.Features {
|
||||||
if ft == "" || !ft.IsOmittable() {
|
if ft == "" || !ft.IsOmittable() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if omit[ft] {
|
if !keep[ft] {
|
||||||
tags = append(tags, ft.OmitTag())
|
tags = append(tags, ft.OmitTag())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,12 +304,6 @@ func (c *Auto) restartMap() {
|
|||||||
c.updateControl()
|
c.updateControl()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartMap cancels the existing map poll and starts a fresh streaming one,
|
|
||||||
// forcing the control server to send a new full netmap response.
|
|
||||||
func (c *Auto) RestartMap() {
|
|
||||||
c.restartMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Auto) authRoutine() {
|
func (c *Auto) authRoutine() {
|
||||||
defer close(c.authDone)
|
defer close(c.authDone)
|
||||||
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
|
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
|
||||||
|
|||||||
@@ -337,7 +337,6 @@ type LocalBackend struct {
|
|||||||
capTailnetLock bool // whether netMap contains the tailnet lock capability
|
capTailnetLock bool // whether netMap contains the tailnet lock capability
|
||||||
// hostinfo is mutated in-place while mu is held.
|
// hostinfo is mutated in-place while mu is held.
|
||||||
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
|
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
|
||||||
explicitServices []tailcfg.Service // services set explicitly via SetExplicitServices; always uploaded
|
|
||||||
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
|
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
|
||||||
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
|
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
|
||||||
engineStatus ipn.EngineStatus
|
engineStatus ipn.EngineStatus
|
||||||
@@ -1763,13 +1762,6 @@ func (b *LocalBackend) PeerCaps(src netip.Addr) tailcfg.PeerCapMap {
|
|||||||
return b.currentNode().PeerCaps(src)
|
return b.currentNode().PeerCaps(src)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerCapsIncludingUnsigned is like [LocalBackend.PeerCaps] but does not deny
|
|
||||||
// capabilities to peers with UnsignedPeerAPIOnly set. It exists only for the
|
|
||||||
// Funnel ingress path; see [nodeBackend.PeerCapsIncludingUnsigned].
|
|
||||||
func (b *LocalBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
|
|
||||||
return b.currentNode().PeerCapsIncludingUnsigned(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PeerCapsForIP returns the capabilities that remote src IP has when
|
// PeerCapsForIP returns the capabilities that remote src IP has when
|
||||||
// talking to the given destination IP on this node.
|
// talking to the given destination IP on this node.
|
||||||
func (b *LocalBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
|
func (b *LocalBackend) PeerCapsForIP(src, dst netip.Addr) tailcfg.PeerCapMap {
|
||||||
@@ -5689,30 +5681,6 @@ func (b *LocalBackend) setPortlistServices(sl []tailcfg.Service) {
|
|||||||
b.doSetHostinfoFilterServices()
|
b.doSetHostinfoFilterServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExplicitServices sets the services this node advertises on the netmap.
|
|
||||||
// Unlike the OS port-scan path (setPortlistServices), services set here are
|
|
||||||
// always uploaded to the control server regardless of the ShouldUploadServices
|
|
||||||
// hook — suitable for environments like browser WASM where OS port scanning is
|
|
||||||
// unavailable and services are declared programmatically.
|
|
||||||
func (b *LocalBackend) SetExplicitServices(sl []tailcfg.Service) {
|
|
||||||
b.mu.Lock()
|
|
||||||
if b.hostinfo == nil {
|
|
||||||
b.hostinfo = new(tailcfg.Hostinfo)
|
|
||||||
}
|
|
||||||
b.hostinfo.Services = sl
|
|
||||||
b.explicitServices = sl
|
|
||||||
ccAuto := b.ccAuto
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
b.doSetHostinfoFilterServices()
|
|
||||||
// Restart the streaming map poll so the control server sends back a fresh
|
|
||||||
// netmap that includes our updated services in SelfNode, and so peers
|
|
||||||
// receive the update promptly via the control server's push.
|
|
||||||
if ccAuto != nil {
|
|
||||||
ccAuto.RestartMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
|
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
|
||||||
// possibly after mangling the given hostinfo.
|
// possibly after mangling the given hostinfo.
|
||||||
//
|
//
|
||||||
@@ -5757,10 +5725,8 @@ func (b *LocalBackend) hostInfoWithServicesLocked() *tailcfg.Hostinfo {
|
|||||||
// Make a shallow copy of hostinfo so we can mutate
|
// Make a shallow copy of hostinfo so we can mutate
|
||||||
// at the Service field.
|
// at the Service field.
|
||||||
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
|
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
|
||||||
if len(b.explicitServices) == 0 {
|
|
||||||
hi.Services = []tailcfg.Service{}
|
hi.Services = []tailcfg.Service{}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Don't mutate hi.Service's underlying array. Append to
|
// Don't mutate hi.Service's underlying array. Append to
|
||||||
// the slice with no free capacity.
|
// the slice with no free capacity.
|
||||||
|
|||||||
@@ -432,32 +432,10 @@ func (nb *nodeBackend) srcIsUnsignedPeerLocked(src netip.Addr) bool {
|
|||||||
return ok && n.UnsignedPeerAPIOnly()
|
return ok && n.UnsignedPeerAPIOnly()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerCapsIncludingUnsigned is like [nodeBackend.PeerCaps] but does not deny
|
|
||||||
// capabilities to peers with UnsignedPeerAPIOnly set.
|
|
||||||
//
|
|
||||||
// Funnel ingress relays are delivered as UnsignedPeerAPIOnly nodes: per the
|
|
||||||
// docs on [tailcfg.Node.UnsignedPeerAPIOnly] they get no network access at all
|
|
||||||
// and exist solely to reach this node's peerapi. The ingress endpoint they need
|
|
||||||
// is gated on [tailcfg.PeerCapabilityIngress], so denying them capabilities
|
|
||||||
// wholesale — as peerCapsLocked does upstream as of 0eb38dc2e — makes Funnel
|
|
||||||
// impossible. Callers must therefore be limited to the ingress path.
|
|
||||||
//
|
|
||||||
// This is a fork-local patch; drop it once upstream restores Funnel.
|
|
||||||
// See webnet/tailscale#16.
|
|
||||||
func (nb *nodeBackend) PeerCapsIncludingUnsigned(src netip.Addr) tailcfg.PeerCapMap {
|
|
||||||
nb.mu.Lock()
|
|
||||||
defer nb.mu.Unlock()
|
|
||||||
return nb.peerCapsIgnoringSignatureLocked(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
|
func (nb *nodeBackend) peerCapsLocked(src netip.Addr) tailcfg.PeerCapMap {
|
||||||
if nb.srcIsUnsignedPeerLocked(src) {
|
if nb.srcIsUnsignedPeerLocked(src) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return nb.peerCapsIgnoringSignatureLocked(src)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (nb *nodeBackend) peerCapsIgnoringSignatureLocked(src netip.Addr) tailcfg.PeerCapMap {
|
|
||||||
if nb.netMap == nil {
|
if nb.netMap == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -592,14 +592,8 @@ func (h *peerAPIHandler) canDebug() bool {
|
|||||||
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
|
var allowSelfIngress = envknob.RegisterBool("TS_ALLOW_SELF_INGRESS")
|
||||||
|
|
||||||
// canIngress reports whether h can send ingress requests to this node.
|
// canIngress reports whether h can send ingress requests to this node.
|
||||||
//
|
|
||||||
// The ingress cap is resolved without the unsigned-peer denial that
|
|
||||||
// [nodeBackend.PeerCaps] applies, because Funnel ingress relays are by design
|
|
||||||
// UnsignedPeerAPIOnly nodes whose only permitted action is this endpoint.
|
|
||||||
// See [nodeBackend.PeerCapsIncludingUnsigned].
|
|
||||||
func (h *peerAPIHandler) canIngress() bool {
|
func (h *peerAPIHandler) canIngress() bool {
|
||||||
caps := h.ps.b.PeerCapsIncludingUnsigned(h.remoteAddr.Addr())
|
return h.peerHasCap(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
|
||||||
return caps.HasCapability(tailcfg.PeerCapabilityIngress) || (allowSelfIngress() && h.isSelf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
|
func (h *peerAPIHandler) peerHasCap(wantCap tailcfg.PeerCapability) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user