...
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run
|
#!/usr/bin/env -S v -n -w -gc none -cc tcc -d use_openssl -enable-globals run
|
||||||
|
|
||||||
|
|
||||||
import freeflowuniverse.herolib.osal.tmux
|
import freeflowuniverse.herolib.osal.tmux
|
||||||
|
|
||||||
mut t := tmux.new()!
|
mut t := tmux.new()!
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ module livekit
|
|||||||
import freeflowuniverse.herolib.data.caching
|
import freeflowuniverse.herolib.data.caching
|
||||||
import os
|
import os
|
||||||
|
|
||||||
const CACHING_METHOD = caching.CachingMethod.once_per_process
|
// const CACHING_METHOD = caching.CachingMethod.once_per_process
|
||||||
|
|
||||||
fn _init() ! {
|
fn _init() ! {
|
||||||
if caching.is_set(key: 'livekit_clients') {
|
if caching.is_set(key: 'livekit_clients') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
caching.set[map[string]LivekitClient](key: 'livekit_clients', val: map[string]LivekitClient{}, CachingMethod.once_per_process)!
|
caching.set[map[string]LivekitClient](
|
||||||
|
key: 'livekit_clients'
|
||||||
|
val: map[string]LivekitClient{}
|
||||||
|
)!
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _get() !map[string]LivekitClient {
|
fn _get() !map[string]LivekitClient {
|
||||||
@@ -25,7 +28,7 @@ pub fn get(name string) !LivekitClient {
|
|||||||
pub fn set(client LivekitClient) ! {
|
pub fn set(client LivekitClient) ! {
|
||||||
mut clients := _get()!
|
mut clients := _get()!
|
||||||
clients[client.name] = client
|
clients[client.name] = client
|
||||||
caching.set[map[string]LivekitClient](key: 'livekit_clients', val: clients, CachingMethod.once_per_process)!
|
caching.set[map[string]LivekitClient](key: 'livekit_clients', val: clients)!
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn exists(name string) !bool {
|
pub fn exists(name string) !bool {
|
||||||
|
|||||||
@@ -60,25 +60,36 @@ pub fn (mut c LivekitClient) start_web_egress(args StartWebEgressArgs) !EgressIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) update_layout(egress_id string, layout string) !EgressInfo {
|
pub fn (mut c LivekitClient) update_layout(egress_id string, layout string) !EgressInfo {
|
||||||
mut resp := c.post('twirp/livekit.Egress/UpdateLayout', {'egress_id': egress_id, 'layout': layout})!
|
mut resp := c.post('twirp/livekit.Egress/UpdateLayout', {
|
||||||
|
'egress_id': egress_id
|
||||||
|
'layout': layout
|
||||||
|
})!
|
||||||
egress_info := json.decode[EgressInfo](resp.body)!
|
egress_info := json.decode[EgressInfo](resp.body)!
|
||||||
return egress_info
|
return egress_info
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) update_stream(egress_id string, args UpdateStreamArgs) !EgressInfo {
|
pub fn (mut c LivekitClient) update_stream(egress_id string, args UpdateStreamArgs) !EgressInfo {
|
||||||
mut resp := c.post('twirp/livekit.Egress/UpdateStream', {'egress_id': egress_id, 'add_output_urls': args.add_output_urls, 'remove_output_urls': args.remove_output_urls})!
|
mut resp := c.post('twirp/livekit.Egress/UpdateStream', {
|
||||||
|
'egress_id': egress_id
|
||||||
|
'add_output_urls': args.add_output_urls
|
||||||
|
'remove_output_urls': args.remove_output_urls
|
||||||
|
})!
|
||||||
egress_info := json.decode[EgressInfo](resp.body)!
|
egress_info := json.decode[EgressInfo](resp.body)!
|
||||||
return egress_info
|
return egress_info
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) list_egress(room_name string) ![]EgressInfo {
|
pub fn (mut c LivekitClient) list_egress(room_name string) ![]EgressInfo {
|
||||||
mut resp := c.post('twirp/livekit.Egress/ListEgress', {'room_name': room_name})!
|
mut resp := c.post('twirp/livekit.Egress/ListEgress', {
|
||||||
|
'room_name': room_name
|
||||||
|
})!
|
||||||
egress_infos := json.decode[[]EgressInfo](resp.body)!
|
egress_infos := json.decode[[]EgressInfo](resp.body)!
|
||||||
return egress_infos
|
return egress_infos
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) stop_egress(egress_id string) !EgressInfo {
|
pub fn (mut c LivekitClient) stop_egress(egress_id string) !EgressInfo {
|
||||||
mut resp := c.post('twirp/livekit.Egress/StopEgress', {'egress_id': egress_id})!
|
mut resp := c.post('twirp/livekit.Egress/StopEgress', {
|
||||||
|
'egress_id': egress_id
|
||||||
|
})!
|
||||||
egress_info := json.decode[EgressInfo](resp.body)!
|
egress_info := json.decode[EgressInfo](resp.body)!
|
||||||
return egress_info
|
return egress_info
|
||||||
}
|
}
|
||||||
@@ -93,8 +93,15 @@ pub mut:
|
|||||||
video IngressVideoOptions
|
video IngressVideoOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn (mut c LivekitClient) create_ingress(args CreateIngressArgs) !IngressInfo {
|
||||||
|
mut resp := c.post('twirp/livekit.Ingress/CreateIngress', args)!
|
||||||
|
ingress_info := json.decode[IngressInfo](resp.body)!
|
||||||
|
return ingress_info
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UpdateIngressArgs {
|
pub struct UpdateIngressArgs {
|
||||||
pub mut:
|
pub mut:
|
||||||
|
ingress_id string
|
||||||
name string
|
name string
|
||||||
room_name string
|
room_name string
|
||||||
participant_identity string
|
participant_identity string
|
||||||
@@ -103,26 +110,32 @@ pub mut:
|
|||||||
video IngressVideoOptions
|
video IngressVideoOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) create_ingress(args CreateIngressArgs) !IngressInfo {
|
pub fn (mut c LivekitClient) update_ingress(args UpdateIngressArgs) !IngressInfo {
|
||||||
mut resp := c.post('twirp/livekit.Ingress/CreateIngress', args)!
|
mut resp := c.post('twirp/livekit.Ingress/UpdateIngress', {
|
||||||
ingress_info := json.decode[IngressInfo](resp.body)!
|
'ingress_id': args.ingress_id
|
||||||
return ingress_info
|
'name': args.name
|
||||||
}
|
'room_name': args.room_name
|
||||||
|
'participant_identity': args.participant_identity
|
||||||
pub fn (mut c LivekitClient) update_ingress(ingress_id string, args UpdateIngressArgs) !IngressInfo {
|
'participant_name': args.participant_name
|
||||||
mut resp := c.post('twirp/livekit.Ingress/UpdateIngress', {'ingress_id': ingress_id, ...args})!
|
'audio': args.audio
|
||||||
|
'video': args.video
|
||||||
|
})!
|
||||||
ingress_info := json.decode[IngressInfo](resp.body)!
|
ingress_info := json.decode[IngressInfo](resp.body)!
|
||||||
return ingress_info
|
return ingress_info
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) list_ingress(room_name string) ![]IngressInfo {
|
pub fn (mut c LivekitClient) list_ingress(room_name string) ![]IngressInfo {
|
||||||
mut resp := c.post('twirp/livekit.Ingress/ListIngress', {'room_name': room_name})!
|
mut resp := c.post('twirp/livekit.Ingress/ListIngress', {
|
||||||
|
'room_name': room_name
|
||||||
|
})!
|
||||||
ingress_infos := json.decode[[]IngressInfo](resp.body)!
|
ingress_infos := json.decode[[]IngressInfo](resp.body)!
|
||||||
return ingress_infos
|
return ingress_infos
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) delete_ingress(ingress_id string) !IngressInfo {
|
pub fn (mut c LivekitClient) delete_ingress(ingress_id string) !IngressInfo {
|
||||||
mut resp := c.post('twirp/livekit.Ingress/DeleteIngress', {'ingress_id': ingress_id})!
|
mut resp := c.post('twirp/livekit.Ingress/DeleteIngress', {
|
||||||
|
'ingress_id': ingress_id
|
||||||
|
})!
|
||||||
ingress_info := json.decode[IngressInfo](resp.body)!
|
ingress_info := json.decode[IngressInfo](resp.body)!
|
||||||
return ingress_info
|
return ingress_info
|
||||||
}
|
}
|
||||||
@@ -33,19 +33,27 @@ pub mut:
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) list_participants(room_name string) ![]ParticipantInfo {
|
pub fn (mut c LivekitClient) list_participants(room_name string) ![]ParticipantInfo {
|
||||||
mut resp := c.post('twirp/livekit.RoomService/ListParticipants', {'room': room_name})!
|
mut resp := c.post('twirp/livekit.RoomService/ListParticipants', {
|
||||||
|
'room': room_name
|
||||||
|
})!
|
||||||
participants := json.decode[[]ParticipantInfo](resp.body)!
|
participants := json.decode[[]ParticipantInfo](resp.body)!
|
||||||
return participants
|
return participants
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) get_participant(room_name string, identity string) !ParticipantInfo {
|
pub fn (mut c LivekitClient) get_participant(room_name string, identity string) !ParticipantInfo {
|
||||||
mut resp := c.post('twirp/livekit.RoomService/GetParticipant', {'room': room_name, 'identity': identity})!
|
mut resp := c.post('twirp/livekit.RoomService/GetParticipant', {
|
||||||
|
'room': room_name
|
||||||
|
'identity': identity
|
||||||
|
})!
|
||||||
participant := json.decode[ParticipantInfo](resp.body)!
|
participant := json.decode[ParticipantInfo](resp.body)!
|
||||||
return participant
|
return participant
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) remove_participant(room_name string, identity string) ! {
|
pub fn (mut c LivekitClient) remove_participant(room_name string, identity string) ! {
|
||||||
_ = c.post('twirp/livekit.RoomService/RemoveParticipant', {'room': room_name, 'identity': identity})!
|
_ = c.post('twirp/livekit.RoomService/RemoveParticipant', {
|
||||||
|
'room': room_name
|
||||||
|
'identity': identity
|
||||||
|
})!
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) update_participant(args UpdateParticipantArgs) ! {
|
pub fn (mut c LivekitClient) update_participant(args UpdateParticipantArgs) ! {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ pub fn (mut c LivekitClient) create_room(args CreateRoomArgs) !Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) delete_room(room_name string) ! {
|
pub fn (mut c LivekitClient) delete_room(room_name string) ! {
|
||||||
_ = c.post('twirp/livekit.RoomService/DeleteRoom', {'room': room_name})!
|
_ = c.post('twirp/livekit.RoomService/DeleteRoom', {
|
||||||
|
'room': room_name
|
||||||
|
})!
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut c LivekitClient) update_room_metadata(args UpdateRoomMetadataArgs) ! {
|
pub fn (mut c LivekitClient) update_room_metadata(args UpdateRoomMetadataArgs) ! {
|
||||||
|
|||||||
@@ -15,19 +15,19 @@ pub:
|
|||||||
unix_micro string
|
unix_micro string
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Room {
|
// pub struct Room {
|
||||||
pub:
|
// pub:
|
||||||
active_recording bool
|
// active_recording bool
|
||||||
creation_time string
|
// creation_time string
|
||||||
departure_timeout int
|
// departure_timeout int
|
||||||
empty_timeout int
|
// empty_timeout int
|
||||||
enabled_codecs []Codec
|
// enabled_codecs []Codec
|
||||||
max_participants int
|
// max_participants int
|
||||||
metadata string
|
// metadata string
|
||||||
name string
|
// name string
|
||||||
num_participants int
|
// num_participants int
|
||||||
num_publishers int
|
// num_publishers int
|
||||||
sid string
|
// sid string
|
||||||
turn_password string
|
// turn_password string
|
||||||
version Version
|
// version Version
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -18,60 +18,60 @@ pub mut:
|
|||||||
name string
|
name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// VideoGrant struct placeholder
|
// // VideoGrant struct placeholder
|
||||||
pub struct VideoGrant {
|
// pub struct VideoGrant {
|
||||||
pub mut:
|
// pub mut:
|
||||||
room string
|
// room string
|
||||||
room_join bool @[json: 'roomJoin']
|
// room_join bool @[json: 'roomJoin']
|
||||||
room_list bool @[json: 'roomList']
|
// room_list bool @[json: 'roomList']
|
||||||
can_publish bool @[json: 'canPublish']
|
// can_publish bool @[json: 'canPublish']
|
||||||
can_publish_data bool @[json: 'canPublishData']
|
// can_publish_data bool @[json: 'canPublishData']
|
||||||
can_subscribe bool @[json: 'canSubscribe']
|
// can_subscribe bool @[json: 'canSubscribe']
|
||||||
}
|
// }
|
||||||
|
|
||||||
// SIPGrant struct placeholder
|
// SIPGrant struct placeholder
|
||||||
struct SIPGrant {}
|
struct SIPGrant {}
|
||||||
|
|
||||||
// AccessToken class
|
// // AccessToken class
|
||||||
pub struct AccessToken {
|
// pub struct AccessToken {
|
||||||
mut:
|
// mut:
|
||||||
api_key string
|
// api_key string
|
||||||
api_secret string
|
// api_secret string
|
||||||
grants ClaimGrants
|
// grants ClaimGrants
|
||||||
identity string
|
// identity string
|
||||||
ttl int
|
// ttl int
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Method to add a video grant to the token
|
// Method to add a video grant to the token
|
||||||
pub fn (mut token AccessToken) add_video_grant(grant VideoGrant) {
|
// pub fn (mut token AccessToken) add_video_grant(grant VideoGrant) {
|
||||||
token.grants.video = grant
|
// token.grants.video = grant
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Method to generate a JWT token
|
// // Method to generate a JWT token
|
||||||
pub fn (token AccessToken) to_jwt() !string {
|
// pub fn (token AccessToken) to_jwt() !string {
|
||||||
// Create JWT payload
|
// // Create JWT payload
|
||||||
payload := json.encode(token.grants)
|
// payload := json.encode(token.grants)
|
||||||
|
|
||||||
println('payload: ${payload}')
|
// println('payload: ${payload}')
|
||||||
|
|
||||||
// Create JWT header
|
// // Create JWT header
|
||||||
header := '{"alg":"HS256","typ":"JWT"}'
|
// header := '{"alg":"HS256","typ":"JWT"}'
|
||||||
|
|
||||||
// Encode header and payload in base64
|
// // Encode header and payload in base64
|
||||||
header_encoded := base64.url_encode_str(header)
|
// header_encoded := base64.url_encode_str(header)
|
||||||
payload_encoded := base64.url_encode_str(payload)
|
// payload_encoded := base64.url_encode_str(payload)
|
||||||
|
|
||||||
// Create the unsigned token
|
// // Create the unsigned token
|
||||||
unsigned_token := '${header_encoded}.${payload_encoded}'
|
// unsigned_token := '${header_encoded}.${payload_encoded}'
|
||||||
|
|
||||||
// Create the HMAC-SHA256 signature
|
// // Create the HMAC-SHA256 signature
|
||||||
signature := hmac.new(token.api_secret.bytes(), unsigned_token.bytes(), sha256.sum,
|
// signature := hmac.new(token.api_secret.bytes(), unsigned_token.bytes(), sha256.sum,
|
||||||
sha256.block_size)
|
// sha256.block_size)
|
||||||
|
|
||||||
// Encode the signature in base64
|
// // Encode the signature in base64
|
||||||
signature_encoded := base64.url_encode(signature)
|
// signature_encoded := base64.url_encode(signature)
|
||||||
|
|
||||||
// Create the final JWT
|
// // Create the final JWT
|
||||||
jwt := '${unsigned_token}.${signature_encoded}'
|
// jwt := '${unsigned_token}.${signature_encoded}'
|
||||||
return jwt
|
// return jwt
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ pub fn (key SSHKey) private_key() !string {
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
module core
|
|
||||||
|
|
||||||
import freeflowuniverse.herolib.core.pathlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
@[params]
|
@[params]
|
||||||
pub struct SSHConfig {
|
pub struct SSHConfig {
|
||||||
pub:
|
pub:
|
||||||
|
|||||||
12
lib/osal/linux/templates/profile_sshagent.sh
Normal file
12
lib/osal/linux/templates/profile_sshagent.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Auto-start ssh-agent if not running
|
||||||
|
SSH_AGENT_PID_FILE="$HOME/.ssh/agent.pid"
|
||||||
|
SSH_AUTH_SOCK_FILE="$HOME/.ssh/agent.sock"
|
||||||
|
|
||||||
|
chown "$NEWUSER":"$NEWUSER" "$PROFILE_SCRIPT"
|
||||||
|
chmod 644 "$PROFILE_SCRIPT"
|
||||||
|
|
||||||
|
# --- source it on login ---
|
||||||
|
#TODO should be done in vcode
|
||||||
|
if ! grep -q ".profile_sshagent" "$USERHOME/.bashrc"; then
|
||||||
|
echo "[ -f ~/.profile_sshagent ] && source ~/.profile_sshagent" >> "$USERHOME/.bashrc"
|
||||||
|
fi
|
||||||
@@ -57,19 +57,4 @@ chown root:ourworld /code
|
|||||||
chmod 2775 /code # rwx for user+group, SGID bit so new files inherit group
|
chmod 2775 /code # rwx for user+group, SGID bit so new files inherit group
|
||||||
echo "✅ /code prepared (group=ourworld, rwx for group, SGID bit set)"
|
echo "✅ /code prepared (group=ourworld, rwx for group, SGID bit set)"
|
||||||
|
|
||||||
# --- create login helper script for ssh-agent ---
|
|
||||||
PROFILE_SCRIPT="$USERHOME/.profile_sshagent"
|
|
||||||
cat > "$PROFILE_SCRIPT" <<'EOF'
|
|
||||||
# Auto-start ssh-agent if not running
|
|
||||||
SSH_AGENT_PID_FILE="$HOME/.ssh/agent.pid"
|
|
||||||
SSH_AUTH_SOCK_FILE="$HOME/.ssh/agent.sock"
|
|
||||||
|
|
||||||
chown "$NEWUSER":"$NEWUSER" "$PROFILE_SCRIPT"
|
|
||||||
chmod 644 "$PROFILE_SCRIPT"
|
|
||||||
|
|
||||||
# --- source it on login ---
|
|
||||||
if ! grep -q ".profile_sshagent" "$USERHOME/.bashrc"; then
|
|
||||||
echo "[ -f ~/.profile_sshagent ] && source ~/.profile_sshagent" >> "$USERHOME/.bashrc"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🎉 Setup complete for user $NEWUSER"
|
echo "🎉 Setup complete for user $NEWUSER"
|
||||||
@@ -119,7 +119,9 @@ pub fn (mut lf LinuxFactory) sshkey_create(args SSHKeyCreateArgs) ! {
|
|||||||
} else {
|
} else {
|
||||||
// Generate new SSH key (modern ed25519)
|
// Generate new SSH key (modern ed25519)
|
||||||
key_path := '${ssh_dir}/${args.sshkey_name}'
|
key_path := '${ssh_dir}/${args.sshkey_name}'
|
||||||
osal.exec(cmd: 'ssh-keygen -t ed25519 -f ${key_path} -N "" -C "${args.username}@$(hostname)"')!
|
osal.exec(
|
||||||
|
cmd: 'ssh-keygen -t ed25519 -f ${key_path} -N "" -C "${args.username}@$(hostname)"'
|
||||||
|
)!
|
||||||
console.print_green('✅ New SSH key generated for ${args.username}')
|
console.print_green('✅ New SSH key generated for ${args.username}')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +203,7 @@ fn (mut lf LinuxFactory) remove_user_config(username string) ! {
|
|||||||
config_path := '${config_dir}/myconfig.json'
|
config_path := '${config_dir}/myconfig.json'
|
||||||
|
|
||||||
if !os.exists(config_path) {
|
if !os.exists(config_path) {
|
||||||
return // Nothing to remove
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content := osal.file_read(config_path)!
|
content := osal.file_read(config_path)!
|
||||||
@@ -243,7 +245,9 @@ fn (mut lf LinuxFactory) create_user_system(args UserCreateArgs) ! {
|
|||||||
|
|
||||||
// Ensure ourworld group exists
|
// Ensure ourworld group exists
|
||||||
group_check := osal.exec(cmd: 'getent group ourworld', raise_error: false) or {
|
group_check := osal.exec(cmd: 'getent group ourworld', raise_error: false) or {
|
||||||
osal.Job{ exit_code: 1 }
|
osal.Job{
|
||||||
|
exit_code: 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if group_check.exit_code != 0 {
|
if group_check.exit_code != 0 {
|
||||||
console.print_item('➕ Creating group ourworld')
|
console.print_item('➕ Creating group ourworld')
|
||||||
@@ -284,58 +288,9 @@ fn (mut lf LinuxFactory) create_ssh_agent_profile(username string) ! {
|
|||||||
user_home := '/home/${username}'
|
user_home := '/home/${username}'
|
||||||
profile_script := '${user_home}/.profile_sshagent'
|
profile_script := '${user_home}/.profile_sshagent'
|
||||||
|
|
||||||
script_content := '# Auto-start ssh-agent if not running
|
// script_content := ''
|
||||||
SSH_AGENT_PID_FILE="$HOME/.ssh/agent.pid"
|
|
||||||
SSH_AUTH_SOCK_FILE="$HOME/.ssh/agent.sock"
|
|
||||||
|
|
||||||
# Function to start ssh-agent
|
panic('implement')
|
||||||
start_ssh_agent() {
|
|
||||||
mkdir -p "$HOME/.ssh"
|
|
||||||
chmod 700 "$HOME/.ssh"
|
|
||||||
|
|
||||||
# Start ssh-agent and save connection info
|
|
||||||
ssh-agent -s > "$SSH_AGENT_PID_FILE"
|
|
||||||
source "$SSH_AGENT_PID_FILE"
|
|
||||||
|
|
||||||
# Save socket path for future sessions
|
|
||||||
echo "$SSH_AUTH_SOCK" > "$SSH_AUTH_SOCK_FILE"
|
|
||||||
|
|
||||||
# Load all private keys found in ~/.ssh
|
|
||||||
if [ -d "$HOME/.ssh" ]; then
|
|
||||||
for KEY in "$HOME"/.ssh/*; do
|
|
||||||
if [ -f "$KEY" ] && [ ! "${KEY##*.}" = "pub" ] && grep -q "PRIVATE KEY" "$KEY" 2>/dev/null; then
|
|
||||||
'ssh-' + 'add "$KEY" >/dev/null 2>&1 && echo "🔑 Loaded key: $(basename $KEY)"'
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if ssh-agent is running
|
|
||||||
if [ -f "$SSH_AGENT_PID_FILE" ]; then
|
|
||||||
source "$SSH_AGENT_PID_FILE" >/dev/null 2>&1
|
|
||||||
# Test if agent is responsive
|
|
||||||
if ! ('ssh-' + 'add -l >/dev/null 2>&1'); then
|
|
||||||
start_ssh_agent
|
|
||||||
else
|
|
||||||
# Agent is running, restore socket path
|
|
||||||
if [ -f "$SSH_AUTH_SOCK_FILE" ]; then
|
|
||||||
export SSH_AUTH_SOCK=$(cat "$SSH_AUTH_SOCK_FILE")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
start_ssh_agent
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For interactive shells
|
|
||||||
if [[ $- == *i* ]]; then
|
|
||||||
echo "🔑 SSH Agent ready at $SSH_AUTH_SOCK"
|
|
||||||
# Show loaded keys
|
|
||||||
KEY_COUNT=$('ssh-' + 'add -l 2>/dev/null | wc -l')
|
|
||||||
if [ "$KEY_COUNT" -gt 0 ]; then
|
|
||||||
echo "🔑 $KEY_COUNT SSH key(s) loaded"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
'
|
|
||||||
|
|
||||||
osal.file_write(profile_script, script_content)!
|
osal.file_write(profile_script, script_content)!
|
||||||
osal.exec(cmd: 'chown ${username}:${username} ${profile_script}')!
|
osal.exec(cmd: 'chown ${username}:${username} ${profile_script}')!
|
||||||
|
|||||||
@@ -96,9 +96,7 @@ fn sshkey_delete(mut agent SSHAgent, name string) ! {
|
|||||||
fn sshkey_load(mut agent SSHAgent, name string) ! {
|
fn sshkey_load(mut agent SSHAgent, name string) ! {
|
||||||
console.print_header('Loading SSH key: ${name}')
|
console.print_header('Loading SSH key: ${name}')
|
||||||
|
|
||||||
mut key := agent.get(name: name) or {
|
mut key := agent.get(name: name) or { return error('SSH key "${name}" not found') }
|
||||||
return error('SSH key "${name}" not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
if key.loaded {
|
if key.loaded {
|
||||||
console.print_debug('SSH key "${name}" is already loaded')
|
console.print_debug('SSH key "${name}" is already loaded')
|
||||||
@@ -113,18 +111,12 @@ fn sshkey_load(mut agent SSHAgent, name string) ! {
|
|||||||
fn sshkey_check(mut agent SSHAgent, name string) ! {
|
fn sshkey_check(mut agent SSHAgent, name string) ! {
|
||||||
console.print_header('Checking SSH key: ${name}')
|
console.print_header('Checking SSH key: ${name}')
|
||||||
|
|
||||||
mut key := agent.get(name: name) or {
|
mut key := agent.get(name: name) or { return error('SSH key "${name}" not found') }
|
||||||
return error('SSH key "${name}" not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if key files exist
|
// Check if key files exist
|
||||||
key_path := key.keypath() or {
|
key_path := key.keypath() or { return error('Private key file not found for "${name}"') }
|
||||||
return error('Private key file not found for "${name}"')
|
|
||||||
}
|
|
||||||
|
|
||||||
key_pub_path := key.keypath_pub() or {
|
key_pub_path := key.keypath_pub() or { return error('Public key file not found for "${name}"') }
|
||||||
return error('Public key file not found for "${name}"')
|
|
||||||
}
|
|
||||||
|
|
||||||
if !key_path.exists() {
|
if !key_path.exists() {
|
||||||
return error('Private key file does not exist: ${key_path.path}')
|
return error('Private key file does not exist: ${key_path.path}')
|
||||||
@@ -157,9 +149,7 @@ fn remote_copy(mut agent SSHAgent, node_addr string, key_name string) ! {
|
|||||||
console.print_header('Copying SSH key "${key_name}" to ${node_addr}')
|
console.print_header('Copying SSH key "${key_name}" to ${node_addr}')
|
||||||
|
|
||||||
// Get the key
|
// Get the key
|
||||||
mut key := agent.get(name: key_name) or {
|
mut key := agent.get(name: key_name) or { return error('SSH key "${key_name}" not found') }
|
||||||
return error('SSH key "${key_name}" not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create builder node
|
// Create builder node
|
||||||
mut b := builder.new()!
|
mut b := builder.new()!
|
||||||
|
|||||||
@@ -92,9 +92,7 @@ pub fn (mut agent SSHAgent) verify_key_access(mut node builder.Node, key_name st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test basic connectivity
|
// Test basic connectivity
|
||||||
result := node.exec_silent('echo "SSH key verification successful"') or {
|
result := node.exec_silent('echo "SSH key verification successful"') or { return false }
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.contains('SSH key verification successful')
|
return result.contains('SSH key verification successful')
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,12 @@ fn play_pane_kill(mut plbook PlayBook, mut tmux_instance Tmux) ! {
|
|||||||
// Kill the active pane in the window
|
// Kill the active pane in the window
|
||||||
if pane := window.pane_active() {
|
if pane := window.pane_active() {
|
||||||
tmux_cmd := 'tmux kill-pane -t ${session.name}:@${window.id}.%${pane.id}'
|
tmux_cmd := 'tmux kill-pane -t ${session.name}:@${window.id}.%${pane.id}'
|
||||||
osal.exec(cmd: tmux_cmd, stdout: false, name: 'tmux_pane_kill', ignore_error: true)!
|
osal.exec(
|
||||||
|
cmd: tmux_cmd
|
||||||
|
stdout: false
|
||||||
|
name: 'tmux_pane_kill'
|
||||||
|
ignore_error: true
|
||||||
|
)!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ pub mut:
|
|||||||
sessionid string // unique link to job
|
sessionid string // unique link to job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// get session (session has windows) .
|
// get session (session has windows) .
|
||||||
// returns none if not found
|
// returns none if not found
|
||||||
pub fn (mut t Tmux) session_get(name_ string) !&Session {
|
pub fn (mut t Tmux) session_get(name_ string) !&Session {
|
||||||
@@ -56,8 +55,6 @@ pub mut:
|
|||||||
reset bool
|
reset bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// create session, if reset will re-create
|
// create session, if reset will re-create
|
||||||
pub fn (mut t Tmux) session_create(args SessionCreateArgs) !&Session {
|
pub fn (mut t Tmux) session_create(args SessionCreateArgs) !&Session {
|
||||||
name := texttools.name_fix(args.name)
|
name := texttools.name_fix(args.name)
|
||||||
@@ -83,7 +80,6 @@ pub fn (mut t Tmux) session_create(args SessionCreateArgs) !&Session {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@[params]
|
@[params]
|
||||||
pub struct TmuxNewArgs {
|
pub struct TmuxNewArgs {
|
||||||
sessionid string
|
sessionid string
|
||||||
@@ -126,7 +122,6 @@ pub fn (mut t Tmux) window_new(args WindowNewArgs) !&Window {
|
|||||||
)!
|
)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn (mut t Tmux) stop() ! {
|
pub fn (mut t Tmux) stop() ! {
|
||||||
$if debug {
|
$if debug {
|
||||||
console.print_debug('Stopping tmux...')
|
console.print_debug('Stopping tmux...')
|
||||||
@@ -156,7 +151,6 @@ pub fn (mut t Tmux) start() ! {
|
|||||||
t.scan()!
|
t.scan()!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// print list of tmux sessions
|
// print list of tmux sessions
|
||||||
pub fn (mut t Tmux) list_print() {
|
pub fn (mut t Tmux) list_print() {
|
||||||
// os.log('TMUX - Start listing ....')
|
// os.log('TMUX - Start listing ....')
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ pub mut:
|
|||||||
last_output_offset int // for tracking new logs
|
last_output_offset int // for tracking new logs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn (mut p Pane) stats() !ProcessStats {
|
pub fn (mut p Pane) stats() !ProcessStats {
|
||||||
if p.pid == 0 {
|
if p.pid == 0 {
|
||||||
return ProcessStats{}
|
return ProcessStats{}
|
||||||
@@ -48,7 +47,6 @@ pub fn (mut p Pane) stats() !ProcessStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub struct TMuxLogEntry {
|
pub struct TMuxLogEntry {
|
||||||
pub mut:
|
pub mut:
|
||||||
content string
|
content string
|
||||||
@@ -57,20 +55,17 @@ pub mut:
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn (mut p Pane) logs_get_new(reset bool) ![]TMuxLogEntry {
|
pub fn (mut p Pane) logs_get_new(reset bool) ![]TMuxLogEntry {
|
||||||
|
if reset {
|
||||||
if reset{
|
|
||||||
p.last_output_offset = 0
|
p.last_output_offset = 0
|
||||||
}
|
}
|
||||||
// Capture pane content with line numbers
|
// Capture pane content with line numbers
|
||||||
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S ${p.last_output_offset} -p'
|
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S ${p.last_output_offset} -p'
|
||||||
result := osal.execute_silent(cmd) or {
|
result := osal.execute_silent(cmd) or { return error('Cannot capture pane output: ${err}') }
|
||||||
return error('Cannot capture pane output: ${err}')
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := result.split_into_lines()
|
lines := result.split_into_lines()
|
||||||
mut entries := []TMuxLogEntry{}
|
mut entries := []TMuxLogEntry{}
|
||||||
|
|
||||||
mut i:= 0
|
mut i := 0
|
||||||
for line in lines {
|
for line in lines {
|
||||||
if line.trim_space() != '' {
|
if line.trim_space() != '' {
|
||||||
entries << TMuxLogEntry{
|
entries << TMuxLogEntry{
|
||||||
@@ -106,9 +101,7 @@ pub fn (mut p Pane) exit_status() !ProcessStatus {
|
|||||||
|
|
||||||
pub fn (mut p Pane) logs_all() !string {
|
pub fn (mut p Pane) logs_all() !string {
|
||||||
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S -2000 -p'
|
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S -2000 -p'
|
||||||
return osal.execute_silent(cmd) or {
|
return osal.execute_silent(cmd) or { error('Cannot capture pane output: ${err}') }
|
||||||
error('Cannot capture pane output: ${err}')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix the output_wait method to use correct method name
|
// Fix the output_wait method to use correct method name
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
module tmux
|
module tmux
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pub struct ProcessStats {
|
pub struct ProcessStats {
|
||||||
pub mut:
|
pub mut:
|
||||||
cpu_percent f64
|
cpu_percent f64
|
||||||
@@ -9,13 +7,9 @@ pub mut:
|
|||||||
memory_percent f64
|
memory_percent f64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
enum ProcessStatus {
|
enum ProcessStatus {
|
||||||
running
|
running
|
||||||
finished_ok
|
finished_ok
|
||||||
finished_error
|
finished_error
|
||||||
not_found
|
not_found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ fn (mut t Tmux) scan_add(line string) !&Pane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scan the system to detect sessions .
|
// scan the system to detect sessions .
|
||||||
//TODO needs to be done differently, here only find the sessions, then per session call the scan() which will find the windows, call scan() there as well ...
|
// TODO needs to be done differently, here only find the sessions, then per session call the scan() which will find the windows, call scan() there as well ...
|
||||||
pub fn (mut t Tmux) scan() ! {
|
pub fn (mut t Tmux) scan() ! {
|
||||||
// os.log('TMUX - Scanning ....')
|
// os.log('TMUX - Scanning ....')
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mut:
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
reset bool
|
reset bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@[params]
|
@[params]
|
||||||
pub struct WindowGetArgs {
|
pub struct WindowGetArgs {
|
||||||
pub mut:
|
pub mut:
|
||||||
@@ -28,10 +29,9 @@ pub mut:
|
|||||||
id int
|
id int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn (mut s Session) create() ! {
|
pub fn (mut s Session) create() ! {
|
||||||
// Check if session already exists
|
// Check if session already exists
|
||||||
cmd_check := "tmux has-session -t ${s.name}"
|
cmd_check := 'tmux has-session -t ${s.name}'
|
||||||
check_result := osal.exec(cmd: cmd_check, stdout: false, ignore_error: true) or {
|
check_result := osal.exec(cmd: cmd_check, stdout: false, ignore_error: true) or {
|
||||||
// Session doesn't exist, this is expected
|
// Session doesn't exist, this is expected
|
||||||
osal.Job{}
|
osal.Job{}
|
||||||
@@ -42,19 +42,19 @@ pub fn (mut s Session) create() ! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new session
|
// Create new session
|
||||||
cmd := "tmux new-session -d -s ${s.name}"
|
cmd := 'tmux new-session -d -s ${s.name}'
|
||||||
osal.exec(cmd: cmd, stdout: false, name: 'tmux_session_create') or {
|
osal.exec(cmd: cmd, stdout: false, name: 'tmux_session_create') or {
|
||||||
return error("Can't create session ${s.name}: ${err}")
|
return error("Can't create session ${s.name}: ${err}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//load info from reality
|
// load info from reality
|
||||||
pub fn (mut s Session) scan() ! {
|
pub fn (mut s Session) scan() ! {
|
||||||
// Get current windows from tmux for this session
|
// Get current windows from tmux for this session
|
||||||
cmd := "tmux list-windows -t ${s.name} -F '#{window_name}|#{window_id}|#{window_active}'"
|
cmd := "tmux list-windows -t ${s.name} -F '#{window_name}|#{window_id}|#{window_active}'"
|
||||||
result := osal.execute_silent(cmd) or {
|
result := osal.execute_silent(cmd) or {
|
||||||
if err.msg().contains('session not found') {
|
if err.msg().contains('session not found') {
|
||||||
return // Session doesn't exist anymore
|
return
|
||||||
}
|
}
|
||||||
return error('Cannot list windows for session ${s.name}: ${err}')
|
return error('Cannot list windows for session ${s.name}: ${err}')
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,6 @@ pub fn (mut s Session) scan() ! {
|
|||||||
s.windows = s.windows.filter(current_windows[it.name] == true)
|
s.windows = s.windows.filter(current_windows[it.name] == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// window_name is the name of the window in session main (will always be called session main)
|
// window_name is the name of the window in session main (will always be called session main)
|
||||||
// cmd to execute e.g. bash file
|
// cmd to execute e.g. bash file
|
||||||
// environment arguments to use
|
// environment arguments to use
|
||||||
@@ -143,10 +142,6 @@ pub fn (mut s Session) window_new(args WindowArgs) !Window {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// get all windows as found in a session
|
// get all windows as found in a session
|
||||||
pub fn (mut s Session) windows_get() []&Window {
|
pub fn (mut s Session) windows_get() []&Window {
|
||||||
mut res := []&Window{}
|
mut res := []&Window{}
|
||||||
@@ -208,8 +203,6 @@ pub fn (mut s Session) stats() !ProcessStats {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fn (mut s Session) window_exist(args_ WindowGetArgs) bool {
|
fn (mut s Session) window_exist(args_ WindowGetArgs) bool {
|
||||||
mut args := args_
|
mut args := args_
|
||||||
s.window_get(args) or { return false }
|
s.window_get(args) or { return false }
|
||||||
@@ -249,7 +242,6 @@ pub fn (mut s Session) window_delete(args_ WindowGetArgs) ! {
|
|||||||
s.windows.delete(i) // i is now the one in the list which needs to be removed
|
s.windows.delete(i) // i is now the one in the list which needs to be removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn (mut s Session) restart() ! {
|
pub fn (mut s Session) restart() ! {
|
||||||
s.stop()!
|
s.stop()!
|
||||||
s.create()!
|
s.create()!
|
||||||
|
|||||||
@@ -22,12 +22,11 @@ pub mut:
|
|||||||
pub struct PaneNewArgs {
|
pub struct PaneNewArgs {
|
||||||
pub mut:
|
pub mut:
|
||||||
name string
|
name string
|
||||||
reset bool //means we reset the pane if it already exists
|
reset bool // means we reset the pane if it already exists
|
||||||
cmd string
|
cmd string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn (mut w Window) scan() ! {
|
pub fn (mut w Window) scan() ! {
|
||||||
// Get current panes for this window
|
// Get current panes for this window
|
||||||
cmd := "tmux list-panes -t ${w.session.name}:@${w.id} -F '#{pane_id}|#{pane_pid}|#{pane_active}|#{pane_start_command}'"
|
cmd := "tmux list-panes -t ${w.session.name}:@${w.id} -F '#{pane_id}|#{pane_pid}|#{pane_active}|#{pane_start_command}'"
|
||||||
@@ -81,12 +80,12 @@ pub fn (mut w Window) scan() ! {
|
|||||||
w.panes = w.panes.filter(current_panes[it.id] == true)
|
w.panes = w.panes.filter(current_panes[it.id] == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn (mut w Window) stop() ! {
|
pub fn (mut w Window) stop() ! {
|
||||||
w.kill()!
|
w.kill()!
|
||||||
}
|
}
|
||||||
//helper function
|
|
||||||
//TODO env variables are not inserted in pane
|
// helper function
|
||||||
|
// TODO env variables are not inserted in pane
|
||||||
pub fn (mut w Window) create(cmd_ string) ! {
|
pub fn (mut w Window) create(cmd_ string) ! {
|
||||||
mut final_cmd := cmd_
|
mut final_cmd := cmd_
|
||||||
if cmd_.contains('\n') {
|
if cmd_.contains('\n') {
|
||||||
@@ -100,7 +99,7 @@ pub fn (mut w Window) create(cmd_ string) ! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mut newcmd := '/bin/bash -c "${final_cmd}"'
|
mut newcmd := '/bin/bash -c "${final_cmd}"'
|
||||||
if cmd_ == "" {
|
if cmd_ == '' {
|
||||||
newcmd = '/bin/bash'
|
newcmd = '/bin/bash'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
module datamodel
|
module datamodel
|
||||||
|
|
||||||
|
// I can bid for infra, and optionally get accepted
|
||||||
@[heap]
|
@[heap]
|
||||||
//I can bid for infra, and optionally get accepted
|
|
||||||
pub struct Bid {
|
pub struct Bid {
|
||||||
pub mut:
|
pub mut:
|
||||||
id u32
|
id u32
|
||||||
customer_id u32 //links back to customer for this capacity (user on ledger)
|
customer_id u32 // links back to customer for this capacity (user on ledger)
|
||||||
compute_slices_nr int //nr of slices I need in 1 machine
|
compute_slices_nr int // nr of slices I need in 1 machine
|
||||||
compute_slice f64 //price per 1 GB slice I want to accept
|
compute_slice f64 // price per 1 GB slice I want to accept
|
||||||
storage_slices []u32
|
storage_slices []u32
|
||||||
status BidStatus
|
status BidStatus
|
||||||
obligation bool //if obligation then will be charged and money needs to be in escrow, otherwise its an intent
|
obligation bool // if obligation then will be charged and money needs to be in escrow, otherwise its an intent
|
||||||
start_date u32 //epoch
|
start_date u32 // epoch
|
||||||
end_date u32
|
end_date u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ module datamodel
|
|||||||
pub struct Reservation {
|
pub struct Reservation {
|
||||||
pub mut:
|
pub mut:
|
||||||
id u32
|
id u32
|
||||||
customer_id u32 //links back to customer for this capacity
|
customer_id u32 // links back to customer for this capacity
|
||||||
compute_slices []u32
|
compute_slices []u32
|
||||||
storage_slices []u32
|
storage_slices []u32
|
||||||
status ReservationStatus
|
status ReservationStatus
|
||||||
start_date u32 //epoch
|
start_date u32 // epoch
|
||||||
end_date u32
|
end_date u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
module datamodel
|
module datamodel
|
||||||
|
|
||||||
import freeflowuniverse.herolib.threefold.grid4.datamodel { Node }
|
import freeflowuniverse.herolib.threefold.grid4.datamodel { Node }
|
||||||
|
|
||||||
pub struct NodeSim {
|
pub struct NodeSim {
|
||||||
Node
|
Node
|
||||||
pub mut:
|
pub mut:
|
||||||
|
|||||||
Reference in New Issue
Block a user