* add terminal (ssh) apis

* add core terminal exec method

* terminal typescript client method

* terminals WIP

* backend for pty

* add ts responses

* about wire everything

* add new blog

* credit Skyfay

* working

* regen lock

* 1.17.4-dev-1

* pty history

* replace the test terminal impl with websocket (pty)

* create api and improve frontend

* fix fe

* terminals

* disable terminal api on periphery

* implement write level terminal perms

* remove unneeded

* fix clippy

* delete unneeded

* fix waste cpu cycles

* set TERM and COLORTERM for shell environment

* fix xterm scrolling behavior

* starship promp in periphery container terminal

* kill all terminals on periphery shutdown signal

* improve starship config and enable ssl in compose

* use same scrollTop setter

* fix periphery container distribution link

* support custom command / args to init terminal

* allow fully configurable init command

* docker exec into container

* add permissioning for container exec

* add starship to core container

* add delete all terminals

* dev-2

* finished gen client

* core need curl

* hide Terminal trigger if disabled

* 1.17.4
This commit is contained in:
Maxwell Becker
2025-04-27 15:53:23 -07:00
committed by GitHub
parent 76f2f61be5
commit 765e5a0df1
74 changed files with 3313 additions and 597 deletions

374
Cargo.lock generated
View File

@@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"getrandom 0.2.15",
"getrandom 0.2.16",
"once_cell",
"version_check 0.9.5",
"zerocopy 0.7.35",
@@ -165,9 +165,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "aws-config"
version = "1.6.1"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c39646d1a6b51240a1a23bb57ea4eebede7e16fbc237fdc876980233dcecb4f"
checksum = "b6fcc63c9860579e4cb396239570e979376e70aab79e496621748a09913f8b36"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -195,9 +195,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
version = "1.2.2"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4471bef4c22a06d2c7a1b6492493d3fdf24a805323109d6874f9c94d5906ac14"
checksum = "687bc16bc431a8533fe0097c7f0182874767f920989d7260950172ae8e3c4465"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -217,9 +217,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.28.0"
version = "0.28.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9f7720b74ed28ca77f90769a71fd8c637a0137f6fae4ae947e1050229cff57f"
checksum = "bfa9b6986f250236c27e5a204062434a773a13243d2ffc2955f37bdba4c5c6a1"
dependencies = [
"bindgen",
"cc",
@@ -230,9 +230,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.5.6"
version = "1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aff45ffe35196e593ea3b9dd65b320e51e2dda95aff4390bc459e461d09c6ad"
checksum = "6c4063282c69991e57faab9e5cb21ae557e59f5b0fb285c196335243df8dc25c"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -246,7 +246,6 @@ dependencies = [
"fastrand",
"http 0.2.12",
"http-body 0.4.6",
"once_cell",
"percent-encoding",
"pin-project-lite",
"tracing",
@@ -255,9 +254,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ec2"
version = "1.121.1"
version = "1.123.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1da132c5b212d7f3d0662dd42b618505424eba492b8290ef292f56e2d30ee51c"
checksum = "b03632589dee533daf47c598819353995b20e4db42a84078fffff11730c3fbbd"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -279,9 +278,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.64.0"
version = "1.65.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d4bdb0e5f80f0689e61c77ab678b2b9304af329616af38aef5b6b967b8e736"
checksum = "8efec445fb78df585327094fcef4cad895b154b58711e504db7a93c41aa27151"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -302,9 +301,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.65.0"
version = "1.66.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbbb3ce8da257aedbccdcb1aadafbbb6a5fe9adf445db0e1ea897bdc7e22d08"
checksum = "5e49cca619c10e7b002dc8e66928ceed66ab7f56c1a3be86c5437bf2d8d89bba"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -325,9 +324,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.65.0"
version = "1.66.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96a78a8f50a1630db757b60f679c8226a8a70ee2ab5f5e6e51dc67f6c61c7cfd"
checksum = "7420479eac0a53f776cc8f0d493841ffe58ad9d9783f3947be7265784471b47a"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -349,9 +348,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69d03c3c05ff80d54ff860fe38c726f6f494c639ae975203a101335f223386db"
checksum = "3503af839bd8751d0bdc5a46b9cac93a003a353e635b0c12cf2376b5b53e41ea"
dependencies = [
"aws-credential-types",
"aws-smithy-http",
@@ -363,7 +362,6 @@ dependencies = [
"hmac",
"http 0.2.12",
"http 1.3.1",
"once_cell",
"percent-encoding",
"sha2",
"time",
@@ -383,9 +381,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.62.0"
version = "0.62.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5949124d11e538ca21142d1fba61ab0a2a2c1bc3ed323cdb3e4b878bfb83166"
checksum = "99335bec6cdc50a346fda1437f9fefe33abf8c99060739a546a16457f2862ca9"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -395,7 +393,6 @@ dependencies = [
"http 0.2.12",
"http 1.3.1",
"http-body 0.4.6",
"once_cell",
"percent-encoding",
"pin-project-lite",
"pin-utils",
@@ -411,7 +408,7 @@ dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.4.8",
"h2 0.4.9",
"http 0.2.12",
"http 1.3.1",
"http-body 0.4.6",
@@ -441,12 +438,11 @@ dependencies = [
[[package]]
name = "aws-smithy-observability"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445d065e76bc1ef54963db400319f1dd3ebb3e0a74af20f7f7630625b0cc7cc0"
checksum = "9364d5989ac4dd918e5cc4c4bdcc61c9be17dcd2586ea7f69e348fc7c6cab393"
dependencies = [
"aws-smithy-runtime-api",
"once_cell",
]
[[package]]
@@ -461,9 +457,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.8.1"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0152749e17ce4d1b47c7747bdfec09dac1ccafdcbc741ebf9daa2a373356730f"
checksum = "14302f06d1d5b7d333fd819943075b13d27c7700b414f574c3c35859bfb55d5e"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -477,7 +473,6 @@ dependencies = [
"http 1.3.1",
"http-body 0.4.6",
"http-body 1.0.1",
"once_cell",
"pin-project-lite",
"pin-utils",
"tokio",
@@ -486,9 +481,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.7.4"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da37cf5d57011cb1753456518ec76e31691f1f474b73934a284eb2a1c76510f"
checksum = "a1e5d9e3a80a18afa109391fb5ad09c3daf887b516c6fd805a157c6ea7994a57"
dependencies = [
"aws-smithy-async",
"aws-smithy-types",
@@ -503,9 +498,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "836155caafba616c0ff9b07944324785de2ab016141c3550bd1c07882f8cee8f"
checksum = "40076bd09fadbc12d5e026ae080d0930defa606856186e31d83ccc6a255eeaf3"
dependencies = [
"base64-simd",
"bytes",
@@ -538,9 +533,9 @@ dependencies = [
[[package]]
name = "aws-types"
version = "1.3.6"
version = "1.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3873f8deed8927ce8d04487630dc9ff73193bab64742a61d050e57a68dec4125"
checksum = "8a322fec39e4df22777ed3ad8ea868ac2f94cd15e1a55f6ee8d8d6305057689a"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -851,13 +846,13 @@ dependencies = [
"ahash",
"base64 0.22.1",
"bitvec",
"getrandom 0.2.15",
"getrandom 0.2.16",
"getrandom 0.3.2",
"hex",
"indexmap 2.9.0",
"js-sys",
"once_cell",
"rand 0.9.0",
"rand 0.9.1",
"serde",
"serde_bytes",
"serde_json",
@@ -898,7 +893,7 @@ dependencies = [
[[package]]
name = "cache"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"tokio",
@@ -906,9 +901,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.18"
version = "1.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c"
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
dependencies = [
"jobserver",
"libc",
@@ -930,6 +925,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -995,9 +996,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.36"
version = "4.5.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04"
checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1005,9 +1006,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.36"
version = "4.5.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5"
checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
dependencies = [
"anstream",
"anstyle",
@@ -1059,7 +1060,7 @@ dependencies = [
[[package]]
name = "command"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"formatting",
@@ -1089,7 +1090,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.15",
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
@@ -1256,15 +1257,15 @@ dependencies = [
[[package]]
name = "data-encoding"
version = "2.8.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "der"
version = "0.7.9"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
@@ -1294,9 +1295,9 @@ dependencies = [
[[package]]
name = "derive-where"
version = "1.2.7"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25"
checksum = "2364b9aa47e460ce9bca6ac1777d14c98eef7e274eb077beed49f3adc94183ed"
dependencies = [
"proc-macro2",
"quote",
@@ -1367,9 +1368,9 @@ dependencies = [
[[package]]
name = "derive_more"
version = "0.99.19"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case",
"proc-macro2",
@@ -1428,6 +1429,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1537,7 +1544,7 @@ dependencies = [
[[package]]
name = "environment_file"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"thiserror 2.0.12",
]
@@ -1589,6 +1596,17 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1606,7 +1624,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"serror",
]
@@ -1735,9 +1753,9 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
@@ -1768,7 +1786,7 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "git"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"cache",
@@ -1819,9 +1837,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.8"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2"
checksum = "75249d144030531f8dee69fe9cea04d3edf809a017ae445e2abdff6629e86633"
dependencies = [
"atomic-waker",
"bytes",
@@ -1973,13 +1991,13 @@ dependencies = [
[[package]]
name = "hostname"
version = "0.4.0"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba"
checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65"
dependencies = [
"cfg-if",
"libc",
"windows 0.52.0",
"windows-link",
]
[[package]]
@@ -2089,7 +2107,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"h2 0.4.8",
"h2 0.4.9",
"http 1.3.1",
"http-body 1.0.1",
"httparse",
@@ -2420,7 +2438,7 @@ dependencies = [
"socket2",
"widestring",
"windows-sys 0.48.0",
"winreg",
"winreg 0.50.0",
]
[[package]]
@@ -2505,7 +2523,7 @@ dependencies = [
[[package]]
name = "komodo_cli"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"clap",
@@ -2521,7 +2539,7 @@ dependencies = [
[[package]]
name = "komodo_client"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2552,7 +2570,7 @@ dependencies = [
[[package]]
name = "komodo_core"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"arc-swap",
@@ -2593,7 +2611,7 @@ dependencies = [
"ordered_hash_map",
"partial_derive2",
"periphery_client",
"rand 0.9.0",
"rand 0.9.1",
"regex",
"reqwest",
"resolver_api",
@@ -2607,6 +2625,7 @@ dependencies = [
"slack_client_rs",
"svi",
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml",
"toml_pretty",
@@ -2620,13 +2639,14 @@ dependencies = [
[[package]]
name = "komodo_periphery"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"async_timing_util",
"axum",
"axum-server",
"bollard",
"bytes",
"cache",
"clap",
"command",
@@ -2641,6 +2661,8 @@ dependencies = [
"logger",
"merge_config_files",
"periphery_client",
"portable-pty",
"rand 0.9.1",
"resolver_api",
"response",
"run_command",
@@ -2652,6 +2674,7 @@ dependencies = [
"svi",
"sysinfo",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
@@ -2673,9 +2696,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.171"
version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libloading"
@@ -2689,9 +2712,9 @@ dependencies = [
[[package]]
name = "libm"
version = "0.2.11"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
checksum = "c9627da5196e5d8ed0b0495e61e518847578da83483c37288316d9b2e03a7f72"
[[package]]
name = "linked-hash-map"
@@ -2732,7 +2755,7 @@ dependencies = [
[[package]]
name = "logger"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"komodo_client",
@@ -2860,9 +2883,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.7"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430"
checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a"
dependencies = [
"adler2",
]
@@ -2976,6 +2999,18 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.9.0",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nom"
version = "4.2.3"
@@ -3095,7 +3130,7 @@ checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.22.1",
"chrono",
"getrandom 0.2.15",
"getrandom 0.2.16",
"http 1.3.1",
"rand 0.8.5",
"reqwest",
@@ -3109,9 +3144,9 @@ dependencies = [
[[package]]
name = "objc2-core-foundation"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.9.0",
]
@@ -3278,7 +3313,7 @@ dependencies = [
"glob",
"opentelemetry",
"percent-encoding",
"rand 0.9.0",
"rand 0.9.1",
"serde_json",
"thiserror 2.0.12",
"tokio",
@@ -3465,15 +3500,19 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "periphery_client"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"komodo_client",
"reqwest",
"resolver_api",
"rustls 0.23.26",
"serde",
"serde_json",
"serde_qs",
"serror",
"tokio",
"tokio-tungstenite",
"tracing",
]
@@ -3568,6 +3607,27 @@ dependencies = [
"spki",
]
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg 0.10.1",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -3604,9 +3664,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.94"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
@@ -3641,7 +3701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012"
dependencies = [
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -3656,13 +3716,13 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.10"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
checksum = "bcbafbbdbb0f638fe3f35f3c56739f77a8a1d070cb25603226c83339b391472b"
dependencies = [
"bytes",
"getrandom 0.3.2",
"rand 0.9.0",
"rand 0.9.1",
"ring",
"rustc-hash 2.1.1",
"rustls 0.23.26",
@@ -3680,7 +3740,7 @@ version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
@@ -3722,13 +3782,12 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
"zerocopy 0.8.24",
]
[[package]]
@@ -3757,7 +3816,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.15",
"getrandom 0.2.16",
]
[[package]]
@@ -3834,7 +3893,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.8",
"h2 0.4.9",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
@@ -3861,11 +3920,13 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-rustls 0.26.2",
"tokio-util",
"tower 0.5.2",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 0.26.8",
"windows-registry",
@@ -3885,9 +3946,9 @@ dependencies = [
[[package]]
name = "reqwest-middleware"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64e8975513bd9a7a43aad01030e79b3498e05db14e9d945df6483e8cf9b8c4c4"
checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e"
dependencies = [
"anyhow",
"async-trait",
@@ -3907,7 +3968,7 @@ dependencies = [
"anyhow",
"async-trait",
"futures",
"getrandom 0.2.15",
"getrandom 0.2.16",
"http 1.3.1",
"hyper 1.6.0",
"parking_lot 0.11.2",
@@ -3922,13 +3983,13 @@ dependencies = [
[[package]]
name = "reqwest-tracing"
version = "0.5.6"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c88a8d9cfe3319b5adc10f3ffc3db75c7346837a1f857f8269f6361f3b2744"
checksum = "d75b0eee96990cfb4c09545847385e89b2d2d2e571143d55264a05d77c713780"
dependencies = [
"anyhow",
"async-trait",
"getrandom 0.2.15",
"getrandom 0.2.16",
"http 1.3.1",
"matchit",
"reqwest",
@@ -3967,7 +4028,7 @@ dependencies = [
[[package]]
name = "response"
version = "1.17.3"
version = "1.17.4"
dependencies = [
"anyhow",
"axum",
@@ -4003,7 +4064,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.15",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -4392,6 +4453,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_qs"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b417bedc008acbdf6d6b4bc482d29859924114bbe2650b7921fb68a261d0aa6"
dependencies = [
"percent-encoding",
"serde",
"thiserror 2.0.12",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4467,6 +4539,17 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "serial2"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d1d08630509d69f90eff4afcd02c3bd974d979225cbd815ff5942351b14375"
dependencies = [
"cfg-if",
"libc",
"winapi",
]
[[package]]
name = "serror"
version = "0.5.0"
@@ -4521,6 +4604,22 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "shlex"
version = "1.3.0"
@@ -4529,9 +4628,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
dependencies = [
"libc",
]
@@ -4751,7 +4850,7 @@ dependencies = [
"memchr",
"ntapi",
"objc2-core-foundation",
"windows 0.57.0",
"windows",
]
[[package]]
@@ -4970,15 +5069,19 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"rustls 0.23.26",
"rustls-native-certs 0.8.1",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.2",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.14"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
dependencies = [
"bytes",
"futures-core",
@@ -5242,7 +5345,9 @@ dependencies = [
"http 1.3.1",
"httparse",
"log",
"rand 0.9.0",
"rand 0.9.1",
"rustls 0.23.26",
"rustls-pki-types",
"sha1",
"thiserror 2.0.12",
"utf-8",
@@ -5382,7 +5487,7 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
dependencies = [
"getrandom 0.3.2",
"js-sys",
"rand 0.9.0",
"rand 0.9.1",
"serde",
"wasm-bindgen",
]
@@ -5506,6 +5611,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-timer"
version = "0.2.5"
@@ -5605,16 +5723,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core 0.52.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.57.0"
@@ -5625,15 +5733,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
@@ -5970,13 +6069,22 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.4"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36"
checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.50.0"

View File

@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
version = "1.17.3"
version = "1.17.4"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -44,19 +44,19 @@ mungos = "3.2.0"
svi = "1.0.1"
# ASYNC
reqwest = { version = "0.12.15", default-features = false, features = ["json", "rustls-tls-native-roots"] }
reqwest = { version = "0.12.15", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
tokio = { version = "1.44.1", features = ["full"] }
tokio-util = "0.7.14"
tokio-util = { version = "0.7.14", features = ["io", "codec"] }
futures = "0.3.31"
futures-util = "0.3.31"
arc-swap = "1.7.1"
# SERVER
tokio-tungstenite = { version = "0.26.2", features = ["rustls-tls-native-roots"] }
axum-extra = { version = "0.10.0", features = ["typed-header"] }
tower-http = { version = "0.6.2", features = ["fs", "cors"] }
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
axum = { version = "0.8.1", features = ["ws", "json", "macros"] }
tokio-tungstenite = "0.26.2"
# SER/DE
ordered_hash_map = { version = "0.4.0", features = ["serde"] }
@@ -64,6 +64,7 @@ serde = { version = "1.0.219", features = ["derive"] }
strum = { version = "0.27.1", features = ["derive"] }
serde_json = "1.0.140"
serde_yaml = "0.9.34"
serde_qs = "0.14.0"
toml = "0.8.20"
# ERROR
@@ -80,7 +81,7 @@ opentelemetry = "0.29.0"
tracing = "0.1.41"
# CONFIG
clap = { version = "4.5.36", features = ["derive"] }
clap = { version = "4.5.37", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
@@ -95,10 +96,11 @@ base64 = "0.22.1"
rustls = "0.23.26"
hmac = "0.12.1"
sha2 = "0.10.8"
rand = "0.9.0"
rand = "0.9.1"
hex = "0.4.3"
# SYSTEM
portable-pty = "0.9.0"
bollard = "0.18.1"
sysinfo = "0.34.2"
@@ -121,4 +123,5 @@ dashmap = "6.1.0"
wildcard = "0.3.0"
colored = "3.0.0"
regex = "1.11.1"
bytes = "1.10.1"
bson = "2.14.0"

View File

@@ -38,6 +38,7 @@ slack.workspace = true
svi.workspace = true
# external
aws-credential-types.workspace = true
tokio-tungstenite.workspace = true
ordered_hash_map.workspace = true
english-to-cron.workspace = true
openidconnect.workspace = true

View File

@@ -24,10 +24,9 @@ RUN cd frontend && yarn link komodo_client && yarn && yarn build
# Final Image
FROM debian:bullseye-slim
# Install Deps
RUN apt update && \
apt install -y git ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY ./bin/core/starship.toml /config/starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
# Setup an application directory
WORKDIR /app

14
bin/core/debian-deps.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
## Core deps installer
apt-get update
apt-get install -y git curl ca-certificates
rm -rf /var/lib/apt/lists/*
# Starship prompt
curl -sS https://starship.rs/install.sh | sh -s -- --yes --bin-dir /usr/local/bin
echo 'export STARSHIP_CONFIG=/config/starship.toml' >> /root/.bashrc
echo 'eval "$(starship init bash)"' >> /root/.bashrc

View File

@@ -15,10 +15,9 @@ FROM ${FRONTEND_IMAGE} AS frontend
# Final Image
FROM debian:bullseye-slim
# Install Deps
RUN apt update && \
apt install -y git ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY ./bin/core/starship.toml /config/starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
WORKDIR /app

View File

@@ -16,10 +16,9 @@ RUN cd frontend && yarn link komodo_client && yarn && yarn build
FROM debian:bullseye-slim
# Install Deps
RUN apt update && \
apt install -y git ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY ./bin/core/starship.toml /config/starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
# Copy
COPY ./config/core.config.toml /config/config.toml

View File

@@ -123,6 +123,7 @@ enum ReadRequest {
ListDockerImages(ListDockerImages),
ListDockerVolumes(ListDockerVolumes),
ListComposeProjects(ListComposeProjects),
ListTerminals(ListTerminals),
// ==== DEPLOYMENT ====
GetDeploymentsSummary(GetDeploymentsSummary),

View File

@@ -21,9 +21,11 @@ use komodo_client::{
network::Network,
volume::Volume,
},
komodo_timestamp,
permission::PermissionLevel,
server::{
Server, ServerActionState, ServerListItem, ServerState,
TerminalInfo,
},
stack::{Stack, StackServiceNames},
stats::{SystemInformation, SystemProcess},
@@ -45,7 +47,10 @@ use resolver_api::Resolve;
use tokio::sync::Mutex;
use crate::{
helpers::{periphery_client, query::get_all_tags},
helpers::{
periphery_client,
query::{get_all_tags, get_system_info},
},
resource,
stack::compose_container_match_regex,
state::{action_states, db_client, server_status_cache},
@@ -198,16 +203,6 @@ impl Resolve<ReadArgs> for GetServerActionState {
}
}
// This protects the peripheries from spam requests
const SYSTEM_INFO_EXPIRY: u128 = FIFTEEN_SECONDS_MS;
type SystemInfoCache =
Mutex<HashMap<String, Arc<(SystemInformation, u128)>>>;
fn system_info_cache() -> &'static SystemInfoCache {
static SYSTEM_INFO_CACHE: OnceLock<SystemInfoCache> =
OnceLock::new();
SYSTEM_INFO_CACHE.get_or_init(Default::default)
}
impl Resolve<ReadArgs> for GetSystemInformation {
async fn resolve(
self,
@@ -219,25 +214,7 @@ impl Resolve<ReadArgs> for GetSystemInformation {
PermissionLevel::Read,
)
.await?;
let mut lock = system_info_cache().lock().await;
let res = match lock.get(&server.id) {
Some(cached) if cached.1 > unix_timestamp_ms() => {
cached.0.clone()
}
_ => {
let stats = periphery_client(&server)?
.request(periphery::stats::GetSystemInformation {})
.await?;
lock.insert(
server.id,
(stats.clone(), unix_timestamp_ms() + SYSTEM_INFO_EXPIRY)
.into(),
);
stats
}
};
Ok(res)
get_system_info(&server).await.map_err(Into::into)
}
}
@@ -812,3 +789,66 @@ impl Resolve<ReadArgs> for ListComposeProjects {
}
}
}
#[derive(Default)]
struct TerminalCacheItem {
list: Vec<TerminalInfo>,
ttl: i64,
}
const TERMINAL_CACHE_TIMEOUT: i64 = 30_000;
#[derive(Default)]
struct TerminalCache(
std::sync::Mutex<
HashMap<String, Arc<tokio::sync::Mutex<TerminalCacheItem>>>,
>,
);
impl TerminalCache {
fn get_or_insert(
&self,
server_id: String,
) -> Arc<tokio::sync::Mutex<TerminalCacheItem>> {
if let Some(cached) =
self.0.lock().unwrap().get(&server_id).cloned()
{
return cached;
}
let to_cache =
Arc::new(tokio::sync::Mutex::new(TerminalCacheItem::default()));
self.0.lock().unwrap().insert(server_id, to_cache.clone());
to_cache
}
}
fn terminals_cache() -> &'static TerminalCache {
static TERMINALS: OnceLock<TerminalCache> = OnceLock::new();
TERMINALS.get_or_init(Default::default)
}
impl Resolve<ReadArgs> for ListTerminals {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListTerminalsResponse> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Read,
)
.await?;
let cache = terminals_cache().get_or_insert(server.id.clone());
let mut cache = cache.lock().await;
if self.fresh || komodo_timestamp() > cache.ttl {
cache.list = periphery_client(&server)?
.request(periphery_client::api::terminal::ListTerminals {})
.await
.context("Failed to get fresh terminal list")?;
cache.ttl = komodo_timestamp() + TERMINAL_CACHE_TIMEOUT;
Ok(cache.list.clone())
} else {
Ok(cache.list.clone())
}
}
}

View File

@@ -81,6 +81,9 @@ pub enum WriteRequest {
UpdateServer(UpdateServer),
RenameServer(RenameServer),
CreateNetwork(CreateNetwork),
CreateTerminal(CreateTerminal),
DeleteTerminal(DeleteTerminal),
DeleteAllTerminals(DeleteAllTerminals),
// ==== DEPLOYMENT ====
CreateDeployment(CreateDeployment),
@@ -208,10 +211,6 @@ async fn handler(
.await
.context("failure in spawned task");
if let Err(e) = &res {
warn!("/write request {req_id} spawn error: {e:#}");
}
res?
}

View File

@@ -1,8 +1,9 @@
use anyhow::Context;
use formatting::format_serror;
use komodo_client::{
api::write::*,
entities::{
Operation,
NoData, Operation,
permission::PermissionLevel,
server::Server,
update::{Update, UpdateStatus},
@@ -101,3 +102,81 @@ impl Resolve<WriteArgs> for CreateNetwork {
Ok(update)
}
}
impl Resolve<WriteArgs> for CreateTerminal {
#[instrument(name = "CreateTerminal", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<NoData> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Write,
)
.await?;
let periphery = periphery_client(&server)?;
periphery
.request(api::terminal::CreateTerminal {
name: self.name,
command: self.command,
recreate: self.recreate,
})
.await
.context("Failed to create terminal on periphery")?;
Ok(NoData {})
}
}
impl Resolve<WriteArgs> for DeleteTerminal {
#[instrument(name = "DeleteTerminal", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<NoData> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Write,
)
.await?;
let periphery = periphery_client(&server)?;
periphery
.request(api::terminal::DeleteTerminal {
terminal: self.terminal,
})
.await
.context("Failed to delete terminal on periphery")?;
Ok(NoData {})
}
}
impl Resolve<WriteArgs> for DeleteAllTerminals {
#[instrument(name = "DeleteAllTerminals", skip(user))]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<NoData> {
let server = resource::get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Write,
)
.await?;
let periphery = periphery_client(&server)?;
periphery
.request(api::terminal::DeleteAllTerminals {})
.await
.context("Failed to delete all terminals on periphery")?;
Ok(NoData {})
}
}

View File

@@ -1,6 +1,11 @@
use std::{collections::HashMap, str::FromStr};
use std::{
collections::HashMap,
str::FromStr,
sync::{Arc, OnceLock},
};
use anyhow::{Context, anyhow};
use async_timing_util::{ONE_MIN_MS, unix_timestamp_ms};
use komodo_client::entities::{
Operation, ResourceTarget, ResourceTargetVariant,
action::Action,
@@ -15,6 +20,7 @@ use komodo_client::entities::{
server::{Server, ServerState},
server_template::ServerTemplate,
stack::{Stack, StackServiceNames, StackState},
stats::SystemInformation,
sync::ResourceSync,
tag::Tag,
update::Update,
@@ -29,6 +35,8 @@ use mungos::{
options::FindOneOptions,
},
};
use periphery_client::api::stats;
use tokio::sync::Mutex;
use crate::{
config::core_config,
@@ -37,6 +45,8 @@ use crate::{
state::{db_client, deployment_status_cache, stack_status_cache},
};
use super::periphery_client;
// user: Id or username
#[instrument(level = "debug")]
pub async fn get_user(user: &str) -> anyhow::Result<User> {
@@ -382,3 +392,36 @@ pub async fn get_variables_and_secrets()
Ok(VariablesAndSecrets { variables, secrets })
}
// This protects the peripheries from spam requests
const SYSTEM_INFO_EXPIRY: u128 = ONE_MIN_MS;
type SystemInfoCache =
Mutex<HashMap<String, Arc<(SystemInformation, u128)>>>;
fn system_info_cache() -> &'static SystemInfoCache {
static SYSTEM_INFO_CACHE: OnceLock<SystemInfoCache> =
OnceLock::new();
SYSTEM_INFO_CACHE.get_or_init(Default::default)
}
pub async fn get_system_info(
server: &Server,
) -> anyhow::Result<SystemInformation> {
let mut lock = system_info_cache().lock().await;
let res = match lock.get(&server.id) {
Some(cached) if cached.1 > unix_timestamp_ms() => {
cached.0.clone()
}
_ => {
let stats = periphery_client(server)?
.request(stats::GetSystemInformation {})
.await?;
lock.insert(
server.id.clone(),
(stats.clone(), unix_timestamp_ms() + SYSTEM_INFO_EXPIRY)
.into(),
);
stats
}
};
Ok(res)
}

View File

@@ -34,6 +34,12 @@ async fn app() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let config = core_config();
logger::init(&config.logging)?;
if let Err(e) =
rustls::crypto::aws_lc_rs::default_provider().install_default()
{
error!("Failed to install default crypto provider | {e:?}");
std::process::exit(1);
};
info!("Komodo Core version: v{}", env!("CARGO_PKG_VERSION"));
info!("{:?}", config.sanitized());

View File

@@ -13,6 +13,7 @@ use mungos::mongodb::{Collection, bson::doc};
use crate::{
config::core_config,
helpers::query::get_system_info,
monitor::update_cache_for_server,
state::{action_states, db_client, server_status_cache},
};
@@ -42,6 +43,10 @@ impl super::KomodoResource for Server {
server: Resource<Self::Config, Self::Info>,
) -> Self::ListItem {
let status = server_status_cache().get(&server.id).await;
let terminals_disabled = get_system_info(&server)
.await
.map(|i| i.terminals_disabled)
.unwrap_or(true);
ServerListItem {
name: server.name,
id: server.id,
@@ -57,6 +62,7 @@ impl super::KomodoResource for Server {
send_cpu_alerts: server.config.send_cpu_alerts,
send_mem_alerts: server.config.send_mem_alerts,
send_disk_alerts: server.config.send_disk_alerts,
terminals_disabled,
},
}
}

View File

@@ -1,209 +0,0 @@
use anyhow::anyhow;
use axum::{
Router,
extract::{
WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::IntoResponse,
routing::get,
};
use futures::{SinkExt, StreamExt};
use komodo_client::{
entities::{
ResourceTarget, permission::PermissionLevel, user::User,
},
ws::WsLoginMessage,
};
use serde_json::json;
use serror::serialize_error;
use tokio::select;
use tokio_util::sync::CancellationToken;
use crate::{
auth::{auth_api_key_check_enabled, auth_jwt_check_enabled},
helpers::{
channel::update_channel,
query::{get_user, get_user_permission_on_target},
},
};
pub fn router() -> Router {
Router::new().route("/update", get(ws_handler))
}
#[instrument(level = "debug")]
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
// get a reveiver for internal update messages.
let mut receiver = update_channel().receiver.resubscribe();
// handle http -> ws updgrade
ws.on_upgrade(|socket| async move {
let Some((socket, user)) = ws_login(socket).await else {
return
};
let (mut ws_sender, mut ws_reciever) = socket.split();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
loop {
// poll for updates off the receiver / await cancel.
let update = select! {
_ = cancel_clone.cancelled() => break,
update = receiver.recv() => {update.expect("failed to recv update msg")}
};
// before sending every update, verify user is still valid.
// kill the connection is user if found to be invalid.
let user = check_user_valid(&user.id).await;
let user = match user {
Err(e) => {
let _ = ws_sender
.send(Message::text(json!({ "type": "INVALID_USER", "msg": serialize_error(&e) }).to_string()))
.await;
let _ = ws_sender.close().await;
return;
},
Ok(user) => user,
};
// Only send if user has permission on the target resource.
if user_can_see_update(&user, &update.target).await.is_ok() {
let _ = ws_sender
.send(Message::text(serde_json::to_string(&update).unwrap()))
.await;
}
}
});
// Handle messages from the client.
// After login, only handles close message.
while let Some(msg) = ws_reciever.next().await {
match msg {
Ok(msg) => {
if let Message::Close(_) = msg {
cancel.cancel();
return;
}
}
Err(_) => {
cancel.cancel();
return;
}
}
}
})
}
#[instrument(level = "debug")]
async fn ws_login(
mut socket: WebSocket,
) -> Option<(WebSocket, User)> {
let login_msg = match socket.recv().await {
Some(Ok(Message::Text(login_msg))) => {
LoginMessage::Ok(login_msg.to_string())
}
Some(Ok(msg)) => {
LoginMessage::Err(format!("invalid login message: {msg:?}"))
}
Some(Err(e)) => {
LoginMessage::Err(format!("failed to get login message: {e:?}"))
}
None => {
LoginMessage::Err("failed to get login message".to_string())
}
};
let login_msg = match login_msg {
LoginMessage::Ok(login_msg) => login_msg,
LoginMessage::Err(msg) => {
let _ = socket.send(Message::text(msg)).await;
let _ = socket.close().await;
return None;
}
};
match WsLoginMessage::from_json_str(&login_msg) {
// Login using a jwt
Ok(WsLoginMessage::Jwt { jwt }) => {
match auth_jwt_check_enabled(&jwt).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using jwt | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
// login using api keys
Ok(WsLoginMessage::ApiKeys { key, secret }) => {
match auth_api_key_check_enabled(&key, &secret).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using api keys | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to parse login message: {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
enum LoginMessage {
/// The text message
Ok(String),
/// The err message
Err(String),
}
#[instrument(level = "debug")]
async fn check_user_valid(user_id: &str) -> anyhow::Result<User> {
let user = get_user(user_id).await?;
if !user.enabled {
return Err(anyhow!("user not enabled"));
}
Ok(user)
}
#[instrument(level = "debug")]
async fn user_can_see_update(
user: &User,
update_target: &ResourceTarget,
) -> anyhow::Result<()> {
if user.admin {
return Ok(());
}
let permissions =
get_user_permission_on_target(user, update_target).await?;
if permissions > PermissionLevel::None {
Ok(())
} else {
Err(anyhow!(
"user does not have permissions on {update_target:?}"
))
}
}

112
bin/core/src/ws/mod.rs Normal file
View File

@@ -0,0 +1,112 @@
use crate::{
auth::{auth_api_key_check_enabled, auth_jwt_check_enabled},
helpers::query::get_user,
};
use anyhow::anyhow;
use axum::{
Router,
extract::ws::{Message, WebSocket},
routing::get,
};
use futures::SinkExt;
use komodo_client::{entities::user::User, ws::WsLoginMessage};
mod terminal;
mod update;
pub fn router() -> Router {
Router::new()
.route("/update", get(update::handler))
.route("/terminal", get(terminal::handler))
}
#[instrument(level = "debug")]
async fn ws_login(
mut socket: WebSocket,
) -> Option<(WebSocket, User)> {
let login_msg = match socket.recv().await {
Some(Ok(Message::Text(login_msg))) => {
LoginMessage::Ok(login_msg.to_string())
}
Some(Ok(msg)) => {
LoginMessage::Err(format!("invalid login message: {msg:?}"))
}
Some(Err(e)) => {
LoginMessage::Err(format!("failed to get login message: {e:?}"))
}
None => {
LoginMessage::Err("failed to get login message".to_string())
}
};
let login_msg = match login_msg {
LoginMessage::Ok(login_msg) => login_msg,
LoginMessage::Err(msg) => {
let _ = socket.send(Message::text(msg)).await;
let _ = socket.close().await;
return None;
}
};
match WsLoginMessage::from_json_str(&login_msg) {
// Login using a jwt
Ok(WsLoginMessage::Jwt { jwt }) => {
match auth_jwt_check_enabled(&jwt).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using jwt | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
// login using api keys
Ok(WsLoginMessage::ApiKeys { key, secret }) => {
match auth_api_key_check_enabled(&key, &secret).await {
Ok(user) => {
let _ = socket.send(Message::text("LOGGED_IN")).await;
Some((socket, user))
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to authenticate user using api keys | {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
Err(e) => {
let _ = socket
.send(Message::text(format!(
"failed to parse login message: {e:#}"
)))
.await;
let _ = socket.close().await;
None
}
}
}
enum LoginMessage {
/// The text message
Ok(String),
/// The err message
Err(String),
}
#[instrument(level = "debug")]
async fn check_user_valid(user_id: &str) -> anyhow::Result<User> {
let user = get_user(user_id).await?;
if !user.enabled {
return Err(anyhow!("user not enabled"));
}
Ok(user)
}

200
bin/core/src/ws/terminal.rs Normal file
View File

@@ -0,0 +1,200 @@
use axum::{
extract::{
Query, WebSocketUpgrade,
ws::{CloseFrame, Message, Utf8Bytes},
},
response::IntoResponse,
};
use futures::{SinkExt, StreamExt};
use komodo_client::{
api::terminal::ConnectTerminalQuery,
entities::{permission::PermissionLevel, server::Server},
};
use tokio_tungstenite::tungstenite;
use tokio_util::sync::CancellationToken;
use crate::{helpers::periphery_client, resource};
#[instrument(name = "ConnectTerminal", skip(ws))]
pub async fn handler(
Query(ConnectTerminalQuery {
server,
terminal,
init,
}): Query<ConnectTerminalQuery>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(|socket| async move {
let Some((mut socket, user)) = super::ws_login(socket).await
else {
return;
};
let server = match resource::get_check_permissions::<Server>(
&server,
&user,
PermissionLevel::Write,
)
.await
{
Ok(server) => server,
Err(e) => {
debug!("could not get server | {e:#}");
let _ =
socket.send(Message::text(format!("ERROR: {e:#}"))).await;
let _ = socket.close().await;
return;
}
};
let periphery = match periphery_client(&server) {
Ok(periphery) => periphery,
Err(e) => {
debug!("couldn't get periphery | {e:#}");
let _ =
socket.send(Message::text(format!("ERROR: {e:#}"))).await;
let _ = socket.close().await;
return;
}
};
trace!("connecting to periphery terminal");
let periphery_socket = match periphery
.connect_terminal(
terminal,
init,
)
.await
{
Ok(ws) => ws,
Err(e) => {
debug!("Failed connect to periphery terminal | {e:#}");
let _ =
socket.send(Message::text(format!("ERROR: {e:#}"))).await;
let _ = socket.close().await;
return;
}
};
trace!("connected to periphery terminal socket");
let (mut periphery_send, mut periphery_receive) =
periphery_socket.split();
let (mut core_send, mut core_receive) = socket.split();
let cancel = CancellationToken::new();
trace!("starting ws exchange");
let core_to_periphery = async {
loop {
let res = tokio::select! {
res = core_receive.next() => res,
_ = cancel.cancelled() => {
trace!("core to periphery read: cancelled from inside");
break;
}
};
match res {
Some(Ok(msg)) => {
if let Err(e) =
periphery_send.send(axum_to_tungstenite(msg)).await
{
debug!("Failed to send terminal message to {} | {e:?}", server.name);
cancel.cancel();
break;
};
}
Some(Err(_e)) => {
cancel.cancel();
break;
}
None => {
cancel.cancel();
break;
}
}
}
};
let periphery_to_core = async {
loop {
let res = tokio::select! {
res = periphery_receive.next() => res,
_ = cancel.cancelled() => {
trace!("periphery to core read: cancelled from inside");
break;
}
};
match res {
Some(Ok(msg)) => {
if let Err(e) =
core_send.send(tungstenite_to_axum(msg)).await
{
debug!("{e:?}");
cancel.cancel();
break;
};
}
Some(Err(e)) => {
let _ = core_send
.send(Message::text(format!(
"ERROR: Failed to receive message from periphery | {e:?}"
)))
.await;
cancel.cancel();
break;
}
None => {
let _ = core_send
.send(Message::text("STREAM EOF"))
.await;
cancel.cancel();
break;
}
}
}
};
tokio::join!(core_to_periphery, periphery_to_core);
})
}
fn axum_to_tungstenite(msg: Message) -> tungstenite::Message {
match msg {
Message::Text(text) => tungstenite::Message::Text(
tungstenite::Utf8Bytes::from(text.to_string()),
),
Message::Binary(bytes) => tungstenite::Message::Binary(bytes),
Message::Ping(bytes) => tungstenite::Message::Ping(bytes),
Message::Pong(bytes) => tungstenite::Message::Pong(bytes),
Message::Close(close_frame) => {
tungstenite::Message::Close(close_frame.map(|cf| {
tungstenite::protocol::CloseFrame {
code: cf.code.into(),
reason: tungstenite::Utf8Bytes::from(cf.reason.to_string()),
}
}))
}
}
}
fn tungstenite_to_axum(msg: tungstenite::Message) -> Message {
match msg {
tungstenite::Message::Text(text) => {
Message::Text(Utf8Bytes::from(text.to_string()))
}
tungstenite::Message::Binary(bytes) => Message::Binary(bytes),
tungstenite::Message::Ping(bytes) => Message::Ping(bytes),
tungstenite::Message::Pong(bytes) => Message::Pong(bytes),
tungstenite::Message::Close(close_frame) => {
Message::Close(close_frame.map(|cf| CloseFrame {
code: cf.code.into(),
reason: Utf8Bytes::from(cf.reason.to_string()),
}))
}
tungstenite::Message::Frame(_) => {
unreachable!()
}
}
}

102
bin/core/src/ws/update.rs Normal file
View File

@@ -0,0 +1,102 @@
use anyhow::anyhow;
use axum::{
extract::{WebSocketUpgrade, ws::Message},
response::IntoResponse,
};
use futures::{SinkExt, StreamExt};
use komodo_client::entities::{
ResourceTarget, permission::PermissionLevel, user::User,
};
use serde_json::json;
use serror::serialize_error;
use tokio::select;
use tokio_util::sync::CancellationToken;
use crate::helpers::{
channel::update_channel, query::get_user_permission_on_target,
};
#[instrument(level = "debug")]
pub async fn handler(ws: WebSocketUpgrade) -> impl IntoResponse {
// get a reveiver for internal update messages.
let mut receiver = update_channel().receiver.resubscribe();
// handle http -> ws updgrade
ws.on_upgrade(|socket| async move {
let Some((socket, user)) = super::ws_login(socket).await else {
return
};
let (mut ws_sender, mut ws_reciever) = socket.split();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
loop {
// poll for updates off the receiver / await cancel.
let update = select! {
_ = cancel_clone.cancelled() => break,
update = receiver.recv() => {update.expect("failed to recv update msg")}
};
// before sending every update, verify user is still valid.
// kill the connection is user if found to be invalid.
let user = super::check_user_valid(&user.id).await;
let user = match user {
Err(e) => {
let _ = ws_sender
.send(Message::text(json!({ "type": "INVALID_USER", "msg": serialize_error(&e) }).to_string()))
.await;
let _ = ws_sender.close().await;
return;
},
Ok(user) => user,
};
// Only send if user has permission on the target resource.
if user_can_see_update(&user, &update.target).await.is_ok() {
let _ = ws_sender
.send(Message::text(serde_json::to_string(&update).unwrap()))
.await;
}
}
});
// Handle messages from the client.
// After login, only handles close message.
while let Some(msg) = ws_reciever.next().await {
match msg {
Ok(msg) => {
if let Message::Close(_) = msg {
cancel.cancel();
return;
}
}
Err(_) => {
cancel.cancel();
return;
}
}
}
})
}
#[instrument(level = "debug")]
async fn user_can_see_update(
user: &User,
update_target: &ResourceTarget,
) -> anyhow::Result<()> {
if user.admin {
return Ok(());
}
let permissions =
get_user_permission_on_target(user, update_target).await?;
if permissions > PermissionLevel::None {
Ok(())
} else {
Err(anyhow!(
"user does not have permissions on {update_target:?}"
))
}
}

67
bin/core/starship.toml Normal file
View File

@@ -0,0 +1,67 @@
## This is used to customize the shell prompt in Periphery container for Terminals
"$schema" = 'https://starship.rs/config-schema.json'
add_newline = true
format = "$time$hostname$container$memory_usage$all"
[character]
success_symbol = "[](bright-blue bold)"
error_symbol = "[](bright-red bold)"
[package]
disabled = true
[time]
format = "[$time](white dimmed) "
time_format = "%l:%M %p"
utc_time_offset = '-5'
disabled = true
[username]
format = "[ $user]($style) "
style_user = "bright-green"
show_always = true
[hostname]
format = "[ $hostname]($style) "
style = "bright-blue"
ssh_only = false
[directory]
format = "[ $path]($style)[$read_only]($read_only_style) "
style = "bright-cyan"
[git_branch]
format = "[ $symbol$branch(:$remote_branch)]($style) "
style = "bright-purple"
[git_status]
style = "bright-purple"
[rust]
format = "[ $symbol($version )]($style)"
symbol = "rustc "
style = "bright-red"
[nodejs]
format = "[ $symbol($version )]($style)"
symbol = "nodejs "
style = "bright-red"
[memory_usage]
format = "[ mem ${ram} ${ram_pct}]($style) "
threshold = -1
style = "white"
[cmd_duration]
format = "[ $duration]($style)"
style = "bright-yellow"
[container]
format = "[ 🦎 core container ]($style)"
style = "bright-green"
[aws]
disabled = true

View File

@@ -33,9 +33,11 @@ resolver_api.workspace = true
run_command.workspace = true
svi.workspace = true
# external
portable-pty.workspace = true
axum-server.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
tokio-util.workspace = true
futures.workspace = true
tracing.workspace = true
bollard.workspace = true
@@ -45,7 +47,9 @@ anyhow.workspace = true
rustls.workspace = true
tokio.workspace = true
serde.workspace = true
bytes.workspace = true
axum.workspace = true
clap.workspace = true
envy.workspace = true
uuid.workspace = true
rand.workspace = true

View File

@@ -15,6 +15,7 @@ RUN cargo build -p komodo_periphery --release
# Final Image
FROM debian:bullseye-slim
COPY ./bin/periphery/starship.toml /config/starship.toml
COPY ./bin/periphery/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh

View File

@@ -1,6 +1,10 @@
#!/bin/bash
## Periphery deps installer
apt-get update
apt-get install -y git curl wget ca-certificates
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
@@ -15,4 +19,10 @@ apt-get update
# apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
apt-get install -y docker-ce-cli docker-buildx-plugin docker-compose-plugin
rm -rf /var/lib/apt/lists/*
rm -rf /var/lib/apt/lists/*
# Starship prompt
curl -sS https://starship.rs/install.sh | sh -s -- --yes --bin-dir /usr/local/bin
echo 'export STARSHIP_CONFIG=/config/starship.toml' >> /root/.bashrc
echo 'eval "$(starship init bash)"' >> /root/.bashrc

View File

@@ -12,6 +12,7 @@ FROM ${AARCH64_BINARIES} AS aarch64
FROM debian:bullseye-slim
COPY ./bin/periphery/starship.toml /config/starship.toml
COPY ./bin/periphery/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh

View File

@@ -8,10 +8,10 @@ FROM ${BINARIES_IMAGE} AS binaries
FROM debian:bullseye-slim
COPY ./bin/periphery/starship.toml /config/starship.toml
COPY ./bin/periphery/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
WORKDIR /app
COPY --from=binaries /periphery /usr/local/bin/periphery
EXPOSE 8120

View File

@@ -8,11 +8,8 @@ use komodo_client::entities::{
update::Log,
};
use periphery_client::api::{
GetDockerLists, GetDockerListsResponse, GetHealth,
GetHealthResponse, GetVersion, GetVersionResponse,
ListDockerRegistries, ListGitProviders, ListSecrets, PruneSystem,
RunCommand, build::*, compose::*, container::*, git::*, image::*,
network::*, stats::*, volume::*,
build::*, compose::*, container::*, git::*, image::*, network::*,
stats::*, terminal::*, volume::*, *,
};
use resolver_api::Resolve;
use response::Response;
@@ -27,9 +24,13 @@ mod deploy;
mod git;
mod image;
mod network;
mod router;
mod stats;
mod terminal;
mod volume;
pub use router::router;
pub struct Args;
#[derive(
@@ -136,6 +137,13 @@ pub enum PeripheryRequest {
// All in one (Write)
PruneSystem(PruneSystem),
// Terminal
ListTerminals(ListTerminals),
CreateTerminal(CreateTerminal),
DeleteTerminal(DeleteTerminal),
DeleteAllTerminals(DeleteAllTerminals),
CreateTerminalAuthToken(CreateTerminalAuthToken),
}
//

View File

@@ -8,7 +8,7 @@ use axum::{
http::{Request, StatusCode},
middleware::{self, Next},
response::Response,
routing::post,
routing::{get, post},
};
use derive_variants::ExtractVariant;
use resolver_api::Resolve;
@@ -19,9 +19,13 @@ use crate::config::periphery_config;
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.merge(
Router::new()
.route("/", post(handler))
.layer(middleware::from_fn(guard_request_by_passkey)),
)
.route("/terminal", get(super::terminal::connect_terminal))
.layer(middleware::from_fn(guard_request_by_ip))
.layer(middleware::from_fn(guard_request_by_passkey))
}
async fn handler(

View File

@@ -0,0 +1,333 @@
use std::{collections::HashMap, sync::OnceLock};
use anyhow::{Context, anyhow};
use axum::{
extract::{
Query, WebSocketUpgrade,
ws::{Message, Utf8Bytes},
},
http::StatusCode,
response::Response,
};
use bytes::Bytes;
use futures::{SinkExt, StreamExt};
use komodo_client::entities::{
NoData, komodo_timestamp, server::TerminalInfo,
};
use periphery_client::api::terminal::{
ConnectTerminalQuery, CreateTerminal, CreateTerminalAuthToken,
CreateTerminalAuthTokenResponse, DeleteAllTerminals,
DeleteTerminal, ListTerminals,
};
use rand::Rng;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use tokio_util::sync::CancellationToken;
use crate::{
config::periphery_config,
terminal::{
ResizeDimensions, StdinMsg, clean_up_terminals, create_terminal,
delete_all_terminals, delete_terminal, get_terminal,
list_terminals,
},
};
impl Resolve<super::Args> for ListTerminals {
#[instrument(name = "ListTerminals", level = "debug")]
async fn resolve(
self,
_: &super::Args,
) -> serror::Result<Vec<TerminalInfo>> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
clean_up_terminals().await;
Ok(list_terminals().await)
}
}
impl Resolve<super::Args> for CreateTerminal {
#[instrument(name = "CreateTerminal", level = "debug")]
async fn resolve(self, _: &super::Args) -> serror::Result<NoData> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
create_terminal(self.name, self.command, self.recreate)
.await
.map(|_| NoData {})
.map_err(Into::into)
}
}
impl Resolve<super::Args> for DeleteTerminal {
#[instrument(name = "DeleteTerminal", level = "debug")]
async fn resolve(self, _: &super::Args) -> serror::Result<NoData> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
delete_terminal(&self.terminal).await;
Ok(NoData {})
}
}
impl Resolve<super::Args> for DeleteAllTerminals {
#[instrument(name = "DeleteAllTerminals", level = "debug")]
async fn resolve(self, _: &super::Args) -> serror::Result<NoData> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
delete_all_terminals().await;
Ok(NoData {})
}
}
impl Resolve<super::Args> for CreateTerminalAuthToken {
#[instrument(name = "CreateTerminalAuthToken", level = "debug")]
async fn resolve(
self,
_: &super::Args,
) -> serror::Result<CreateTerminalAuthTokenResponse> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
Ok(CreateTerminalAuthTokenResponse {
token: auth_tokens().create_auth_token(),
})
}
}
/// Tokens valid for 3 seconds
const TOKEN_VALID_FOR_MS: i64 = 3_000;
fn auth_tokens() -> &'static AuthTokens {
static AUTH_TOKENS: OnceLock<AuthTokens> = OnceLock::new();
AUTH_TOKENS.get_or_init(Default::default)
}
#[derive(Default)]
struct AuthTokens {
map: std::sync::Mutex<HashMap<String, i64>>,
}
impl AuthTokens {
pub fn create_auth_token(&self) -> String {
let token: String = rand::rng()
.sample_iter(&rand::distr::Alphanumeric)
.take(30)
.map(char::from)
.collect();
self
.map
.lock()
.unwrap()
.insert(token.clone(), komodo_timestamp() + TOKEN_VALID_FOR_MS);
token
}
pub fn check_token(&self, token: String) -> serror::Result<()> {
let Some(valid_until) = self.map.lock().unwrap().remove(&token)
else {
return Err(
anyhow!("Terminal auth token not found")
.status_code(StatusCode::UNAUTHORIZED),
);
};
if komodo_timestamp() <= valid_until {
Ok(())
} else {
Err(
anyhow!("Terminal token is expired")
.status_code(StatusCode::UNAUTHORIZED),
)
}
}
}
pub async fn connect_terminal(
Query(ConnectTerminalQuery {
token,
terminal,
init,
}): Query<ConnectTerminalQuery>,
ws: WebSocketUpgrade,
) -> serror::Result<Response> {
if periphery_config().disable_terminals {
return Err(
anyhow!("Terminals are disabled in the periphery config")
.status_code(StatusCode::FORBIDDEN),
);
}
// Auth the connection with single use token
auth_tokens().check_token(token)?;
clean_up_terminals().await;
let terminal = get_terminal(&terminal).await?;
Ok(ws.on_upgrade(|mut socket| async move {
let init_res = async {
let (a, b) = terminal.history.bytes_parts();
if !a.is_empty() {
socket.send(Message::Binary(a)).await.context("Failed to send history part a")?;
}
if !b.is_empty() {
socket.send(Message::Binary(b)).await.context("Failed to send history part b")?;
}
if let Some(init) = init {
terminal
.stdin
.send(StdinMsg::Bytes(Bytes::from(init + "\n")))
.await
.context("Failed to run init command")?
}
anyhow::Ok(())
}.await;
if let Err(e) = init_res {
let _ = socket.send(Message::Text(format!("ERROR: {e:#}").into())).await;
let _ = socket.close().await;
return;
}
let (mut ws_write, mut ws_read) = socket.split();
let cancel = CancellationToken::new();
let ws_read = async {
loop {
let res = tokio::select! {
res = ws_read.next() => res,
_ = terminal.cancel.cancelled() => {
trace!("ws read: cancelled from outside");
break
},
_ = cancel.cancelled() => {
trace!("ws read: cancelled from inside");
break;
}
};
match res {
Some(Ok(Message::Binary(bytes)))
if bytes.first() == Some(&0x00) =>
{
// println!("Got ws read bytes - for stdin");
if let Err(e) = terminal.stdin.send(StdinMsg::Bytes(
Bytes::copy_from_slice(&bytes[1..]),
)).await {
debug!("WS -> PTY channel send error: {e:}");
terminal.cancel();
break;
};
}
Some(Ok(Message::Binary(bytes)))
if bytes.first() == Some(&0xFF) =>
{
// println!("Got ws read bytes - for resize");
if let Ok(dimensions) =
serde_json::from_slice::<ResizeDimensions>(&bytes[1..])
{
if let Err(e) =
terminal.stdin.send(StdinMsg::Resize(dimensions)).await
{
debug!("WS -> PTY channel send error: {e:}");
terminal.cancel();
break;
};
}
}
Some(Ok(Message::Text(text))) => {
trace!("Got ws read text");
if let Err(e) =
terminal.stdin.send(StdinMsg::Bytes(Bytes::from(text))).await
{
debug!("WS -> PTY channel send error: {e:?}");
terminal.cancel();
break;
};
}
Some(Ok(Message::Close(_))) => {
debug!("got ws read close");
cancel.cancel();
break;
}
Some(Ok(_)) => {
// Do nothing (ping, non-prefixed bytes, etc.)
}
Some(Err(e)) => {
debug!("Got ws read error: {e:?}");
cancel.cancel();
break;
}
None => {
debug!("Got ws read none");
cancel.cancel();
break;
}
}
}
};
let ws_write = async {
let mut stdout = terminal.stdout.resubscribe();
loop {
let res = tokio::select! {
res = stdout.recv() => res.context("Failed to get message over stdout receiver"),
_ = terminal.cancel.cancelled() => {
trace!("ws write: cancelled from outside");
let _ = ws_write.send(Message::Text(Utf8Bytes::from_static("PTY KILLED"))).await;
if let Err(e) = ws_write.close().await {
debug!("Failed to close ws: {e:?}");
};
break
},
_ = cancel.cancelled() => {
let _ = ws_write.send(Message::Text(Utf8Bytes::from_static("WS KILLED"))).await;
if let Err(e) = ws_write.close().await {
debug!("Failed to close ws: {e:?}");
};
break
}
};
match res {
Ok(bytes) => {
if let Err(e) =
ws_write.send(Message::Binary(bytes)).await
{
debug!("Failed to send to WS: {e:?}");
cancel.cancel();
break;
}
}
Err(e) => {
debug!("PTY -> WS channel read error: {e:?}");
let _ = ws_write.send(Message::Text(Utf8Bytes::from(format!("ERROR: {e:#}")))).await;
let _ = ws_write.close().await;
terminal.cancel();
break;
}
}
}
};
tokio::join!(ws_read, ws_write);
clean_up_terminals().await;
}))
}

View File

@@ -42,6 +42,9 @@ pub fn periphery_config() -> &'static PeripheryConfig {
repo_dir: env.periphery_repo_dir.or(config.repo_dir),
stack_dir: env.periphery_stack_dir.or(config.stack_dir),
build_dir: env.periphery_build_dir.or(config.build_dir),
disable_terminals: env
.periphery_disable_terminals
.unwrap_or(config.disable_terminals),
stats_polling_rate: env
.periphery_stats_polling_rate
.unwrap_or(config.stats_polling_rate),

View File

@@ -12,9 +12,9 @@ mod compose;
mod config;
mod docker;
mod helpers;
mod router;
mod ssl;
mod stats;
mod terminal;
async fn app() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
@@ -35,8 +35,8 @@ async fn app() -> anyhow::Result<()> {
let socket_addr = SocketAddr::from_str(&addr)
.context("failed to parse listen address")?;
let app = router::router()
.into_make_service_with_connect_info::<SocketAddr>();
let app =
api::router().into_make_service_with_connect_info::<SocketAddr>();
if config.ssl_enabled {
info!("🔒 Periphery SSL Enabled");
@@ -73,7 +73,10 @@ async fn main() -> anyhow::Result<()> {
tokio::select! {
res = app => return res?,
_ = term_signal.recv() => {},
_ = term_signal.recv() => {
info!("Exiting all active Terminals for shutdown");
terminal::delete_all_terminals().await;
},
}
Ok(())

View File

@@ -201,5 +201,6 @@ fn get_system_information(
.next()
.map(|cpu| cpu.brand().to_string())
.unwrap_or_default(),
terminals_disabled: periphery_config().disable_terminals,
}
}

View File

@@ -0,0 +1,346 @@
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, OnceLock},
time::Duration,
};
use anyhow::{Context, anyhow};
use bytes::Bytes;
use komodo_client::{
api::write::TerminalRecreateMode, entities::server::TerminalInfo,
};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use tokio::sync::{broadcast, mpsc};
use tokio_util::sync::CancellationToken;
type PtyName = String;
type PtyMap = tokio::sync::RwLock<HashMap<PtyName, Arc<Terminal>>>;
type StdinSender = mpsc::Sender<StdinMsg>;
type StdoutReceiver = broadcast::Receiver<Bytes>;
pub async fn create_terminal(
name: String,
command: String,
recreate: TerminalRecreateMode,
) -> anyhow::Result<()> {
trace!(
"CreateTerminal: {name} | command: {command} | recreate: {recreate:?}"
);
let mut terminals = terminals().write().await;
use TerminalRecreateMode::*;
if matches!(recreate, Never | DifferentCommand) {
if let Some(terminal) = terminals.get(&name) {
if terminal.command == command {
return Ok(());
} else if matches!(recreate, Never) {
return Err(anyhow!(
"Terminal {name} already exists, but has command {} instead of {command}",
terminal.command
));
}
}
}
if let Some(prev) = terminals.insert(
name,
Terminal::new(command)
.await
.context("Failed to init terminal")?
.into(),
) {
prev.cancel();
}
Ok(())
}
pub async fn delete_terminal(name: &str) {
if let Some(terminal) = terminals().write().await.remove(name) {
terminal.cancel.cancel();
}
}
pub async fn list_terminals() -> Vec<TerminalInfo> {
let mut terminals = terminals()
.read()
.await
.iter()
.map(|(name, terminal)| TerminalInfo {
name: name.to_string(),
command: terminal.command.clone(),
stored_size_kb: terminal.history.size_kb(),
})
.collect::<Vec<_>>();
terminals.sort_by(|a, b| a.name.cmp(&b.name));
terminals
}
pub async fn get_terminal(
name: &str,
) -> anyhow::Result<Arc<Terminal>> {
terminals()
.read()
.await
.get(name)
.cloned()
.with_context(|| format!("No terminal at {name}"))
}
pub async fn clean_up_terminals() {
terminals()
.write()
.await
.retain(|_, terminal| !terminal.cancel.is_cancelled());
}
pub async fn delete_all_terminals() {
terminals()
.write()
.await
.drain()
.for_each(|(_, terminal)| terminal.cancel());
// The terminals poll cancel every 500 millis, need to wait for them
// to finish cancelling.
tokio::time::sleep(Duration::from_millis(100)).await;
}
fn terminals() -> &'static PtyMap {
static TERMINALS: OnceLock<PtyMap> = OnceLock::new();
TERMINALS.get_or_init(Default::default)
}
#[derive(Clone, serde::Deserialize)]
pub struct ResizeDimensions {
rows: u16,
cols: u16,
}
#[derive(Clone)]
pub enum StdinMsg {
Bytes(Bytes),
Resize(ResizeDimensions),
}
pub struct Terminal {
/// The command that was used as the root command, eg `shell`
command: String,
pub cancel: CancellationToken,
pub stdin: StdinSender,
pub stdout: StdoutReceiver,
pub history: Arc<History>,
}
impl Terminal {
async fn new(command: String) -> anyhow::Result<Terminal> {
trace!("Creating terminal with command: {command}");
let terminal = native_pty_system()
.openpty(PtySize::default())
.context("Failed to open terminal")?;
let mut command_split = command.split(' ').map(|arg| arg.trim());
let cmd =
command_split.next().context("Command cannot be empty")?;
let mut cmd = CommandBuilder::new(cmd);
for arg in command_split {
cmd.arg(arg);
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let mut child = terminal
.slave
.spawn_command(cmd)
.context("Failed to spawn child command")?;
// Check the child didn't stop immediately (after a little wait) with error
tokio::time::sleep(Duration::from_millis(100)).await;
if let Some(status) = child
.try_wait()
.context("Failed to check child process exit status")?
{
return Err(anyhow!(
"Child process exited immediately with code {}",
status.exit_code()
));
}
let mut terminal_write = terminal
.master
.take_writer()
.context("Failed to take terminal writer")?;
let mut terminal_read = terminal
.master
.try_clone_reader()
.context("Failed to clone terminal reader")?;
let cancel = CancellationToken::new();
// CHILD WAIT TASK
let _cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
loop {
if _cancel.is_cancelled() {
trace!("child wait handle cancelled from outside");
if let Err(e) = child.kill() {
debug!("Failed to kill child | {e:?}");
}
break;
}
match child.try_wait() {
Ok(Some(code)) => {
debug!("child exited with code {code}");
_cancel.cancel();
break;
}
Ok(None) => {
std::thread::sleep(Duration::from_millis(500));
}
Err(e) => {
debug!("failed to wait for child | {e:?}");
_cancel.cancel();
break;
}
}
}
});
// WS (channel) -> STDIN TASK
// Theres only one consumer here, so use mpsc
let (stdin, mut channel_read) =
tokio::sync::mpsc::channel::<StdinMsg>(8192);
let _cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
loop {
if _cancel.is_cancelled() {
trace!("terminal write: cancelled from outside");
break;
}
match channel_read.blocking_recv() {
Some(StdinMsg::Bytes(bytes)) => {
if let Err(e) = terminal_write.write_all(&bytes) {
debug!("Failed to write to PTY: {e:?}");
_cancel.cancel();
break;
}
}
Some(StdinMsg::Resize(dimensions)) => {
if let Err(e) = terminal.master.resize(PtySize {
cols: dimensions.cols,
rows: dimensions.rows,
pixel_width: 0,
pixel_height: 0,
}) {
debug!("Failed to resize | {e:?}");
_cancel.cancel();
break;
};
}
None => {
debug!("WS -> PTY channel read error: Disconnected");
_cancel.cancel();
break;
}
}
}
});
let history = Arc::new(History::default());
// PTY -> WS (channel) TASK
// Uses broadcast to output to multiple client simultaneously
let (write, stdout) =
tokio::sync::broadcast::channel::<Bytes>(8192);
let _cancel = cancel.clone();
let _history = history.clone();
tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8192];
loop {
if _cancel.is_cancelled() {
trace!("terminal read: cancelled from outside");
break;
}
match terminal_read.read(&mut buf) {
Ok(0) => {
// EOF
trace!("Got PTY read EOF");
_cancel.cancel();
break;
}
Ok(n) => {
_history.push(&buf[..n]);
if let Err(e) =
write.send(Bytes::copy_from_slice(&buf[..n]))
{
debug!("PTY -> WS channel send error: {e:?}");
_cancel.cancel();
break;
}
}
Err(e) => {
debug!("Failed to read for PTY: {e:?}");
_cancel.cancel();
break;
}
}
}
});
trace!("terminal tasks spawned");
Ok(Terminal {
command,
cancel,
stdin,
stdout,
history,
})
}
pub fn cancel(&self) {
trace!("Cancel called");
self.cancel.cancel();
}
}
/// 1 MiB max history size per terminal
const MAX_BYTES: usize = 1024 * 1024;
pub struct History {
buf: std::sync::RwLock<VecDeque<u8>>,
}
impl Default for History {
fn default() -> Self {
History {
buf: VecDeque::with_capacity(MAX_BYTES).into(),
}
}
}
impl History {
/// Push some bytes, evicting the oldest when full.
fn push(&self, bytes: &[u8]) {
let mut buf = self.buf.write().unwrap();
for byte in bytes {
if buf.len() == MAX_BYTES {
buf.pop_front();
}
buf.push_back(*byte);
}
}
pub fn bytes_parts(&self) -> (Bytes, Bytes) {
let buf = self.buf.read().unwrap();
let (a, b) = buf.as_slices();
(Bytes::copy_from_slice(a), Bytes::copy_from_slice(b))
}
pub fn size_kb(&self) -> f64 {
self.buf.read().unwrap().len() as f64 / 1024.0
}
}

View File

@@ -0,0 +1,67 @@
## This is used to customize the shell prompt in Periphery container for Terminals
"$schema" = 'https://starship.rs/config-schema.json'
add_newline = true
format = "$time$hostname$container$memory_usage$all"
[character]
success_symbol = "[](bright-blue bold)"
error_symbol = "[](bright-red bold)"
[package]
disabled = true
[time]
format = "[$time](white dimmed) "
time_format = "%l:%M %p"
utc_time_offset = '-5'
disabled = true
[username]
format = "[ $user]($style) "
style_user = "bright-green"
show_always = true
[hostname]
format = "[ $hostname]($style) "
style = "bright-blue"
ssh_only = false
[directory]
format = "[ $path]($style)[$read_only]($read_only_style) "
style = "bright-cyan"
[git_branch]
format = "[ $symbol$branch(:$remote_branch)]($style) "
style = "bright-purple"
[git_status]
style = "bright-purple"
[rust]
format = "[ $symbol($version )]($style)"
symbol = "rustc "
style = "bright-red"
[nodejs]
format = "[ $symbol($version )]($style)"
symbol = "nodejs "
style = "bright-red"
[memory_usage]
format = "[ mem ${ram} ${ram_pct}]($style) "
threshold = -1
style = "white"
[cmd_duration]
format = "[ $duration]($style)"
style = "bright-yellow"
[container]
format = "[ 🦎 periphery container ]($style)"
style = "bright-green"
[aws]
disabled = true

View File

@@ -68,6 +68,7 @@
pub mod auth;
pub mod execute;
pub mod terminal;
pub mod read;
pub mod user;
pub mod write;

View File

@@ -13,7 +13,7 @@ use crate::entities::{
},
server::{
Server, ServerActionState, ServerListItem, ServerQuery,
ServerState,
ServerState, TerminalInfo,
},
stack::ComposeProject,
stats::{
@@ -628,3 +628,27 @@ pub struct GetServersSummaryResponse {
/// The number of disabled servers.
pub disabled: I64,
}
//
/// List the current terminals on specified server.
/// Response: [ListTerminalsResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoReadRequest)]
#[response(ListTerminalsResponse)]
#[error(serror::Error)]
pub struct ListTerminals {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub server: String,
/// Force a fresh call to Periphery for the list.
/// Otherwise the response will be cached for 30s
#[serde(default)]
pub fresh: bool,
}
#[typeshare]
pub type ListTerminalsResponse = Vec<TerminalInfo>;

View File

@@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
/// Query to connect to a terminal (interactive shell over websocket) on the given server.
/// TODO: Document calling.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ConnectTerminalQuery {
/// Server Id or name
pub server: String,
/// Each periphery can keep multiple terminals open.
/// If a terminals with the specified name already exists,
/// it will be attached to.
/// Otherwise a new terminal will be created for the command,
/// which will persist until it is deleted using
/// [DeleteTerminal][crate::api::write::server::DeleteTerminal]
pub terminal: String,
/// Optional. The initial command to execute on connection to the shell.
pub init: Option<String>,
}

View File

@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{
NoData,
server::{_PartialServerConfig, Server},
update::Update,
};
@@ -105,3 +106,83 @@ pub struct CreateNetwork {
/// The name of the network to create.
pub name: String,
}
//
/// Configures the behavior of [CreateTerminal] if the
/// specified terminal name already exists.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
pub enum TerminalRecreateMode {
/// Never kill the old terminal if it already exists.
/// If the command is different, returns error.
#[default]
Never,
/// Always kill the old terminal and create new one
Always,
/// Only kill and recreate if the command is different.
DifferentCommand,
}
/// Create a terminal on the server.
/// Response: [NoData]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(NoData)]
#[error(serror::Error)]
pub struct CreateTerminal {
/// Server Id or name
pub server: String,
/// The name of the terminal on the server to create.
pub name: String,
/// The shell command (eg `bash`) to init the shell.
///
/// This can also include args:
/// `docker exec -it container sh`
///
/// Default: `bash`
#[serde(default = "default_command")]
pub command: String,
/// Default: `Never`
#[serde(default)]
pub recreate: TerminalRecreateMode,
}
fn default_command() -> String {
String::from("bash")
}
//
/// Delete a terminal on the server.
/// Response: [NoData]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(NoData)]
#[error(serror::Error)]
pub struct DeleteTerminal {
/// Server Id or name
pub server: String,
/// The name of the terminal on the server to delete.
pub terminal: String,
}
/// Delete all terminals on the server.
/// Response: [NoData]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(NoData)]
#[error(serror::Error)]
pub struct DeleteAllTerminals {
/// Server Id or name
pub server: String,
}

View File

@@ -124,6 +124,8 @@ pub struct Env {
pub periphery_stack_dir: Option<PathBuf>,
/// Override `build_dir`
pub periphery_build_dir: Option<PathBuf>,
/// Override `disable_terminals`
pub periphery_disable_terminals: Option<bool>,
/// Override `stats_polling_rate`
pub periphery_stats_polling_rate: Option<Timelength>,
/// Override `legacy_compose_cli`
@@ -203,6 +205,12 @@ pub struct PeripheryConfig {
/// Default: empty
pub build_dir: Option<PathBuf>,
/// Whether to disable the terminal APIs
/// and disallow remote shell access.
/// Default: false
#[serde(default)]
pub disable_terminals: bool,
/// The rate at which the system stats will be polled to update the cache.
/// Default: `5-sec`
#[serde(default = "default_stats_polling_rate")]
@@ -298,6 +306,7 @@ impl Default for PeripheryConfig {
repo_dir: None,
stack_dir: None,
build_dir: None,
disable_terminals: Default::default(),
stats_polling_rate: default_stats_polling_rate(),
legacy_compose_cli: Default::default(),
logging: Default::default(),
@@ -324,6 +333,7 @@ impl PeripheryConfig {
repo_dir: self.repo_dir.clone(),
stack_dir: self.stack_dir.clone(),
build_dir: self.build_dir.clone(),
disable_terminals: self.disable_terminals,
stats_polling_rate: self.stats_polling_rate,
legacy_compose_cli: self.legacy_compose_cli,
logging: self.logging.clone(),

View File

@@ -1009,3 +1009,5 @@ pub enum ScheduleFormat {
English,
Cron,
}
pub const KOMODO_EXIT_DATA: &str = "__KOMODO_EXIT_DATA:";

View File

@@ -38,6 +38,8 @@ pub struct ServerListItemInfo {
pub send_mem_alerts: bool,
/// Whether server is configured to send disk alerts.
pub send_disk_alerts: bool,
/// Whether terminals are disabled for this Server.
pub terminals_disabled: bool,
}
#[typeshare(serialized_as = "Partial<ServerConfig>")]
@@ -276,6 +278,19 @@ pub struct ServerHealth {
pub disks: HashMap<PathBuf, ServerHealthState>,
}
/// Info about an active terminal on a server.
/// Retrieve with [ListTerminals][crate::api::read::server::ListTerminals].
#[typeshare]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct TerminalInfo {
/// The name of the terminal.
pub name: String,
/// The root program / args of the pty
pub command: String,
/// The size of the terminal history in memory.
pub stored_size_kb: f64,
}
/// Current pending actions on the server.
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]

View File

@@ -21,6 +21,8 @@ pub struct SystemInformation {
pub host_name: Option<String>,
/// The CPU's brand
pub cpu_brand: String,
/// Whether terminals are disabled on this Periphery
pub terminals_disabled: bool,
}
/// System stats stored on the database.

View File

@@ -1,5 +1,4 @@
use anyhow::{Context, anyhow};
use reqwest::StatusCode;
use serde::{Serialize, de::DeserializeOwned};
use serde_json::json;
use serror::deserialize_error;
@@ -208,7 +207,7 @@ impl KomodoClient {
let res =
req.send().await.context("failed to reach Komodo API")?;
let status = res.status();
if status == StatusCode::OK {
if status.is_success() {
match res.json().await {
Ok(res) => Ok(res),
Err(e) => Err(anyhow!("{e:#?}").context(status)),
@@ -236,7 +235,7 @@ impl KomodoClient {
.json(&body);
let res = req.send().context("failed to reach Komodo API")?;
let status = res.status();
if status == StatusCode::OK {
if status.is_success() {
match res.json() {
Ok(res) => Ok(res),
Err(e) => Err(anyhow!("{e:#?}").context(status)),

View File

@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "1.17.3",
"version": "1.17.4",
"description": "Komodo client package",
"homepage": "https://komo.do",
"main": "dist/lib.js",

View File

@@ -8,6 +8,7 @@ import {
import {
AuthRequest,
BatchExecutionResponse,
ConnectTerminalQuery,
ExecuteRequest,
ReadRequest,
Update,
@@ -42,7 +43,7 @@ export function KomodoClient(url: string, options: InitOptions) {
secret: options.type === "api-key" ? options.params.secret : undefined,
};
const request = async <Req, Res>(
const request = <Req, Res>(
path: "/auth" | "/user" | "/read" | "/execute" | "/write",
request: Req
): Promise<Res> =>
@@ -270,6 +271,62 @@ export function KomodoClient(url: string, options: InitOptions) {
}
};
const connect_terminal = ({
query,
on_message,
on_login,
on_open,
on_close,
}: {
query: ConnectTerminalQuery;
on_message?: (e: MessageEvent<any>) => void;
on_login?: () => void;
on_open?: () => void;
on_close?: () => void;
}) => {
const url_query = new URLSearchParams(
query as any as Record<string, string>
).toString();
const ws = new WebSocket(
url.replace("http", "ws") + "/ws/terminal?" + url_query
);
// Handle login on websocket open
ws.onopen = () => {
const login_msg: WsLoginMessage =
options.type === "jwt"
? {
type: "Jwt",
params: {
jwt: options.params.jwt,
},
}
: {
type: "ApiKeys",
params: {
key: options.params.key,
secret: options.params.secret,
},
};
ws.send(JSON.stringify(login_msg));
on_open?.();
};
ws.onmessage = (e) => {
if (e.data == "LOGGED_IN") {
ws.binaryType = "arraybuffer";
ws.onmessage = (e) => on_message?.(e);
on_login?.();
return;
} else {
on_message?.(e);
}
};
ws.onclose = () => on_close?.();
return ws;
};
return {
/**
* Call the `/auth` api.
@@ -360,5 +417,10 @@ export function KomodoClient(url: string, options: InitOptions) {
* Note. Awaiting this method will never finish.
*/
subscribe_to_update_websocket,
/**
* Subscribes to terminal io over websocket message,
* for use with xtermjs.
*/
connect_terminal,
};
}

View File

@@ -80,6 +80,7 @@ export type ReadResponses = {
GetHistoricalServerStats: Types.GetHistoricalServerStatsResponse;
ListServers: Types.ListServersResponse;
ListFullServers: Types.ListFullServersResponse;
ListTerminals: Types.ListTerminalsResponse;
// ==== DEPLOYMENT ====
GetDeploymentsSummary: Types.GetDeploymentsSummaryResponse;
@@ -213,6 +214,9 @@ export type WriteResponses = {
UpdateServer: Types.Server;
RenameServer: Types.Update;
CreateNetwork: Types.Update;
CreateTerminal: Types.NoData;
DeleteTerminal: Types.NoData;
DeleteAllTerminals: Types.NoData;
// ==== DEPLOYMENT ====
CreateDeployment: Types.Deployment;

View File

@@ -1999,6 +1999,8 @@ export interface SystemInformation {
host_name?: string;
/** The CPU's brand */
cpu_brand: string;
/** Whether terminals are disabled on this Periphery */
terminals_disabled: boolean;
}
export type GetSystemInformationResponse = SystemInformation;
@@ -3546,6 +3548,8 @@ export interface ServerListItemInfo {
send_mem_alerts: boolean;
/** Whether server is configured to send disk alerts. */
send_disk_alerts: boolean;
/** Whether terminals are disabled for this Server. */
terminals_disabled: boolean;
}
export type ServerListItem = ResourceListItem<ServerListItemInfo>;
@@ -3670,6 +3674,21 @@ export type ListSystemProcessesResponse = SystemProcess[];
export type ListTagsResponse = Tag[];
/**
* Info about an active terminal on a server.
* Retrieve with [ListTerminals][crate::api::read::server::ListTerminals].
*/
export interface TerminalInfo {
/** The name of the terminal. */
name: string;
/** The root program / args of the pty */
command: string;
/** The size of the terminal history in memory. */
stored_size_kb: number;
}
export type ListTerminalsResponse = TerminalInfo[];
export type ListUserGroupsResponse = UserGroup[];
export type ListUserTargetPermissionsResponse = Permission[];
@@ -4217,6 +4236,26 @@ export interface CommitSync {
sync: string;
}
/**
* Query to connect to a terminal (interactive shell over websocket) on the given server.
* TODO: Document calling.
*/
export interface ConnectTerminalQuery {
/** Server Id or name */
server: string;
/**
* Each periphery can keep multiple terminals open.
* If a terminals with the specified name already exists,
* it will be attached to.
* Otherwise a new terminal will be created for the command,
* which will persist until it is deleted using
* [DeleteTerminal][crate::api::write::server::DeleteTerminal]
*/
terminal: string;
/** Optional. The initial command to execute on connection to the shell. */
init?: string;
}
export interface Conversion {
/** reference on the server. */
local: string;
@@ -4607,6 +4646,44 @@ export interface CreateTag {
name: string;
}
/**
* Configures the behavior of [CreateTerminal] if the
* specified terminal name already exists.
*/
export enum TerminalRecreateMode {
/**
* Never kill the old terminal if it already exists.
* If the command is different, returns error.
*/
Never = "Never",
/** Always kill the old terminal and create new one */
Always = "Always",
/** Only kill and recreate if the command is different. */
DifferentCommand = "DifferentCommand",
}
/**
* Create a terminal on the server.
* Response: [NoData]
*/
export interface CreateTerminal {
/** Server Id or name */
server: string;
/** The name of the terminal on the server to create. */
name: string;
/**
* The shell command (eg `bash`) to init the shell.
*
* This can also include args:
* `docker exec -it container sh`
*
* Default: `bash`
*/
command: string;
/** Default: `Never` */
recreate?: TerminalRecreateMode;
}
/** **Admin only.** Create a user group. Response: [UserGroup] */
export interface CreateUserGroup {
/** The name to assign to the new UserGroup */
@@ -4658,6 +4735,15 @@ export interface DeleteAlerter {
id: string;
}
/**
* Delete all terminals on the server.
* Response: [NoData]
*/
export interface DeleteAllTerminals {
/** Server Id or name */
server: string;
}
/**
* Delete an api key for the calling user.
* Response: [NoData]
@@ -4851,6 +4937,17 @@ export interface DeleteTag {
id: string;
}
/**
* Delete a terminal on the server.
* Response: [NoData]
*/
export interface DeleteTerminal {
/** Server Id or name */
server: string;
/** The name of the terminal on the server to delete. */
terminal: string;
}
/**
* **Admin only**. Delete a user.
* Admins can delete any non-admin user.
@@ -6437,6 +6534,20 @@ export interface ListTags {
query?: MongoDocument;
}
/**
* List the current terminals on specified server.
* Response: [ListTerminalsResponse].
*/
export interface ListTerminals {
/** Id or name */
server: string;
/**
* Force a fresh call to Periphery for the list.
* Otherwise the response will be cached for 30s
*/
fresh?: boolean;
}
/**
* Paginated endpoint for updates matching optional query.
* More recent updates will be returned first.
@@ -7870,6 +7981,7 @@ export type ReadRequest =
| { type: "ListDockerImages", params: ListDockerImages }
| { type: "ListDockerVolumes", params: ListDockerVolumes }
| { type: "ListComposeProjects", params: ListComposeProjects }
| { type: "ListTerminals", params: ListTerminals }
| { type: "GetDeploymentsSummary", params: GetDeploymentsSummary }
| { type: "GetDeployment", params: GetDeployment }
| { type: "GetDeploymentContainer", params: GetDeploymentContainer }
@@ -7968,6 +8080,9 @@ export type WriteRequest =
| { type: "UpdateServer", params: UpdateServer }
| { type: "RenameServer", params: RenameServer }
| { type: "CreateNetwork", params: CreateNetwork }
| { type: "CreateTerminal", params: CreateTerminal }
| { type: "DeleteTerminal", params: DeleteTerminal }
| { type: "DeleteAllTerminals", params: DeleteAllTerminals }
| { type: "CreateDeployment", params: CreateDeployment }
| { type: "CopyDeployment", params: CopyDeployment }
| { type: "CreateDeploymentFromContainer", params: CreateDeploymentFromContainer }

View File

@@ -13,11 +13,15 @@ repository.workspace = true
# local
komodo_client.workspace = true
# mogh
serror.workspace = true
resolver_api.workspace = true
serror.workspace = true
# external
reqwest.workspace = true
anyhow.workspace = true
serde.workspace = true
tokio-tungstenite.workspace = true
serde_json.workspace = true
tracing.workspace = true
serde_qs.workspace = true
reqwest.workspace = true
tracing.workspace = true
anyhow.workspace = true
rustls.workspace = true
tokio.workspace = true
serde.workspace = true

View File

@@ -19,6 +19,7 @@ pub mod git;
pub mod image;
pub mod network;
pub mod stats;
pub mod terminal;
pub mod volume;
//

View File

@@ -0,0 +1,80 @@
use komodo_client::{
api::write::TerminalRecreateMode,
entities::{NoData, server::TerminalInfo},
};
use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone, Resolve)]
#[response(Vec<TerminalInfo>)]
#[error(serror::Error)]
pub struct ListTerminals {}
#[derive(Serialize, Deserialize, Debug, Clone, Resolve)]
#[response(NoData)]
#[error(serror::Error)]
pub struct CreateTerminal {
/// The name of the terminal to create
pub name: String,
/// The shell command (eg `bash`) to init the shell.
///
/// This can also include args:
/// `docker exec -it container sh`
#[serde(default = "default_command")]
pub command: String,
/// Default: `Never`
#[serde(default)]
pub recreate: TerminalRecreateMode,
}
fn default_command() -> String {
String::from("bash")
}
//
#[derive(Serialize, Deserialize, Debug, Clone, Resolve)]
#[response(NoData)]
#[error(serror::Error)]
pub struct DeleteTerminal {
/// The name of the terminal to delete
pub terminal: String,
}
//
#[derive(Serialize, Deserialize, Debug, Clone, Resolve)]
#[response(NoData)]
#[error(serror::Error)]
pub struct DeleteAllTerminals {}
//
/// Create a single use auth token to connect to periphery terminal websocket.
#[derive(Serialize, Deserialize, Debug, Clone, Resolve)]
#[response(CreateTerminalAuthTokenResponse)]
#[error(serror::Error)]
pub struct CreateTerminalAuthToken {}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateTerminalAuthTokenResponse {
pub token: String,
}
//
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ConnectTerminalQuery {
/// Use [CreateTerminalAuthToken] to create a single-use
/// token to send in the query.
pub token: String,
/// Each periphery can keep multiple terminals open.
/// If a terminal with the specified name already exists,
/// it will be attached to.
/// Otherwise a new terminal will be created,
/// which will persist until it is either exited via command (ie `exit`),
/// or deleted using [DeleteTerminal]
pub terminal: String,
/// Optional. The initial command to execute on connection to the shell.
pub init: Option<String>,
}

View File

@@ -8,6 +8,8 @@ use serde_json::json;
pub mod api;
mod terminal;
fn periphery_http_client() -> &'static reqwest::Client {
static PERIPHERY_HTTP_CLIENT: OnceLock<reqwest::Client> =
OnceLock::new();
@@ -95,12 +97,12 @@ impl PeripheryClient {
req.send().await.context("failed at request to periphery")?;
let status = res.status();
tracing::debug!(
"got response | type: {req_type} | {status} | body: {res:?}",
"got response | type: {req_type} | {status} | response: {res:?}",
);
if status == StatusCode::OK {
tracing::debug!("response ok, deserializing");
res.json().await.with_context(|| format!(
"failed to parse response to json | type: {req_type} | body: {request:?}"
"failed to parse response to json | type: {req_type} | request: {request:?}"
))
} else {
tracing::debug!("response is non-200");

View File

@@ -0,0 +1,125 @@
use std::sync::Arc;
use anyhow::Context;
use rustls::{ClientConfig, client::danger::ServerCertVerifier};
use tokio::net::TcpStream;
use tokio_tungstenite::{Connector, MaybeTlsStream, WebSocketStream};
use crate::{
PeripheryClient,
api::terminal::{ConnectTerminalQuery, CreateTerminalAuthToken},
};
impl PeripheryClient {
/// Handles ws connect and login.
/// Does not handle reconnect.
pub async fn connect_terminal(
&self,
terminal: String,
init: Option<String>,
) -> anyhow::Result<WebSocketStream<MaybeTlsStream<TcpStream>>> {
tracing::trace!(
"request | type: ConnectTerminal | terminal name: {terminal} | init command: {init:?}",
);
let token = self
.request(CreateTerminalAuthToken {})
.await
.context("Failed to create terminal auth token")?;
let query_str = serde_qs::to_string(&ConnectTerminalQuery {
token: token.token,
terminal,
init,
})
.context("Failed to serialize query string")?;
let url = format!(
"{}/terminal?{query_str}",
self.address.replacen("http", "ws", 1)
);
let (stream, _) = if url.starts_with("wss") {
tokio_tungstenite::connect_async_tls_with_config(
url,
None,
false,
Some(Connector::Rustls(Arc::new(
ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(
InsecureVerifier,
))
.with_no_client_auth(),
))),
)
.await
.context("failed to connect to websocket")?
} else {
tokio_tungstenite::connect_async(url)
.await
.context("failed to connect to websocket")?
};
Ok(stream)
}
}
#[derive(Debug)]
struct InsecureVerifier;
impl ServerCertVerifier for InsecureVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error>
{
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<
rustls::client::danger::HandshakeSignatureValid,
rustls::Error,
> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<
rustls::client::danger::HandshakeSignatureValid,
rustls::Error,
> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}

View File

@@ -120,11 +120,15 @@ KOMODO_HETZNER_TOKEN= # Alt: KOMODO_HETZNER_TOKEN_FILE
## Full variable list + descriptions are available here:
## 🦎 https://github.com/moghtech/komodo/blob/main/config/periphery.config.toml 🦎
## Specify the root directory used by Periphery agent.
PERIPHERY_ROOT_DIRECTORY=/etc/komodo
## Periphery passkeys must include KOMODO_PASSKEY to authenticate.
PERIPHERY_PASSKEYS=${KOMODO_PASSKEY}
## Specify the root directory used by Periphery agent.
PERIPHERY_ROOT_DIRECTORY=/etc/komodo
## Specify whether to disable the terminals feature
## and disallow remote shell access (inside the Periphery container).
PERIPHERY_DISABLE_TERMINALS=false
## Enable SSL using self signed certificates.
## Connect to Periphery at https://address:8120.

View File

@@ -18,6 +18,11 @@ services:
PERIPHERY_ROOT_DIRECTORY: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}
## Pass the same passkey as used by the Komodo Core connecting to this Periphery agent.
PERIPHERY_PASSKEYS: abc123
## Make server run over https
PERIPHERY_SSL_ENABLED: true
## Specify whether to disable the terminals feature
## and disallow remote shell access (inside the Periphery container).
PERIPHERY_DISABLE_TERMINALS: false
## If the disk size is overreporting, can use one of these to
## whitelist / blacklist the disks to filter them, whichever is easier.
## Accepts comma separated list of paths.

View File

@@ -48,10 +48,16 @@ root_directory = "/etc/komodo"
## Default: ${root_directory}/builds
# build_dir = "/etc/komodo/builds"
## Disable the terminal APIs and disallow remote shell access through Periphery.
## Env: PERIPHERY_DISABLE_TERMINALS
## Default: false
disable_terminals = false
## How often Periphery polls the host for system stats,
## like CPU / memory usage.
## like CPU / memory usage. To effectively disable polling,
## set this to something like 1-hr.
## Env: PERIPHERY_STATS_POLLING_RATE
## Options: 1-sec, 5-sec, 10-sec, 30-sec, 1-min
## Options: 1-sec, 5-sec, 10-sec, 30-sec, 1-min, 5-min, 30-min, 1-hr
## Default: 5-sec
stats_polling_rate = "5-sec"

View File

@@ -11,7 +11,7 @@ Connecting a server to Komodo has 2 steps:
## Install Periphery
You can install Periphery as a systemd managed process, run it as a [docker container](https://github.com/moghtech/komodo/pkgs/container/periphery), or do whatever you want with the binary.
You can install Periphery as a systemd managed process, run it as a [docker container](https://github.com/moghtech/komodo/pkgs/container/komodo-periphery), or do whatever you want with the binary.
:::warning
Allowing unintended access to the Periphery agent API is a security risk.

View File

@@ -5,7 +5,7 @@
- [FAQ, Tips, and Tricks](https://blog.foxxmd.dev/posts/komodo-tips-tricks) by [FoxxMD](https://github.com/FoxxMD)
- [Compose Environments Explained](https://blog.foxxmd.dev/posts/compose-envs-explained) by [FoxxMD](https://github.com/FoxxMD)
- [How To: Automate version updates for your self-hosted Docker containers with Gitea, Renovate, and Komodo](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo) by [TheNickOfTime](https://github.com/TheNickOfTime)
- [Setting up Komodo, comparison to Portainer, and FAQ](https://skyblog.one/komodo-the-better-alternative-to-portainer-for-container-management) by [Skyfay](https://skyblog.one/authors/)
### Community Alerters
These provide alerting implementations which can be used with the `Custom` Alerter type.
- [Discord](https://github.com/FoxxMD/deploy-discord-alerter) by [FoxxMD](https://github.com/FoxxMD)

View File

@@ -31,6 +31,8 @@
"@radix-ui/react-toggle-group": "1.1.2",
"@tanstack/react-query": "5.67.3",
"@tanstack/react-table": "8.21.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"ansi-to-html": "0.7.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -44,6 +46,7 @@
"react-dom": "19.0.0",
"react-minimal-pie-chart": "9.1.0",
"react-router-dom": "7.3.0",
"react-xtermjs": "^1.0.10",
"sanitize-html": "2.14.0",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7"

View File

@@ -1,5 +1,5 @@
import { AuthResponses, ExecuteResponses, ReadResponses, UserResponses, WriteResponses } from "./responses.js";
import { AuthRequest, ExecuteRequest, ReadRequest, Update, UpdateListItem, UserRequest, WriteRequest } from "./types.js";
import { AuthRequest, ConnectTerminalQuery, ExecuteRequest, ReadRequest, Update, UpdateListItem, UserRequest, WriteRequest } from "./types.js";
export * as Types from "./types.js";
type InitOptions = {
type: "jwt";
@@ -132,4 +132,15 @@ export declare function KomodoClient(url: string, options: InitOptions): {
cancel?: CancelToken;
on_cancel?: () => void;
}) => Promise<void>;
/**
* Subscribes to terminal io over websocket message,
* for use with xtermjs.
*/
connect_terminal: ({ query, on_message, on_login, on_open, on_close, }: {
query: ConnectTerminalQuery;
on_message?: (e: MessageEvent<any>) => void;
on_login?: () => void;
on_open?: () => void;
on_close?: () => void;
}) => WebSocket;
};

View File

@@ -16,7 +16,7 @@ export function KomodoClient(url, options) {
key: options.type === "api-key" ? options.params.key : undefined,
secret: options.type === "api-key" ? options.params.secret : undefined,
};
const request = async (path, request) => new Promise(async (res, rej) => {
const request = (path, request) => new Promise(async (res, rej) => {
try {
let response = await fetch(url + path, {
method: "POST",
@@ -155,6 +155,42 @@ export function KomodoClient(url, options) {
}
}
};
const connect_terminal = ({ query, on_message, on_login, on_open, on_close, }) => {
const url_query = new URLSearchParams(query).toString();
const ws = new WebSocket(url.replace("http", "ws") + "/ws/terminal?" + url_query);
// Handle login on websocket open
ws.onopen = () => {
const login_msg = options.type === "jwt"
? {
type: "Jwt",
params: {
jwt: options.params.jwt,
},
}
: {
type: "ApiKeys",
params: {
key: options.params.key,
secret: options.params.secret,
},
};
ws.send(JSON.stringify(login_msg));
on_open?.();
};
ws.onmessage = (e) => {
if (e.data == "LOGGED_IN") {
ws.binaryType = "arraybuffer";
ws.onmessage = (e) => on_message?.(e);
on_login?.();
return;
}
else {
on_message?.(e);
}
};
ws.onclose = () => on_close?.();
return ws;
};
return {
/**
* Call the `/auth` api.
@@ -245,5 +281,10 @@ export function KomodoClient(url, options) {
* Note. Awaiting this method will never finish.
*/
subscribe_to_update_websocket,
/**
* Subscribes to terminal io over websocket message,
* for use with xtermjs.
*/
connect_terminal,
};
}

View File

@@ -65,6 +65,7 @@ export type ReadResponses = {
GetHistoricalServerStats: Types.GetHistoricalServerStatsResponse;
ListServers: Types.ListServersResponse;
ListFullServers: Types.ListFullServersResponse;
ListTerminals: Types.ListTerminalsResponse;
GetDeploymentsSummary: Types.GetDeploymentsSummaryResponse;
GetDeployment: Types.GetDeploymentResponse;
GetDeploymentContainer: Types.GetDeploymentContainerResponse;
@@ -158,6 +159,9 @@ export type WriteResponses = {
UpdateServer: Types.Server;
RenameServer: Types.Update;
CreateNetwork: Types.Update;
CreateTerminal: Types.NoData;
DeleteTerminal: Types.NoData;
DeleteAllTerminals: Types.NoData;
CreateDeployment: Types.Deployment;
CopyDeployment: Types.Deployment;
CreateDeploymentFromContainer: Types.Deployment;

View File

@@ -2106,6 +2106,8 @@ export interface SystemInformation {
host_name?: string;
/** The CPU's brand */
cpu_brand: string;
/** Whether terminals are disabled on this Periphery */
terminals_disabled: boolean;
}
export type GetSystemInformationResponse = SystemInformation;
/** Info for a single disk mounted on the system. */
@@ -3514,6 +3516,8 @@ export interface ServerListItemInfo {
send_mem_alerts: boolean;
/** Whether server is configured to send disk alerts. */
send_disk_alerts: boolean;
/** Whether terminals are disabled for this Server. */
terminals_disabled: boolean;
}
export type ServerListItem = ResourceListItem<ServerListItemInfo>;
export type ListServersResponse = ServerListItem[];
@@ -3625,6 +3629,19 @@ export interface SystemProcess {
}
export type ListSystemProcessesResponse = SystemProcess[];
export type ListTagsResponse = Tag[];
/**
* Info about an active terminal on a server.
* Retrieve with [ListTerminals][crate::api::read::server::ListTerminals].
*/
export interface TerminalInfo {
/** The name of the terminal. */
name: string;
/** The root program / args of the pty */
command: string;
/** The size of the terminal history in memory. */
stored_size_kb: number;
}
export type ListTerminalsResponse = TerminalInfo[];
export type ListUserGroupsResponse = UserGroup[];
export type ListUserTargetPermissionsResponse = Permission[];
export type ListUsersResponse = User[];
@@ -4090,6 +4107,25 @@ export interface CommitSync {
/** Id or name */
sync: string;
}
/**
* Query to connect to a terminal (interactive shell over websocket) on the given server.
* TODO: Document calling.
*/
export interface ConnectTerminalQuery {
/** Server Id or name */
server: string;
/**
* Each periphery can keep multiple terminals open.
* If a terminals with the specified name already exists,
* it will be attached to.
* Otherwise a new terminal will be created for the command,
* which will persist until it is deleted using
* [DeleteTerminal][crate::api::write::server::DeleteTerminal]
*/
terminal: string;
/** Optional. The initial command to execute on connection to the shell. */
init?: string;
}
export interface Conversion {
/** reference on the server. */
local: string;
@@ -4449,6 +4485,42 @@ export interface CreateTag {
/** The name of the tag. */
name: string;
}
/**
* Configures the behavior of [CreateTerminal] if the
* specified terminal name already exists.
*/
export declare enum TerminalRecreateMode {
/**
* Never kill the old terminal if it already exists.
* If the command is different, returns error.
*/
Never = "Never",
/** Always kill the old terminal and create new one */
Always = "Always",
/** Only kill and recreate if the command is different. */
DifferentCommand = "DifferentCommand"
}
/**
* Create a terminal on the server.
* Response: [NoData]
*/
export interface CreateTerminal {
/** Server Id or name */
server: string;
/** The name of the terminal on the server to create. */
name: string;
/**
* The shell command (eg `bash`) to init the shell.
*
* This can also include args:
* `docker exec -it container sh`
*
* Default: `bash`
*/
command: string;
/** Default: `Never` */
recreate?: TerminalRecreateMode;
}
/** **Admin only.** Create a user group. Response: [UserGroup] */
export interface CreateUserGroup {
/** The name to assign to the new UserGroup */
@@ -4494,6 +4566,14 @@ export interface DeleteAlerter {
/** The id or name of the alerter to delete. */
id: string;
}
/**
* Delete all terminals on the server.
* Response: [NoData]
*/
export interface DeleteAllTerminals {
/** Server Id or name */
server: string;
}
/**
* Delete an api key for the calling user.
* Response: [NoData]
@@ -4667,6 +4747,16 @@ export interface DeleteTag {
/** The id of the tag to delete. */
id: string;
}
/**
* Delete a terminal on the server.
* Response: [NoData]
*/
export interface DeleteTerminal {
/** Server Id or name */
server: string;
/** The name of the terminal on the server to delete. */
terminal: string;
}
/**
* **Admin only**. Delete a user.
* Admins can delete any non-admin user.
@@ -6092,6 +6182,19 @@ export interface ListSystemProcesses {
export interface ListTags {
query?: MongoDocument;
}
/**
* List the current terminals on specified server.
* Response: [ListTerminalsResponse].
*/
export interface ListTerminals {
/** Id or name */
server: string;
/**
* Force a fresh call to Periphery for the list.
* Otherwise the response will be cached for 30s
*/
fresh?: boolean;
}
/**
* Paginated endpoint for updates matching optional query.
* More recent updates will be returned first.
@@ -7646,6 +7749,9 @@ export type ReadRequest = {
} | {
type: "ListComposeProjects";
params: ListComposeProjects;
} | {
type: "ListTerminals";
params: ListTerminals;
} | {
type: "GetDeploymentsSummary";
params: GetDeploymentsSummary;
@@ -7930,6 +8036,15 @@ export type WriteRequest = {
} | {
type: "CreateNetwork";
params: CreateNetwork;
} | {
type: "CreateTerminal";
params: CreateTerminal;
} | {
type: "DeleteTerminal";
params: DeleteTerminal;
} | {
type: "DeleteAllTerminals";
params: DeleteAllTerminals;
} | {
type: "CreateDeployment";
params: CreateDeployment;

View File

@@ -489,6 +489,22 @@ export var SyncWebhookAction;
SyncWebhookAction["Refresh"] = "Refresh";
SyncWebhookAction["Sync"] = "Sync";
})(SyncWebhookAction || (SyncWebhookAction = {}));
/**
* Configures the behavior of [CreateTerminal] if the
* specified terminal name already exists.
*/
export var TerminalRecreateMode;
(function (TerminalRecreateMode) {
/**
* Never kill the old terminal if it already exists.
* If the command is different, returns error.
*/
TerminalRecreateMode["Never"] = "Never";
/** Always kill the old terminal and create new one */
TerminalRecreateMode["Always"] = "Always";
/** Only kill and recreate if the command is different. */
TerminalRecreateMode["DifferentCommand"] = "DifferentCommand";
})(TerminalRecreateMode || (TerminalRecreateMode = {}));
export var HetznerDatacenter;
(function (HetznerDatacenter) {
HetznerDatacenter["Nuremberg1Dc3"] = "Nuremberg1Dc3";

View File

@@ -31,6 +31,8 @@ import {
import { RenameResource } from "@components/config/util";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { DeploymentTerminal } from "./terminal";
import { useEditPermissions } from "@pages/resource";
// const configOrLog = atomWithStorage("config-or-log-v1", "Config");
@@ -43,57 +45,68 @@ export const useFullDeployment = (id: string) =>
useRead("GetDeployment", { deployment: id }, { refetchInterval: 10_000 })
.data;
const ConfigOrLog = ({ id }: { id: string }) => {
const ConfigTabs = ({ id }: { id: string }) => {
const deployment = useDeployment(id);
if (!deployment) return null;
return <ConfigTabsInner deployment={deployment} />;
};
const ConfigTabsInner = ({
deployment,
}: {
deployment: Types.DeploymentListItem;
}) => {
// const [view, setView] = useAtom(configOrLog);
const [view, setView] = useLocalStorage("deployment-tabs-v1", "Config");
const state = useDeployment(id)?.info.state;
const [_view, setView] = useLocalStorage<"Config" | "Log" | "Terminal">(
"deployment-tabs-v1",
"Config"
);
const { canWrite: canWriteServer } = useEditPermissions({
type: "Server",
id: deployment.info.server_id,
});
const terminals_disabled =
useServer(deployment.info.server_id)?.info.terminals_disabled ?? true;
const state = deployment.info.state;
const logsDisabled =
state === undefined ||
state === Types.DeploymentState.Unknown ||
state === Types.DeploymentState.NotDeployed;
const terminalDisabled =
!canWriteServer ||
terminals_disabled ||
state !== Types.DeploymentState.Running;
const view =
(logsDisabled && _view === "Log") ||
(terminalDisabled && _view === "Terminal")
? "Config"
: _view;
const tabs = (
<TabsList className="justify-start w-fit">
<TabsTrigger value="Config" className="w-[110px]">
Config
</TabsTrigger>
<TabsTrigger value="Log" className="w-[110px]" disabled={logsDisabled}>
Log
</TabsTrigger>
{!terminalDisabled && (
<TabsTrigger value="Terminal" className="w-[110px]">
Terminal
</TabsTrigger>
)}
</TabsList>
);
return (
<Tabs
value={logsDisabled ? "Config" : view}
onValueChange={setView}
className="grid gap-4"
>
<Tabs value={view} onValueChange={setView as any} className="grid gap-4">
<TabsContent value="Config">
<DeploymentConfig
id={id}
titleOther={
<TabsList className="justify-start w-fit">
<TabsTrigger value="Config" className="w-[110px]">
Config
</TabsTrigger>
<TabsTrigger
value="Log"
className="w-[110px]"
disabled={logsDisabled}
>
Log
</TabsTrigger>
</TabsList>
}
/>
<DeploymentConfig id={deployment.id} titleOther={tabs} />
</TabsContent>
<TabsContent value="Log">
<DeploymentLogs
id={id}
titleOther={
<TabsList className="justify-start w-fit">
<TabsTrigger value="Config" className="w-[110px]">
Config
</TabsTrigger>
<TabsTrigger
value="Log"
className="w-[110px]"
disabled={logsDisabled}
>
Log
</TabsTrigger>
</TabsList>
}
/>
<DeploymentLogs id={deployment.id} titleOther={tabs} />
</TabsContent>
<TabsContent value="Terminal">
<DeploymentTerminal deployment={deployment} titleOther={tabs} />
</TabsContent>
</Tabs>
);
@@ -272,7 +285,7 @@ export const DeploymentComponents: RequiredResourceComponents = {
Page: {},
Config: ConfigOrLog,
Config: ConfigTabs,
DangerZone: ({ id }) => (
<>

View File

@@ -0,0 +1,21 @@
import { ReactNode } from "react";
import { ContainerTerminal } from "@components/terminal";
import { Types } from "komodo_client";
export const DeploymentTerminal = ({
deployment,
titleOther,
}: {
deployment: Types.DeploymentListItem;
titleOther?: ReactNode;
}) => {
return (
deployment.info.server_id && (
<ContainerTerminal
titleOther={titleOther}
server={deployment.info.server_id}
container_name={deployment.name}
/>
)
);
};

View File

@@ -38,6 +38,9 @@ import { ServerInfo } from "./info";
import { ServerStats } from "./stats";
import { RenameResource } from "@components/config/util";
import { GroupActions } from "@components/group-actions";
import { ServerTerminals } from "./terminal";
import { useEditPermissions } from "@pages/resource";
import { Card, CardHeader, CardTitle } from "@ui/card";
export const useServer = (id?: string) =>
useRead("ListServers", {}, { refetchInterval: 10_000 }).data?.find(
@@ -59,12 +62,14 @@ const Icon = ({ id, size }: { id?: string; size: number }) => {
);
};
const ConfigStatsDockerResources = ({ id }: { id: string }) => {
const ConfigTabs = ({ id }: { id: string }) => {
const [view, setView] = useLocalStorage<
"Config" | "Stats" | "Docker" | "Resources"
"Config" | "Stats" | "Docker" | "Resources" | "Terminals"
>(`server-${id}-tab`, "Config");
const is_admin = useUser().data?.admin ?? false;
const { canWrite } = useEditPermissions({ type: "Server", id });
const terminals_disabled = useServer(id)?.info.terminals_disabled ?? true;
const disable_non_admin_create =
useRead("GetCoreInfo", {}).data?.disable_non_admin_create ?? true;
@@ -109,6 +114,12 @@ const ConfigStatsDockerResources = ({ id }: { id: string }) => {
>
Resources
</TabsTrigger>
{!terminals_disabled && canWrite && (
<TabsTrigger value="Terminals" className="w-[110px]">
Terminals
</TabsTrigger>
)}
</TabsList>
);
return (
@@ -163,6 +174,32 @@ const ConfigStatsDockerResources = ({ id }: { id: string }) => {
</Section>
</Section>
</TabsContent>
<TabsContent value="Terminals">
{!terminals_disabled && canWrite && (
<ServerTerminals id={id} titleOther={tabsList} />
)}
{terminals_disabled && canWrite && (
<Section titleOther={tabsList}>
<Card>
<CardHeader>
<CardTitle>Terminals are disabled on this Server.</CardTitle>
</CardHeader>
</Card>
</Section>
)}
{!canWrite && (
<Section titleOther={tabsList}>
<Card>
<CardHeader>
<CardTitle>
User does not have permission to use Terminals.
</CardTitle>
</CardHeader>
</Card>
</Section>
)}
</TabsContent>
</Tabs>
);
};
@@ -451,7 +488,7 @@ export const ServerComponents: RequiredResourceComponents = {
Page: {},
Config: ConfigStatsDockerResources,
Config: ConfigTabs,
DangerZone: ({ id }) => (
<>

View File

@@ -0,0 +1,143 @@
import { Section } from "@components/layouts";
import { ReactNode, useEffect, useState } from "react";
import { useLocalStorage, useRead, useWrite } from "@lib/hooks";
import { Card, CardContent, CardHeader } from "@ui/card";
import { Badge } from "@ui/badge";
import { Button } from "@ui/button";
import { Loader2, Plus, RefreshCcw, X } from "lucide-react";
import { Terminal } from "@components/terminal";
export const ServerTerminals = ({
id,
titleOther,
}: {
id: string;
titleOther?: ReactNode;
}) => {
const { data: terminals, refetch: refetchTerminals } = useRead(
"ListTerminals",
{
server: id,
fresh: true,
},
{
refetchInterval: 5000,
}
);
const { mutateAsync: create_terminal, isPending: create_pending } =
useWrite("CreateTerminal");
const { mutateAsync: delete_terminal } = useWrite("DeleteTerminal");
const [_selected, setSelected] = useLocalStorage<{
selected: string | undefined;
}>(`server-${id}-selected-terminal-v1`, { selected: undefined });
const selected =
_selected.selected ??
terminals?.[0]?.name ??
next_terminal_name(terminals?.map((t) => t.name) ?? []);
const [_reconnect, _setReconnect] = useState(false);
const triggerReconnect = () => _setReconnect((r) => !r);
const create = async () => {
if (!terminals) return;
const name = next_terminal_name(terminals.map((t) => t.name));
await create_terminal({
server: id,
name,
command: "bash",
});
refetchTerminals();
setTimeout(() => {
setSelected({
selected: name,
});
}, 100);
};
useEffect(() => {
if (terminals && terminals.length === 0) {
create();
}
}, [terminals]);
return (
<Section titleOther={titleOther}>
<Card>
<CardHeader className="flex flex-row gap-4 items-center justify-between">
<div className="flex gap-4">
{terminals?.map(({ name: terminal }) => (
<Badge
key={terminal}
variant={terminal === selected ? "default" : "secondary"}
className="w-fit min-w-[150px] px-2 py-1 cursor-pointer flex gap-4 justify-between"
onClick={() => setSelected({ selected: terminal })}
>
{terminal}
<Button
className="p-1 h-fit"
variant="destructive"
onClick={async (e) => {
e.stopPropagation();
await delete_terminal({ server: id, terminal });
refetchTerminals();
if (selected === terminal) {
setSelected({ selected: undefined });
}
}}
>
<X className="w-4 h-4" />
</Button>
</Badge>
))}
{terminals && (
<Button
className="flex items-center gap-2"
variant="outline"
onClick={create}
disabled={create_pending}
>
New Terminal
{create_pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
</Button>
)}
</div>
<Button
className="flex items-center gap-2"
variant="secondary"
onClick={() => triggerReconnect()}
>
Reconnect
<RefreshCcw className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent className="min-h-[65vh]">
{terminals?.map(({ name: terminal }) => (
<Terminal
key={terminal}
server={id}
terminal={terminal}
selected={selected === terminal}
_reconnect={_reconnect}
/>
))}
</CardContent>
</Card>
</Section>
);
};
const next_terminal_name = (terminal_names: string[]) => {
for (let i = 1; i <= terminal_names.length + 1; i++) {
const name = `terminal ${i}`;
if (!terminal_names.includes(name)) {
return name;
}
}
// This shouldn't happen
return `terminal -1`;
};

View File

@@ -0,0 +1,282 @@
import { komodo_client, useLocalStorage, useWrite } from "@lib/hooks";
import { cn } from "@lib/utils";
import { useTheme } from "@ui/theme";
import { FitAddon } from "@xterm/addon-fit";
import { ITheme } from "@xterm/xterm";
import { Types } from "komodo_client";
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useXTerm, UseXTermProps } from "react-xtermjs";
import { Section } from "./layouts";
import { CardTitle } from "@ui/card";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { Input } from "@ui/input";
import { Button } from "@ui/button";
import { RefreshCcw } from "lucide-react";
const BASE_SHELLS = ["sh", "bash"];
export const ContainerTerminal = ({
server,
container_name,
titleOther,
}: {
server: string;
container_name: string;
titleOther?: ReactNode;
}) => {
const { mutateAsync: create_terminal } = useWrite("CreateTerminal");
const [_reconnect, _setReconnect] = useState(false);
const triggerReconnect = () => _setReconnect((r) => !r);
const [_clear, _setClear] = useState(false);
const [shell, setShell] = useLocalStorage(
`server-${server}-${container_name}-shell-v1`,
"sh"
);
const [otherShell, setOtherShell] = useState("");
let command = `docker exec -it ${container_name} ${shell}`;
useEffect(() => {
create_terminal({
server,
name: container_name,
command,
recreate: Types.TerminalRecreateMode.DifferentCommand,
}).then(() => _setClear((c) => !c));
}, [server, container_name, shell]);
return (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 mr-[16px]">
<CardTitle className="text-muted-foreground flex items-center gap-2">
docker exec -it {container_name}
<Select value={shell} onValueChange={setShell}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[
...BASE_SHELLS,
...(!BASE_SHELLS.includes(shell) ? [shell] : []),
].map((shell) => (
<SelectItem key={shell} value={shell}>
{shell}
</SelectItem>
))}
<Input
placeholder="other"
value={otherShell}
onChange={(e) => setOtherShell(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setShell(otherShell);
setOtherShell("");
} else {
e.stopPropagation();
}
}}
/>
</SelectGroup>
</SelectContent>
</Select>
</CardTitle>
<Button
className="flex items-center gap-2"
variant="secondary"
onClick={() => triggerReconnect()}
>
Reconnect
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
}
>
<div className="min-h-[65vh]">
<Terminal
server={server}
terminal={container_name}
selected={true}
command={command}
_clear={_clear}
_reconnect={_reconnect}
/>
</div>
</Section>
);
};
export const Terminal = ({
server,
terminal,
selected,
command,
_clear,
_reconnect,
}: {
server: string;
/** The terminal name to connect to */
terminal: string;
selected: boolean;
/** Pass the command to reconnect if it changes */
command?: string;
_clear?: boolean;
_reconnect: boolean;
}) => {
const { theme: __theme } = useTheme();
const _theme =
__theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: __theme;
const theme = _theme === "dark" ? DARK_THEME : LIGHT_THEME;
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon>(new FitAddon());
const resize = () => {
fitRef.current.fit();
if (term) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const json = JSON.stringify({
rows: term.rows,
cols: term.cols,
});
const buf = new Uint8Array(json.length + 1);
buf[0] = 0xff; // resize prefix
for (let i = 0; i < json.length; i++) buf[i + 1] = json.charCodeAt(i);
wsRef.current.send(buf);
}
term.focus();
}
};
const onStdin = (data: string) => {
// This is data user writes to stdin
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const buf = new Uint8Array(data.length + 1);
buf[0] = 0x00; // data prefix
for (let i = 0; i < data.length; i++) buf[i + 1] = data.charCodeAt(i);
wsRef.current.send(buf);
};
useEffect(resize, [selected]);
const params: UseXTermProps = useMemo(
() => ({
options: {
convertEol: false,
cursorBlink: true,
cursorStyle: "block",
fontFamily: "monospace",
scrollback: 5000,
// This is handled in ws on_message handler
scrollOnUserInput: false,
theme,
},
listeners: {
onResize: resize,
onData: onStdin,
},
addons: [fitRef.current],
}),
[theme]
);
const { instance: term, ref: termRef } = useXTerm(params);
const viewport = (term as any)?._core?.viewport?._viewportElement as
| HTMLDivElement
| undefined;
useEffect(() => {
if (!term || !viewport) return;
let delta = 0;
term.attachCustomWheelEventHandler((e) => {
e.preventDefault();
// This is used to make touchpad and mousewheel more similar
delta += Math.sign(e.deltaY) * Math.sqrt(Math.abs(e.deltaY)) * 20;
return false;
});
const int = setInterval(() => {
if (Math.abs(delta) < 1) return;
viewport.scrollTop += delta;
delta = 0;
}, 100);
return () => clearInterval(int);
}, [term, termRef.current]);
useEffect(() => {
if (!selected || !term) return;
term.clear();
let debounce = -1;
const ws = komodo_client().connect_terminal({
query: {
server,
terminal,
},
on_login: () => {
// console.log("logged in terminal");
},
on_open: resize,
on_message: (e) => {
term.write(new Uint8Array(e.data as ArrayBuffer), () => {
if (viewport) {
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}
clearTimeout(debounce);
debounce = setTimeout(() => {
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}, 500);
});
},
on_close: () => {
term.writeln("\r\n\x1b[33m[connection closed]\x1b[0m");
},
});
wsRef.current = ws;
return () => {
ws.close();
wsRef.current = null;
};
}, [term, viewport, selected, command, _reconnect]);
useEffect(() => term?.clear(), [_clear]);
return (
<div
ref={termRef}
className={cn("w-full h-[65vh]", selected ? "" : "hidden")}
/>
);
};
const LIGHT_THEME: ITheme = {
background: "#f7f8f9",
foreground: "#24292e",
cursor: "#24292e",
selectionBackground: "#c8d9fa",
};
const DARK_THEME: ITheme = {
background: "#151b25",
foreground: "#f6f8fa",
cursor: "#ffffff",
selectionBackground: "#6e778a",
};

View File

@@ -92,6 +92,10 @@
position: absolute !important;
}
.asdfa {
color: #151b25
.xterm {
padding: 12px !important;
}
.xterm .xterm-viewport::-webkit-scrollbar {
@apply w-[16px] rounded-sm
}

View File

@@ -108,12 +108,15 @@ export const useManageUser = <
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().user<T, R>(type, params),
onError: (e: { result: { error?: string } }, v, c) => {
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
let msg_log = msg ? msg + " | " : "";
if (msg_log) {
msg_log = msg_log[0].toUpperCase() + msg_log.slice(1);
const msg = e.result?.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Request ${type} Failed`,
@@ -142,12 +145,15 @@ export const useWrite = <
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().write<T, R>(type, params),
onError: (e: { result: { error?: string } }, v, c) => {
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Write error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
let msg_log = msg ? msg + " - " : "";
if (msg_log) {
msg_log = msg_log[0].toUpperCase() + msg_log.slice(1);
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Write request ${type} failed`,
@@ -176,12 +182,15 @@ export const useExecute = <
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().execute<T, R>(type, params),
onError: (e: { result: { error?: string } }, v, c) => {
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Execute error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
let msg_log = msg ? msg + " | " : "";
if (msg_log) {
msg_log = msg_log[0].toUpperCase() + msg_log.slice(1);
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Execute request ${type} failed`,
@@ -210,12 +219,15 @@ export const useAuth = <
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().auth<T, R>(type, params),
onError: (e: { result: { error?: string } }, v, c) => {
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
let msg_log = msg ? msg + " | " : "";
if (msg_log) {
msg_log = msg_log[0].toUpperCase() + msg_log.slice(1);
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Auth request ${type} failed`,
@@ -526,3 +538,31 @@ export const useFilterByUpdateAvailable: () => [boolean, () => void] = () => {
const [filter, set] = useAtom<boolean>(filter_by_update_available);
return [filter, () => set(!filter)];
};
// export function useReadableLines(stream: ReadableStream<string>): string[] {
// const [out, setOut] = useState<string[]>([]);
// const cancelRef = useRef<AbortController | null>(null);
// useEffect(() => {
// if (!stream) return;
// const aborter = new AbortController();
// cancelRef.current = aborter;
// setOut([]); // reset on new stream
// (async () => {
// try {
// for await (const line of lines(stream)) {
// if (aborter.signal.aborted) break;
// setOut((prev) => [...prev, line]); // append as we go
// }
// } catch (err) {
// if (err.name !== "AbortError") console.error(err);
// }
// })();
// return () => aborter.abort(); // stop when unmounted
// }, [stream]);
// return out;
// }

View File

@@ -9,7 +9,7 @@ import {
ResourcePageHeader,
ShowHideButton,
} from "@components/util";
import { useRead, useSetTitle, useWrite } from "@lib/hooks";
import { useLocalStorage, useRead, useSetTitle, useWrite } from "@lib/hooks";
import { Button } from "@ui/button";
import { DataTable } from "@ui/data-table";
import {
@@ -31,6 +31,8 @@ import { useEditPermissions } from "@pages/resource";
import { ResourceNotifications } from "@pages/resource-notifications";
import { MonacoEditor } from "@components/monaco";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { ContainerTerminal } from "@components/terminal";
export const ContainerPage = () => {
const { type, id, container } = useParams() as {
@@ -182,8 +184,11 @@ const ContainerPageInner = ({
</Section>
)}
{/* Logs */}
<ContainerLogs id={id} container_name={container_name} />
<LogOrTerminal
server={id}
container_name={container_name}
state={state}
/>
{/* TOP LEVEL CONTAINER INFO */}
<Section title="Details" icon={<Info className="w-4 h-4" />}>
@@ -229,93 +234,61 @@ const ContainerPageInner = ({
</div>
</div>
);
};
// return (
// <div className="flex flex-col gap-16 mb-24">
// {/* HEADER */}
// <div className="flex flex-col gap-4">
// {/* BACK */}
// <div className="flex items-center justify-between mb-4">
// <Button
// className="gap-2"
// variant="secondary"
// onClick={() => nav("/servers/" + id)}
// >
// <ChevronLeft className="w-4" /> Back
// </Button>
// <NewDeployment id={id} container={container_name} />
// </div>
// {/* TITLE */}
// <div className="flex items-center gap-4">
// <div className="mt-1">
// <DOCKER_LINK_ICONS.container
// server_id={id}
// name={container_name}
// size={8}
// />
// </div>
// <DockerResourcePageName name={container_name} />
// <div className="flex items-center gap-4 flex-wrap">
// <StatusBadge
// text={state}
// intent={container_state_intention(state)}
// />
// {status && (
// <p className="text-sm text-muted-foreground">{status}</p>
// )}
// </div>
// </div>
// {/* INFO */}
// <div className="flex flex-wrap gap-4 items-center text-muted-foreground">
// <ResourceLink type="Server" id={id} />
// <AttachedResource id={id} container={container_name} />
// </div>
// </div>
// {/* Actions */}
// {canExecute && (
// <Section title="Actions" icon={<Clapperboard className="w-4 h-4" />}>
// <div className="flex gap-4 items-center flex-wrap">
// {Object.entries(Actions).map(([key, Action]) => (
// <Action key={key} id={id} container={container_name} />
// ))}
// </div>
// </Section>
// )}
// {/* Updates */}
// <ResourceUpdates type="Server" id={id} />
// <ContainerLogs id={id} container_name={container_name} />
// {/* TOP LEVEL CONTAINER INFO */}
// <Section title="Details" icon={<Info className="w-4 h-4" />}>
// <DataTable
// tableKey="container-info"
// data={[container]}
// columns={[
// {
// accessorKey: "Id",
// header: "Id",
// },
// {
// accessorKey: "Image",
// header: "Image",
// },
// {
// accessorKey: "Driver",
// header: "Driver",
// },
// ]}
// />
// </Section>
// <DockerLabelsSection labels={container.Config?.Labels} />
// </div>
// );
const LogOrTerminal = ({
server,
container_name,
state,
}: {
server: string;
container_name: string;
state: Types.ContainerStateStatusEnum;
}) => {
const [_view, setView] = useLocalStorage<"Log" | "Terminal">(
`server-${server}-${container_name}-tabs-v1`,
"Log"
);
const { canWrite } = useEditPermissions({
type: "Server",
id: server,
});
const terminals_disabled = useServer(server)?.info.terminals_disabled ?? true;
const terminalDisabled =
!canWrite ||
terminals_disabled ||
state !== Types.ContainerStateStatusEnum.Running;
const view = terminalDisabled && _view === "Terminal" ? "Log" : _view;
const tabs = (
<TabsList className="justify-start w-fit">
<TabsTrigger value="Log" className="w-[110px]">
Log
</TabsTrigger>
{!terminalDisabled && (
<TabsTrigger value="Terminal" className="w-[110px]">
Terminal
</TabsTrigger>
)}
</TabsList>
);
return (
<Tabs value={view} onValueChange={setView as any} className="grid gap-4">
<TabsContent value="Log">
<ContainerLogs
id={server}
container_name={container_name}
titleOther={tabs}
/>
</TabsContent>
<TabsContent value="Terminal">
<ContainerTerminal
server={server}
container_name={container_name}
titleOther={tabs}
/>
</TabsContent>
</Tabs>
);
};
const AttachedResource = ({

View File

@@ -1,17 +1,20 @@
import { Log, LogSection } from "@components/log";
import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { ReactNode } from "react";
export const ContainerLogs = ({
id,
container_name,
titleOther,
}: {
/// Server id
id: string;
container_name: string;
titleOther?: ReactNode;
}) => {
return (
<LogSection
titleOther={titleOther}
regular_logs={(timestamps, stream, tail) =>
NoSearchLogs(id, container_name, tail, timestamps, stream)
}

View File

@@ -16,7 +16,7 @@ import {
container_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { useRead, useSetTitle } from "@lib/hooks";
import { useLocalStorage, useRead, useSetTitle } from "@lib/hooks";
import { cn } from "@lib/utils";
import { Types } from "komodo_client";
import { ChevronLeft, Clapperboard, Layers2 } from "lucide-react";
@@ -28,6 +28,9 @@ import { DockerResourceLink, ResourcePageHeader } from "@components/util";
import { useEditPermissions } from "@pages/resource";
import { ResourceNotifications } from "@pages/resource-notifications";
import { Fragment } from "react/jsx-runtime";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { ContainerTerminal } from "@components/terminal";
import { useServer } from "@components/resources/server";
type IdServiceComponent = React.FC<{ id: string; service?: string }>;
@@ -183,9 +186,72 @@ const StackServicePageInner = ({
{/* Logs */}
<div className="pt-4">
<StackServiceLogs id={stack_id} service={service} />
{stack && (
<LogOrTerminal
stack={stack}
service={service}
container_name={container?.name}
container_state={state}
/>
)}
</div>
</div>
</div>
);
};
const LogOrTerminal = ({
stack,
service,
container_name,
container_state,
}: {
stack: Types.StackListItem;
service: string;
container_name: string | undefined;
container_state: Types.ContainerStateStatusEnum;
}) => {
const [_view, setView] = useLocalStorage<"Log" | "Terminal">(
`stack-${stack.id}-${service}-tabs-v1`,
"Log"
);
const { canWrite: canWriteServer } = useEditPermissions({
type: "Server",
id: stack.info.server_id,
});
const terminals_disabled =
useServer(stack.info.server_id)?.info.terminals_disabled ?? true;
const terminalDisabled =
!canWriteServer ||
terminals_disabled ||
container_state !== Types.ContainerStateStatusEnum.Running;
const view = terminalDisabled && _view === "Terminal" ? "Log" : _view;
const tabs = (
<TabsList className="justify-start w-fit">
<TabsTrigger value="Log" className="w-[110px]">
Log
</TabsTrigger>
{!terminalDisabled && (
<TabsTrigger value="Terminal" className="w-[110px]">
Terminal
</TabsTrigger>
)}
</TabsList>
);
return (
<Tabs value={view} onValueChange={setView as any} className="grid gap-4">
<TabsContent value="Log">
<StackServiceLogs id={stack.id} service={service} titleOther={tabs} />
</TabsContent>
<TabsContent value="Terminal">
{stack.info.server_id && container_name && (
<ContainerTerminal
server={stack.info.server_id}
container_name={container_name}
titleOther={tabs}
/>
)}
</TabsContent>
</Tabs>
);
};

View File

@@ -1,14 +1,17 @@
import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { Log, LogSection } from "@components/log";
import { ReactNode } from "react";
export const StackServiceLogs = ({
id,
service,
titleOther,
}: {
/// Stack id
id: string;
service: string;
titleOther?: ReactNode;
}) => {
// const stack = useStack(id);
const services = useRead("ListStackServices", { stack: id }).data;
@@ -19,19 +22,22 @@ export const StackServiceLogs = ({
return null;
}
return <StackLogsInner id={id} service={service} />;
return <StackLogsInner titleOther={titleOther} id={id} service={service} />;
};
const StackLogsInner = ({
id,
service,
titleOther,
}: {
/// Stack id
id: string;
service: string;
titleOther?: ReactNode;
}) => {
return (
<LogSection
titleOther={titleOther}
regular_logs={(timestamps, stream, tail) =>
NoSearchLogs(id, service, tail, timestamps, stream)
}

View File

@@ -2,10 +2,13 @@
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@@ -13,18 +16,26 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": false,
/* Paths */
"baseUrl": "./src",
"paths": { "@*": ["./*"] }
"paths": {
"@*": [
"./*"
]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -1447,6 +1447,16 @@
"@types/babel__core" "^7.20.5"
react-refresh "^0.14.2"
"@xterm/addon-fit@^0.10.0":
version "0.10.0"
resolved "https://registry.yarnpkg.com/@xterm/addon-fit/-/addon-fit-0.10.0.tgz#bebf87fadd74e3af30fdcdeef47030e2592c6f55"
integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==
"@xterm/xterm@^5.5.0":
version "5.5.0"
resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396"
integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -2875,6 +2885,11 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
react-xtermjs@^1.0.10:
version "1.0.10"
resolved "https://registry.yarnpkg.com/react-xtermjs/-/react-xtermjs-1.0.10.tgz#faac189f60ca599345b69b5c1a6b662f537df667"
integrity sha512-+xpKEKbmsypWzRKE0FR1LNIGcI8gx+R6VMHe8IQW7iTbgeqp3Qg7SbiVNOzR+Ovb1QK4DPA3KqsIQV+XP0iRUA==
react@19.0.0:
version "19.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd"