feat: add incus terminal

This commit is contained in:
Khairul Hidayat 2024-11-07 04:56:19 +07:00
parent 2adde048b0
commit ab9b3368d1
7 changed files with 302 additions and 21 deletions

View File

@ -6,37 +6,40 @@ import InteractiveSession, {
} from "@/components/containers/interactive-session"; } from "@/components/containers/interactive-session";
import PagerView from "@/components/ui/pager-view"; import PagerView from "@/components/ui/pager-view";
let nextSession = 1;
type Session = InteractiveSessionProps & { id: string }; type Session = InteractiveSessionProps & { id: string };
const HomePage = () => { const HomePage = () => {
const [sessions, setSessions] = useState<Session[]>([ const [sessions, setSessions] = useState<Session[]>([
{ {
id: "1", id: "1",
type: "ssh", type: "incus",
params: { serverId: "1" }, params: { client: "xtermjs", serverId: "1", shell: "bash" },
},
{
id: "2",
type: "pve",
params: { client: "vnc", serverId: "2" },
},
{
id: "3",
type: "pve",
params: { client: "xtermjs", serverId: "3" },
}, },
// {
// id: "1",
// type: "ssh",
// params: { serverId: "1" },
// },
// {
// id: "2",
// type: "pve",
// params: { client: "vnc", serverId: "2" },
// },
// {
// id: "3",
// type: "pve",
// params: { client: "xtermjs", serverId: "3" },
// },
]); ]);
const [curSession, setSession] = useState(0); const [curSession, setSession] = useState(0);
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Stack.Screen options={{ title: "Home" }} /> <Stack.Screen options={{ title: "Home", headerShown: false }} />
<ScrollView <ScrollView
horizontal horizontal
style={{ flexGrow: 0 }} style={{ flexGrow: 0, backgroundColor: "#111" }}
contentContainerStyle={{ flexDirection: "row", gap: 8 }} contentContainerStyle={{ flexDirection: "row", gap: 8 }}
> >
{sessions.map((session, idx) => ( {sessions.map((session, idx) => (
@ -46,7 +49,7 @@ const HomePage = () => {
> >
<Button <Button
title={"Session " + session.id} title={"Session " + session.id}
color="#222" color="#333"
onPress={() => setSession(idx)} onPress={() => setSession(idx)}
/> />
<Button <Button

View File

@ -18,9 +18,22 @@ type PVESessionProps = {
}; };
}; };
export type InteractiveSessionProps = SSHSessionProps | PVESessionProps; type IncusSessionProps = {
type: "incus";
params: {
client: "vnc" | "xtermjs";
serverId: string;
shell?: string;
};
};
export type InteractiveSessionProps =
| SSHSessionProps
| PVESessionProps
| IncusSessionProps;
const InteractiveSession = ({ type, params }: InteractiveSessionProps) => { const InteractiveSession = ({ type, params }: InteractiveSessionProps) => {
let url = "";
const query = new URLSearchParams({ const query = new URLSearchParams({
...params, ...params,
}); });
@ -30,7 +43,15 @@ const InteractiveSession = ({ type, params }: InteractiveSessionProps) => {
return <Terminal wsUrl={`${BASE_WS_URL}/ws/ssh?${query}`} />; return <Terminal wsUrl={`${BASE_WS_URL}/ws/ssh?${query}`} />;
case "pve": case "pve":
const url = `${BASE_WS_URL}/ws/pve?${query}`; url = `${BASE_WS_URL}/ws/pve?${query}`;
return params.client === "vnc" ? (
<VNCViewer url={url} />
) : (
<Terminal wsUrl={url} />
);
case "incus":
url = `${BASE_WS_URL}/ws/incus?${query}`;
return params.client === "vnc" ? ( return params.client === "vnc" ? (
<VNCViewer url={url} /> <VNCViewer url={url} />
) : ( ) : (

View File

@ -58,7 +58,9 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => {
function resizeTerminal() { function resizeTerminal() {
const { cols, rows } = xterm; const { cols, rows } = xterm;
ws.send(`\x01${cols},${rows}`); if (ws.readyState === ws.OPEN) {
ws.send(`\x01${cols},${rows}`);
}
} }
function onResize() { function onResize() {

37
server/lib/crypto.go Normal file
View File

@ -0,0 +1,37 @@
package lib
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
)
func LoadClientCertificate(clientCert string, clientKey string) (*tls.Certificate, error) {
// Client certificate
ccb, _ := pem.Decode([]byte(clientCert))
if ccb == nil {
return nil, fmt.Errorf("failed to parse client certificate")
}
cert, err := x509.ParseCertificate(ccb.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse client certificate: %v", err)
}
// Client key
ckb, _ := pem.Decode([]byte(clientKey))
if ckb == nil {
return nil, fmt.Errorf("failed to parse client key")
}
key, err := x509.ParsePKCS8PrivateKey(ckb.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse client key: %v", err)
}
return &tls.Certificate{
Certificate: [][]byte{cert.Raw},
PrivateKey: key,
}, nil
}

114
server/lib/incus.go Normal file
View File

@ -0,0 +1,114 @@
package lib
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
)
type IncusServer struct {
HostName string
Port int
ClientCert string
ClientKey string
}
type IncusFetchConfig struct {
Body map[string]interface{}
}
func (i *IncusServer) GetCertificate() (*tls.Certificate, error) {
return LoadClientCertificate(i.ClientCert, i.ClientKey)
}
func (i *IncusServer) Fetch(method string, url string, cfg *IncusFetchConfig) ([]byte, error) {
var body io.Reader
if cfg != nil && cfg.Body != nil {
json, _ := json.Marshal(cfg.Body)
body = bytes.NewBuffer(json)
}
reqUrl := fmt.Sprintf("https://%s:%d%s", i.HostName, i.Port, url)
req, _ := http.NewRequest(method, reqUrl, body)
if body != nil {
req.Header.Add("Content-Type", "application/json")
}
clientCert, err := i.GetCertificate()
if err != nil {
return nil, err
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
Certificates: []tls.Certificate{*clientCert},
},
}
client := &http.Client{
Transport: tr,
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("request failed with status code %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
type IncusInstanceExecRes struct {
ID string
Operation string
Control string
Secret string
}
func (i *IncusServer) InstanceExec(instance string, command []string, interactive bool) (*IncusInstanceExecRes, error) {
url := fmt.Sprintf("/1.0/instances/%s/exec?project=default", instance)
body, err := i.Fetch("POST", url, &IncusFetchConfig{
Body: map[string]interface{}{
"command": command,
"interactive": interactive,
"wait-for-websocket": true,
},
})
if err != nil {
log.Println(err)
return nil, err
}
var res struct {
Operation string `json:"operation"`
Metadata struct {
ID string `json:"id"`
Metadata struct {
Fds map[string]string `json:"fds"`
} `json:"metadata"`
} `json:"metadata"`
}
if err := json.Unmarshal(body, &res); err != nil {
return nil, err
}
control := res.Metadata.Metadata.Fds["control"]
secret := res.Metadata.Metadata.Fds["0"]
return &IncusInstanceExecRes{
ID: res.Metadata.ID,
Operation: res.Operation,
Control: control,
Secret: secret,
}, nil
}

View File

@ -0,0 +1,90 @@
package lib
import (
"crypto/tls"
"encoding/json"
"fmt"
"log"
"strings"
fastWs "github.com/fasthttp/websocket"
"github.com/gofiber/contrib/websocket"
)
func NewIncusWebsocketSession(c *websocket.Conn, incus *IncusServer) error {
exec, err := incus.InstanceExec("test", []string{"/bin/sh"}, true)
if err != nil {
return err
}
clientCert, err := incus.GetCertificate()
if err != nil {
return err
}
dialer := fastWs.Dialer{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
Certificates: []tls.Certificate{*clientCert},
},
}
controlUrl := fmt.Sprintf("wss://%s:%d%s/websocket?secret=%s", incus.HostName, incus.Port, exec.Operation, exec.Control)
controlWs, _, err := dialer.Dial(controlUrl, nil)
if err != nil {
return err
}
defer controlWs.Close()
ttyUrl := fmt.Sprintf("wss://%s:%d%s/websocket?secret=%s", incus.HostName, incus.Port, exec.Operation, exec.Secret)
ttyWs, _, err := dialer.Dial(ttyUrl, nil)
if err != nil {
return err
}
defer ttyWs.Close()
go func() {
for {
_, msg, err := c.ReadMessage()
if err != nil {
log.Println("Error reading from client:", err)
break
}
if strings.HasPrefix(string(msg), "\x01") {
parts := strings.Split(string(msg[1:]), ",")
if len(parts) == 2 {
resizeCmd, _ := json.Marshal(map[string]interface{}{
"command": "window-resize",
"args": map[string]string{
"width": parts[0],
"height": parts[1],
},
})
controlWs.WriteMessage(websocket.BinaryMessage, resizeCmd)
}
continue
}
if err = ttyWs.WriteMessage(websocket.BinaryMessage, msg); err != nil {
log.Println("Error writing to Incus:", err)
break
}
}
}()
for {
t, msg, err := ttyWs.ReadMessage()
if err != nil {
log.Println("Error reading from Incus:", err)
break
}
if err = c.WriteMessage(t, msg); err != nil {
log.Println("Error writing to client:", err)
break
}
}
return nil
}

View File

@ -14,7 +14,7 @@ func main() {
app := fiber.New() app := fiber.New()
var pve = &lib.PVEServer{ var pve = &lib.PVEServer{
HostName: "pve", HostName: "10.0.0.1",
Port: 8006, Port: 8006,
Username: os.Getenv("PVE_USERNAME"), Username: os.Getenv("PVE_USERNAME"),
Password: os.Getenv("PVE_PASSWORD"), Password: os.Getenv("PVE_PASSWORD"),
@ -75,5 +75,19 @@ func main() {
c.WriteMessage(websocket.TextMessage, []byte(err.Error())) c.WriteMessage(websocket.TextMessage, []byte(err.Error()))
} }
})) }))
app.Get("/ws/incus", websocket.New(func(c *websocket.Conn) {
incus := &lib.IncusServer{
HostName: "100.64.0.3",
Port: 8443,
ClientCert: "",
ClientKey: "",
}
if err := lib.NewIncusWebsocketSession(c, incus); err != nil {
c.WriteMessage(websocket.TextMessage, []byte(err.Error()))
}
}))
app.Listen(":3000") app.Listen(":3000")
} }