1#![no_std]
2#![no_main]
3#![feature(alloc_error_handler)]
4
5extern crate alloc;
6
7use alloc::{format, string::String, vec::Vec};
8use base64::Engine;
9use core::{alloc::Layout, panic::PanicInfo};
10use libssh_strat9::{Server, SessionPump};
11use ssh_core::{
12 AuthProvider, CoreDirective, ExecSessionProvider, ExecSessionWiring, HostKeyProvider, SshCore,
13 SshCoreError, Transport,
14};
15use strat9_syscall::{call, error, flag};
16
17const GLOBAL_AUTH_PATHS: [&str; 2] = ["/initfs/etc/ssh/authorized_keys", "/initfs/authorized_keys"];
18const USER_AUTH_DIR: &str = "/initfs/etc/ssh/authorized_keys.d";
19const ALLOWED_COMMANDS_PATH: &str = "/initfs/etc/ssh/allowed_commands";
20const LISTEN_PORT_PATH: &str = "/initfs/etc/ssh/listen_port";
21const MAX_AUTH_TRIES_PATH: &str = "/initfs/etc/ssh/max_auth_tries";
22const DENY_ROOT_LOGIN_PATH: &str = "/initfs/etc/ssh/deny_root_login";
23const LOG_PATH: &str = "/var/log/sshd.log";
24const MAX_AUTH_KEYS: usize = 4096;
25
26alloc_freelist::define_freelist_brk_allocator!(
27 pub struct SshdAllocator;
28 brk = strat9_syscall::call::brk;
29 heap_max = 16 * 1024 * 1024;
30);
31
32#[global_allocator]
33static ALLOCATOR: SshdAllocator = SshdAllocator;
34
35#[alloc_error_handler]
36fn alloc_error(_layout: Layout) -> ! {
38 let _ = call::debug_log(b"[sshd] OOM\n");
39 call::exit(12)
40}
41
42#[panic_handler]
43fn panic(_info: &PanicInfo) -> ! {
45 let _ = call::debug_log(b"[sshd] panic\n");
46 call::exit(255)
47}
48
49fn open_log_fd() -> Option<usize> {
51 let flags = (flag::OpenFlags::WRONLY | flag::OpenFlags::CREATE | flag::OpenFlags::APPEND).bits()
52 as usize;
53 call::openat(0, LOG_PATH, flags, 0).ok()
54}
55
56fn log_with_fd(log_fd: Option<usize>, msg: &str) {
58 if let Some(fd) = log_fd {
59 let _ = call::write(fd, msg.as_bytes());
60 }
61 let _ = call::debug_log(msg.as_bytes());
62}
63
64fn read_file(path: &str) -> Option<Vec<u8>> {
66 let fd = call::openat(0, path, flag::OpenFlags::RDONLY.bits() as usize, 0).ok()?;
67 let mut out = Vec::new();
68 let mut chunk = [0u8; 1024];
69
70 loop {
71 match call::read(fd, &mut chunk) {
72 Ok(0) => break,
73 Ok(n) => out.extend_from_slice(&chunk[..n]),
74 Err(_) => {
75 let _ = call::close(fd);
76 return None;
77 }
78 }
79 }
80
81 let _ = call::close(fd);
82 Some(out)
83}
84
85fn parse_u32_ascii(data: &[u8]) -> Option<u32> {
87 let s = core::str::from_utf8(data).ok()?.trim();
88 if s.is_empty() {
89 return None;
90 }
91 let mut out: u32 = 0;
92 for &b in s.as_bytes() {
93 if !b.is_ascii_digit() {
94 return None;
95 }
96 out = out.checked_mul(10)?;
97 out = out.checked_add((b - b'0') as u32)?;
98 }
99 Some(out)
100}
101
102fn load_bool_flag(path: &str, default: bool) -> bool {
104 let Some(data) = read_file(path) else {
105 return default;
106 };
107 let Ok(s) = core::str::from_utf8(&data) else {
108 return default;
109 };
110 let v = s.trim();
111 if v.eq_ignore_ascii_case("1")
112 || v.eq_ignore_ascii_case("true")
113 || v.eq_ignore_ascii_case("yes")
114 || v.eq_ignore_ascii_case("on")
115 {
116 return true;
117 }
118 if v.eq_ignore_ascii_case("0")
119 || v.eq_ignore_ascii_case("false")
120 || v.eq_ignore_ascii_case("no")
121 || v.eq_ignore_ascii_case("off")
122 {
123 return false;
124 }
125 default
126}
127
128fn load_listen_port() -> u16 {
130 let Some(data) = read_file(LISTEN_PORT_PATH) else {
131 return 22;
132 };
133 let Some(port) = parse_u32_ascii(&data) else {
134 return 22;
135 };
136 if (1..=65535).contains(&port) {
137 port as u16
138 } else {
139 22
140 }
141}
142
143fn load_max_auth_tries() -> usize {
145 let Some(data) = read_file(MAX_AUTH_TRIES_PATH) else {
146 return 6;
147 };
148 let Some(v) = parse_u32_ascii(&data) else {
149 return 6;
150 };
151 let v = v.clamp(1, 32);
152 v as usize
153}
154
155fn open_listener(path: &str, log_fd: Option<usize>) -> usize {
157 let flags = flag::OpenFlags::RDWR.bits() as usize;
158 loop {
159 match call::openat(0, path, flags, 0) {
160 Ok(fd) => return fd,
161 Err(_) => {
162 log_with_fd(log_fd, "[sshd] waiting for /net tcp listener\n");
163 let _ = call::sched_yield();
164 }
165 }
166 }
167}
168
169struct NetTransport {
170 fd: usize,
171 connected: bool,
172 saw_zero_read: bool,
173}
174
175impl NetTransport {
176 fn new(fd: usize) -> Self {
178 Self {
179 fd,
180 connected: false,
181 saw_zero_read: false,
182 }
183 }
184
185 fn close(&mut self) {
187 let _ = call::close(self.fd);
188 }
189
190 fn saw_zero_read(&mut self) -> bool {
192 let v = self.saw_zero_read;
193 self.saw_zero_read = false;
194 v
195 }
196
197 fn is_connected(&self) -> bool {
199 self.connected
200 }
201}
202
203impl Transport for NetTransport {
204 fn recv(&mut self, out: &mut [u8]) -> ssh_core::Result<usize> {
206 match call::read(self.fd, out) {
207 Ok(0) => {
208 self.saw_zero_read = true;
209 Ok(0)
210 }
211 Ok(n) => {
212 self.connected = true;
213 self.saw_zero_read = false;
214 Ok(n)
215 }
216 Err(error::Error::Again) | Err(error::Error::Interrupted) => Ok(0),
217 Err(_) => Err(SshCoreError::Backend),
218 }
219 }
220
221 fn send(&mut self, data: &[u8]) -> ssh_core::Result<usize> {
223 let mut off = 0usize;
224 while off < data.len() {
225 match call::write(self.fd, &data[off..]) {
226 Ok(0) => return Err(SshCoreError::Backend),
227 Ok(n) => off += n,
228 Err(error::Error::Again) | Err(error::Error::Interrupted) => {
229 let _ = call::sched_yield();
230 }
231 Err(_) => return Err(SshCoreError::Backend),
232 }
233 }
234 Ok(off)
235 }
236}
237
238struct FixedHostKey {
239 key: &'static [u8],
240}
241
242impl HostKeyProvider for FixedHostKey {
243 fn host_public_key(&self) -> &[u8] {
245 self.key
246 }
247
248 fn sign_exchange_hash(
250 &mut self,
251 exchange_hash: &[u8],
252 out: &mut [u8],
253 ) -> ssh_core::Result<usize> {
254 if out.is_empty() {
255 return Err(SshCoreError::BufferTooSmall);
256 }
257 let mut acc: u8 = 0;
258 for b in exchange_hash {
259 acc ^= *b;
260 }
261 let n = out.len().min(32);
262 for (i, b) in out[..n].iter_mut().enumerate() {
263 *b = acc ^ (i as u8);
264 }
265 Ok(n)
266 }
267}
268
269struct AuthorizedKey {
270 algo: Vec<u8>,
271 key_blob: Vec<u8>,
272}
273
274struct PublicKeyAuth {
275 global_paths: &'static [&'static str],
276 user_dir: &'static str,
277 deny_root_login: bool,
278 global_keys: Vec<AuthorizedKey>,
279 global_fingerprint: u64,
280}
281
282impl PublicKeyAuth {
283 fn from_paths(
285 global_paths: &'static [&'static str],
286 user_dir: &'static str,
287 deny_root_login: bool,
288 ) -> Self {
289 let mut auth = Self {
290 global_paths,
291 user_dir,
292 deny_root_login,
293 global_keys: Vec::new(),
294 global_fingerprint: 0,
295 };
296 auth.reload_global();
297 auth
298 }
299
300 fn reload_global(&mut self) -> bool {
302 let keys = Self::load_keys_from_paths(self.global_paths);
303 let fp = Self::keys_fingerprint(&keys);
304 if fp != self.global_fingerprint || keys.len() != self.global_keys.len() {
305 self.global_keys = keys;
306 self.global_fingerprint = fp;
307 return true;
308 }
309 false
310 }
311
312 fn load_keys_from_paths(paths: &[&str]) -> Vec<AuthorizedKey> {
314 let mut keys = Vec::new();
315
316 for path in paths {
317 if let Some(data) = read_file(path) {
318 Self::parse_authorized_keys(&data, &mut keys);
319 if !keys.is_empty() {
320 break;
321 }
322 }
323 }
324
325 keys
326 }
327
328 fn load_user_keys(&self, username: &[u8]) -> Vec<AuthorizedKey> {
330 let Some(user) = Self::sanitize_username(username) else {
331 return Vec::new();
332 };
333
334 let mut path = String::from(self.user_dir);
335 if !path.ends_with('/') {
336 path.push('/');
337 }
338 path.push_str(&user);
339
340 let Some(data) = read_file(&path) else {
341 return Vec::new();
342 };
343
344 let mut keys = Vec::new();
345 Self::parse_authorized_keys(&data, &mut keys);
346 keys
347 }
348
349 fn sanitize_username(username: &[u8]) -> Option<String> {
351 if username.is_empty() || username.len() > 64 {
352 return None;
353 }
354
355 let mut out = String::with_capacity(username.len());
356 for &b in username {
357 let ok = matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b'.');
358 if !ok {
359 return None;
360 }
361 out.push(b as char);
362 }
363
364 Some(out)
365 }
366
367 fn parse_authorized_keys(data: &[u8], out: &mut Vec<AuthorizedKey>) {
369 let Ok(text) = core::str::from_utf8(data) else {
370 return;
371 };
372
373 for raw in text.lines() {
374 if out.len() >= MAX_AUTH_KEYS {
375 break;
376 }
377
378 let line = raw.trim();
379 if line.is_empty() || line.starts_with('#') {
380 continue;
381 }
382
383 let tokens: Vec<&str> = line.split_whitespace().collect();
384 if tokens.len() < 2 {
385 continue;
386 }
387
388 let Some(algo_idx) = Self::find_algo_index(&tokens) else {
389 continue;
390 };
391 if algo_idx + 1 >= tokens.len() {
392 continue;
393 }
394
395 let algo = tokens[algo_idx];
396 let blob_b64 = tokens[algo_idx + 1];
397
398 let Ok(blob) = base64::engine::general_purpose::STANDARD.decode(blob_b64.as_bytes())
399 else {
400 continue;
401 };
402
403 out.push(AuthorizedKey {
404 algo: algo.as_bytes().to_vec(),
405 key_blob: blob,
406 });
407 }
408 }
409
410 fn find_algo_index(tokens: &[&str]) -> Option<usize> {
412 let mut i = 0;
413 while i < tokens.len() {
414 if Self::looks_like_algo(tokens[i]) {
415 return Some(i);
416 }
417 i += 1;
418 }
419 None
420 }
421
422 fn looks_like_algo(token: &str) -> bool {
424 token.starts_with("ssh-")
425 || token.starts_with("ecdsa-")
426 || token.starts_with("sk-")
427 || token == "rsa-sha2-256"
428 || token == "rsa-sha2-512"
429 }
430
431 fn matches(keys: &[AuthorizedKey], algorithm: &[u8], public_key: &[u8]) -> bool {
433 for key in keys {
434 if key.algo.as_slice() == algorithm && key.key_blob.as_slice() == public_key {
435 return true;
436 }
437 }
438 false
439 }
440
441 fn keys_fingerprint(keys: &[AuthorizedKey]) -> u64 {
443 let mut hash = 0xcbf29ce484222325u64;
444 for key in keys {
445 for b in key.algo.iter().chain(key.key_blob.iter()) {
446 hash ^= *b as u64;
447 hash = hash.wrapping_mul(0x100000001b3);
448 }
449 hash ^= 0xff;
450 hash = hash.wrapping_mul(0x100000001b3);
451 }
452 hash
453 }
454}
455
456impl AuthProvider for PublicKeyAuth {
457 fn authorize_public_key(
459 &mut self,
460 username: &[u8],
461 algorithm: &[u8],
462 public_key: &[u8],
463 _signed_data: &[u8],
464 _signature: &[u8],
465 ) -> ssh_core::Result<bool> {
466 if self.deny_root_login && username == b"root" {
467 return Ok(false);
468 }
469
470 if Self::matches(&self.global_keys, algorithm, public_key) {
471 return Ok(true);
472 }
473
474 let user_keys = self.load_user_keys(username);
475 if Self::matches(&user_keys, algorithm, public_key) {
476 return Ok(true);
477 }
478
479 Ok(false)
480 }
481}
482
483struct ExecPolicy {
484 path: &'static str,
485 allowlist: Vec<String>,
486 fingerprint: u64,
487}
488
489impl ExecPolicy {
490 fn new(path: &'static str) -> Self {
492 let mut policy = Self {
493 path,
494 allowlist: Vec::new(),
495 fingerprint: 0,
496 };
497 let _ = policy.reload();
498 policy
499 }
500
501 fn reload(&mut self) -> bool {
503 let mut allowlist = Vec::new();
504
505 if let Some(data) = read_file(self.path) {
506 if let Ok(text) = core::str::from_utf8(&data) {
507 for raw in text.lines() {
508 let line = raw.trim();
509 if line.is_empty() || line.starts_with('#') {
510 continue;
511 }
512 if line.starts_with("/initfs/bin/") {
513 allowlist.push(String::from(line));
514 }
515 }
516 }
517 }
518
519 let fp = Self::fingerprint(&allowlist);
520 if fp != self.fingerprint || allowlist.len() != self.allowlist.len() {
521 self.allowlist = allowlist;
522 self.fingerprint = fp;
523 return true;
524 }
525 false
526 }
527
528 fn fingerprint(items: &[String]) -> u64 {
530 let mut hash = 0xcbf29ce484222325u64;
531 for item in items {
532 for b in item.as_bytes() {
533 hash ^= *b as u64;
534 hash = hash.wrapping_mul(0x100000001b3);
535 }
536 hash ^= 0xff;
537 hash = hash.wrapping_mul(0x100000001b3);
538 }
539 hash
540 }
541
542 fn is_allowed(&self, path: &str) -> bool {
544 if self.allowlist.is_empty() {
545 return true;
546 }
547 self.allowlist.iter().any(|p| p == path)
548 }
549}
550
551struct ExecProc {
552 session_id: u32,
553 pid: usize,
554}
555
556struct ExecPlan {
557 path: String,
558 args: Vec<String>,
559}
560
561struct ExecBridge {
562 session_seq: u32,
563 procs: Vec<ExecProc>,
564 policy: ExecPolicy,
565 log_fd: Option<usize>,
566}
567
568impl ExecBridge {
569 fn new(policy_path: &'static str, log_fd: Option<usize>) -> Self {
571 Self {
572 session_seq: 1,
573 procs: Vec::new(),
574 policy: ExecPolicy::new(policy_path),
575 log_fd,
576 }
577 }
578
579 fn reload_policy(&mut self) -> bool {
581 self.policy.reload()
582 }
583
584 fn terminate_all(&mut self) {
586 while let Some(proc) = self.procs.pop() {
587 let _ = call::kill(proc.pid as isize, 15);
588 let mut status = 0;
589 let _ = call::waitpid(proc.pid as isize, Some(&mut status), 0);
590 }
591 }
592
593 fn is_safe_exec_byte(b: u8) -> bool {
595 matches!(
596 b,
597 b'a'..=b'z'
598 | b'A'..=b'Z'
599 | b'0'..=b'9'
600 | b'/'
601 | b'.'
602 | b'-'
603 | b'_'
604 | b':'
605 | b'='
606 | b'+'
607 | b'@'
608 )
609 }
610
611 fn parse_exec_plan(&self, command: &[u8]) -> ssh_core::Result<ExecPlan> {
613 let text = core::str::from_utf8(command).map_err(|_| SshCoreError::Unsupported)?;
614 let trimmed = text.trim();
615
616 let mut args: Vec<String> = Vec::new();
617
618 if trimmed.is_empty() {
619 args.push(String::from("/initfs/bin/sh"));
620 } else {
621 for token in trimmed.split_whitespace() {
622 if token.is_empty() {
623 continue;
624 }
625 for b in token.as_bytes() {
626 if !Self::is_safe_exec_byte(*b) {
627 return Err(SshCoreError::Unsupported);
628 }
629 }
630 args.push(String::from(token));
631 if args.len() > 32 {
632 return Err(SshCoreError::Unsupported);
633 }
634 }
635 if args.is_empty() {
636 args.push(String::from("/initfs/bin/sh"));
637 }
638 }
639
640 let path = args[0].clone();
641 if !path.starts_with("/initfs/bin/") || !self.policy.is_allowed(&path) {
642 return Err(SshCoreError::Unsupported);
643 }
644
645 Ok(ExecPlan { path, args })
646 }
647
648 fn reap_exited(&mut self) {
650 loop {
651 match call::waitpid(-1, None, call::WNOHANG) {
652 Ok(0) => break,
653 Ok(pid) => self.drop_pid(pid),
654 Err(error::Error::NoChildren) | Err(error::Error::Again) => break,
655 Err(error::Error::Interrupted) => continue,
656 Err(_) => break,
657 }
658 }
659 }
660
661 fn drop_pid(&mut self, pid: usize) {
663 if let Some(idx) = self.procs.iter().position(|p| p.pid == pid) {
664 self.procs.swap_remove(idx);
665 }
666 }
667}
668
669impl ExecSessionProvider for ExecBridge {
670 fn spawn_exec(
672 &mut self,
673 _username: &[u8],
674 command: &[u8],
675 ) -> ssh_core::Result<ExecSessionWiring> {
676 let plan = self.parse_exec_plan(command)?;
677
678 let pid = call::fork().map_err(|_| SshCoreError::Backend)?;
679
680 if pid == 0 {
681 let _ = call::setpgid(0, 0);
682
683 let mut path_c = plan.path.as_bytes().to_vec();
684 path_c.push(0);
685
686 let mut argv_bytes: Vec<Vec<u8>> = Vec::with_capacity(plan.args.len());
687 for arg in plan.args {
688 let mut v = arg.into_bytes();
689 v.push(0);
690 argv_bytes.push(v);
691 }
692
693 let mut argv: Vec<usize> = Vec::with_capacity(argv_bytes.len() + 1);
694 for arg in &argv_bytes {
695 argv.push(arg.as_ptr() as usize);
696 }
697 argv.push(0);
698
699 let envp = [0usize];
700
701 let _ = unsafe {
703 call::execve(
704 path_c.as_slice(),
705 argv.as_ptr() as usize,
706 envp.as_ptr() as usize,
707 )
708 };
709 call::exit(127);
710 }
711
712 let session_id = self.session_seq;
713 self.session_seq = self.session_seq.wrapping_add(1);
714 self.procs.push(ExecProc { session_id, pid });
715
716 log_with_fd(self.log_fd, "[sshd] exec session started\n");
717
718 Ok(ExecSessionWiring {
719 session_id,
720 stdin_ring: 0,
721 stdout_ring: 0,
722 stderr_ring: 0,
723 })
724 }
725
726 fn close_exec(&mut self, wiring: &ExecSessionWiring) -> ssh_core::Result<()> {
728 let Some(idx) = self
729 .procs
730 .iter()
731 .position(|p| p.session_id == wiring.session_id)
732 else {
733 return Ok(());
734 };
735
736 let proc = self.procs.swap_remove(idx);
737 let _ = call::kill(proc.pid as isize, 15);
738 let mut status = 0;
739 let _ = call::waitpid(proc.pid as isize, Some(&mut status), 0);
740
741 log_with_fd(self.log_fd, "[sshd] exec session closed\n");
742 Ok(())
743 }
744}
745
746#[unsafe(no_mangle)]
747pub extern "C" fn _start() -> ! {
749 let log_fd = open_log_fd();
750 let listen_port = load_listen_port();
751 let deny_root_login = load_bool_flag(DENY_ROOT_LOGIN_PATH, true);
752 let max_auth_tries = load_max_auth_tries();
753 let listen_path = format!("/net/tcp/listen/{listen_port}");
754
755 log_with_fd(
756 log_fd,
757 "[sshd] started (network scheme + pubkey auth + policy reload + child reap)\n",
758 );
759
760 loop {
761 let fd = open_listener(&listen_path, log_fd);
762
763 let backend = ssh_core::default_backend();
764 let auth = PublicKeyAuth::from_paths(&GLOBAL_AUTH_PATHS, USER_AUTH_DIR, deny_root_login);
765 let host = FixedHostKey {
766 key: b"strat9-sshd-host-key",
767 };
768 let exec = ExecBridge::new(ALLOWED_COMMANDS_PATH, log_fd);
769
770 let core = SshCore::new(backend, auth, host, exec);
771 let mut server = Server::new(core);
772 let mut pump = SessionPump::new(NetTransport::new(fd));
773 let mut rx_buf = [0u8; 4096];
774 let mut tick = 0usize;
775 let mut auth_rejects = 0usize;
776
777 'session: loop {
778 let directives = match pump.pump_once(&mut server, &mut rx_buf) {
779 Ok(directives) => directives,
780 Err(_) => break 'session,
781 };
782
783 if directives.is_empty() {
784 let transport = pump.transport_mut();
785 if transport.saw_zero_read() && transport.is_connected() {
786 break 'session;
787 }
788 let _ = call::sched_yield();
789 }
790
791 let mut force_close = false;
792 for directive in &directives {
793 match directive {
794 CoreDirective::AuthAccepted { username } => {
795 auth_rejects = 0;
796 let user = core::str::from_utf8(username).unwrap_or("<invalid>");
797 let line = format!("[sshd] auth accepted user={user}\n");
798 log_with_fd(log_fd, &line);
799 }
800 CoreDirective::AuthRejected => {
801 auth_rejects = auth_rejects.saturating_add(1);
802 log_with_fd(log_fd, "[sshd] auth rejected\n");
803 if auth_rejects >= max_auth_tries {
804 log_with_fd(log_fd, "[sshd] max auth retries reached\n");
805 force_close = true;
806 }
807 }
808 CoreDirective::ExecStarted { .. } => {
809 log_with_fd(log_fd, "[sshd] exec started\n");
810 }
811 CoreDirective::CloseConnection => {
812 force_close = true;
813 }
814 CoreDirective::SendPacket(_) | CoreDirective::StdinData { .. } => {}
815 }
816 }
817
818 {
819 let core = server.core_mut();
820 if (tick & 0x7ff) == 0 {
821 if core.auth_mut().reload_global() {
822 log_with_fd(log_fd, "[sshd] authorized_keys reloaded\n");
823 }
824 if core.sessions_mut().reload_policy() {
825 log_with_fd(log_fd, "[sshd] command policy reloaded\n");
826 }
827 }
828 core.sessions_mut().reap_exited();
829 }
830
831 if force_close {
832 break 'session;
833 }
834
835 tick = tick.wrapping_add(1);
836 let _ = call::sched_yield();
837 }
838
839 {
840 let core = server.core_mut();
841 core.sessions_mut().terminate_all();
842 }
843 pump.transport_mut().close();
844 let _ = call::sched_yield();
845 }
846}