From 8831d372b08d935c15aebf96fbe08d3dddb99a23 Mon Sep 17 00:00:00 2001 From: codevski <1435321+codevski@users.noreply.github.com> Date: Wed, 20 May 2026 09:55:13 +1000 Subject: [PATCH] Enable default authentication and lock root to / --- CHANGELOG.md | 17 ++++++ README.md | 35 +++++++---- main.py | 63 ++++++++++++++++---- package.json | 2 +- src/SettingsModal.tsx | 93 +++++++++++++++++++++++++---- src/defaults.ts | 10 +--- src/index.tsx | 134 ++++++++++++++++++++++-------------------- src/types.ts | 6 +- 8 files changed, 255 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b39df1a..b135468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project will be documented here. +## [0.1.4] - 2026-05-20 + +### Added +- Username/password authentication, on by default with credentials `deck` / `deck` (editable in Settings). +- Anonymous (no-password) mode as an opt-in toggle, gated by a confirmation prompt. +- Login row on the main panel showing the active username (or "anonymous"). + +### Changed +- Root directory is now locked to `/`. Navigate to subdirectories from your FTP client. This sidesteps a "no access" bug seen when the configured root resolved through a symlink (notably `/run/media` for the SD card). +- Settings modal: removed the Root directory field; added Username, Password, and Anonymous access controls. +- Quick Paths panel removed from the main screen. +- Settings moved from a bottom button to a gear icon in the panel title bar. The Options section is gone; the QAM panel is now action-only. +- Login and Sharing rows merged into a single line (` Β· /`) to tighten the FTP Server section. + +### Migration +- Existing installations with a custom `root_dir` setting will start serving `/` after upgrade. Authentication is on by default, so existing users automatically gain credential protection. + ## [0.1.3] - 2026-04-18 ### Fixed diff --git a/README.md b/README.md index 417c6bb..85672db 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ * πŸ”— **Instant connect** β€” your Deck's local IP and port are displayed right in the panel * πŸ“ **Full read/write access** to `/` - Games, saves, emulators, homebrew all transferable. -* πŸ”Œ **Zero config** β€” anonymous login, no credentials to set up +* πŸ”’ **Authentication on by default** β€” default credentials `deck` / `deck`, fully editable in Settings. Anonymous mode is opt-in. * ⚑ **Fully offline** β€” no internet required on the Deck after install * πŸ›‘οΈ **Local network only** β€” never exposed to the public internet @@ -52,13 +52,15 @@ ### Connecting -| Field | Value | -|----------|------------------------------| -| Protocol | FTP (not SFTP or FTP-SSL) | -| Host | IP shown in the QAM panel | -| Port | `2121` | -| Username | `anonymous` (or leave blank) | -| Password | *(anything or empty)* | +| Field | Value | +|----------|-------------------------------------------| +| Protocol | FTP (not SFTP or FTP-SSL) | +| Host | IP shown in the QAM panel | +| Port | `2121` | +| Username | `deck` (default β€” change in Settings) | +| Password | `deck` (default β€” change in Settings) | + +> Prefer no-password access on a trusted home network? Enable **Anonymous access** in Settings and confirm the warning prompt. ### Recommended FTP clients @@ -121,10 +123,21 @@ You can [download](https://github.com/codevski/decky-ftpd/releases) the latest r ## Roadmap -- [x] Settings page β€” custom port, root directory, passive port range -- [ ] Optional username/password auth -- [ ] MicroSD card quick-access shortcut +### Server +- [x] Settings page, custom port, passive port range +- [x] Username/password authentication (default on, anonymous opt-in) - [ ] Active connection count in the status line +- [ ] Configurable root directory (reintroduce safely once the symlink edge cases are sorted) + +### Client (PSP/3DS Sync) +- [ ] Pull mode connect to a remote FTP server and mirror a path locally +- [ ] Configurable remote IP, remote path, and local destination +- [ ] Remembered last-used remote IP (persisted via settingsManager) +- [ ] Progress feedback and sync log in QAM +- [ ] Auto-detect SD card destinations for sync target + +### Future +- [ ] Steam Machine compatibility (dynamic mount point detection) ## Credits diff --git a/main.py b/main.py index 08cc98b..8e1a10f 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,8 @@ PY_MODULES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "py_modules") +FTP_ROOT = "/" + class Plugin: _server: "FTPServer | None" = None @@ -27,9 +29,11 @@ class Plugin: DEFAULTS = { "port": 2121, - "root_dir": "/", "passive_port_start": 50000, "passive_port_end": 50100, + "username": "deck", + "password": "deck", + "anonymous": False, } async def _main(self): @@ -44,9 +48,10 @@ async def _main(self): self._settings = settings decky.logger.info( - "decky-ftpd loaded (port=%d, root=%s)", + "decky-ftpd loaded (port=%d, root=%s, anonymous=%s)", self._get("port"), - self._get("root_dir"), + FTP_ROOT, + self._get("anonymous"), ) async def _unload(self): @@ -67,7 +72,9 @@ async def _emit_status(self): "running": self._running, "ip": get_local_ip() if self._running else "", "port": self._get("port"), - "root": self._get("root_dir"), + "root": FTP_ROOT, + "username": self._get("username"), + "anonymous": bool(self._get("anonymous")), }, ) except Exception as e: @@ -83,10 +90,25 @@ async def start_server(self) -> dict: from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer + anonymous = bool(self._get("anonymous")) + username = str(self._get("username") or "").strip() + password = str(self._get("password") or "") + with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) authorizer = DummyAuthorizer() - authorizer.add_anonymous(self._get("root_dir"), perm="elradfmwMT") + if anonymous: + authorizer.add_anonymous(FTP_ROOT, perm="elradfmwMT") + else: + if not username or not password: + return { + "success": False, + "error": ( + "Username and password must be set, or enable " + "anonymous mode in settings." + ), + } + authorizer.add_user(username, password, FTP_ROOT, perm="elradfmwMT") p_start = self._get("passive_port_start") p_end = self._get("passive_port_end") @@ -105,7 +127,9 @@ class DeckFTPHandler(FTPHandler): def _serve(): decky.logger.info( - "decky-ftpd: server started on port %d", self._get("port") + "decky-ftpd: server started on port %d (anonymous=%s)", + self._get("port"), + anonymous, ) try: server.serve_forever() @@ -164,7 +188,9 @@ async def get_status(self) -> dict: "running": self._running, "ip": get_local_ip() if self._running else "", "port": self._get("port"), - "root": self._get("root_dir"), + "root": FTP_ROOT, + "username": self._get("username"), + "anonymous": bool(self._get("anonymous")), } def _get(self, key: str): @@ -179,18 +205,20 @@ async def save_settings(self, new_settings: dict) -> dict: assert self._settings is not None port = int(new_settings.get("port", self._get("port"))) - root = str(new_settings.get("root_dir", self._get("root_dir"))) p_start = int( new_settings.get("passive_port_start", self._get("passive_port_start")) ) p_end = int( new_settings.get("passive_port_end", self._get("passive_port_end")) ) + anonymous = bool(new_settings.get("anonymous", self._get("anonymous"))) + username = str( + new_settings.get("username", self._get("username") or "") + ).strip() + password = str(new_settings.get("password", self._get("password") or "")) if not (1024 <= port <= 65535): return {"success": False, "error": "Port must be 1024–65535."} - if not root.startswith("/"): - return {"success": False, "error": "Root must be an absolute path."} if not (1024 <= p_start <= 65535 and 1024 <= p_end <= 65535): return {"success": False, "error": "Passive ports must be 1024–65535."} if p_end <= p_start: @@ -203,11 +231,24 @@ async def save_settings(self, new_settings: dict) -> dict: "success": False, "error": "Control port must not sit inside the passive range.", } + if not anonymous: + if not username: + return { + "success": False, + "error": "Username cannot be empty when anonymous mode is off.", + } + if not password: + return { + "success": False, + "error": "Password cannot be empty when anonymous mode is off.", + } self._settings.setSetting("port", port) - self._settings.setSetting("root_dir", root) self._settings.setSetting("passive_port_start", p_start) self._settings.setSetting("passive_port_end", p_end) + self._settings.setSetting("anonymous", anonymous) + self._settings.setSetting("username", username) + self._settings.setSetting("password", password) self._settings.commit() restarted = False diff --git a/package.json b/package.json index 926f94e..67a1cdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "decky-ftpd", - "version": "0.1.3", + "version": "0.1.4", "description": "FTP server for Steam Deck Game Mode, no desktop required", "type": "module", "scripts": { diff --git a/src/SettingsModal.tsx b/src/SettingsModal.tsx index 23977d9..bce5410 100644 --- a/src/SettingsModal.tsx +++ b/src/SettingsModal.tsx @@ -1,4 +1,11 @@ -import { ButtonItem, ModalRoot, TextField } from "@decky/ui"; +import { + ButtonItem, + ConfirmModal, + ModalRoot, + TextField, + ToggleField, + showModal, +} from "@decky/ui"; import { callable, toaster } from "@decky/api"; import { useEffect, useState } from "react"; import { FtpdSettings } from "./types"; @@ -6,7 +13,7 @@ import { DEFAULTS } from "./defaults"; const getSettings = callable<[], FtpdSettings>("get_settings"); const saveSettings = callable< - [Record], + [Record], { success: boolean; error?: string; restarted?: boolean } >("save_settings"); @@ -16,28 +23,64 @@ interface Props { export default function SettingsModal({ closeModal }: Props) { const [portStr, setPortStr] = useState(String(DEFAULTS.port)); - const [rootDir, setRootDir] = useState(DEFAULTS.root_dir); + const [username, setUsername] = useState(DEFAULTS.username); + const [password, setPassword] = useState(DEFAULTS.password); + const [anonymous, setAnonymous] = useState(DEFAULTS.anonymous); + const [anonToggleKey, setAnonToggleKey] = useState(0); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); useEffect(() => { getSettings() - .then((s) => { - const cur = s ?? DEFAULTS; + .then((setting) => { + const cur = setting ?? DEFAULTS; setPortStr(String(cur.port)); - setRootDir(cur.root_dir); + setUsername(cur.username ?? DEFAULTS.username); + setPassword(cur.password ?? DEFAULTS.password); + setAnonymous(Boolean(cur.anonymous)); }) .catch((e) => console.error("[decky-ftpd] get_settings failed", e)) .finally(() => setLoading(false)); }, []); + const handleAnonymousToggle = (next: boolean) => { + if (!next) { + setAnonymous(false); + return; + } + const resetVisualToggle = () => { + setAnonymous(false); + setAnonToggleKey((k) => k + 1); + }; + showModal( + setAnonymous(true)} + onCancel={resetVisualToggle} + onEscKeypress={resetVisualToggle} + />, + ); + }; + const onSave = async () => { setSaving(true); try { const res = await saveSettings({ port: portStr, - root_dir: rootDir.trim(), + username: username.trim(), + password, + anonymous, }); if (res.success) { toaster.toast({ @@ -77,12 +120,40 @@ export default function SettingsModal({ closeModal }: Props) { value={portStr} onChange={(e) => setPortStr(e.target.value)} /> + +
+ Authentication +
+ + setUsername(e.target.value)} + /> setRootDir(e.target.value)} + label="Password" + description="FTP login password. Default 'deck' β€” change this if you share a network." + value={password} + disabled={anonymous} + bIsPassword + onChange={(e) => setPassword(e.target.value)} /> + + {anonymous + ? "⚠ Anyone on your network can connect with no password and full read/write access. Only use on a trusted home network." + : "Off: a username and password are required to connect. Recommended."} + + } + checked={anonymous} + onChange={handleAnonymousToggle} + /> +
{saving ? "Saving…" : "Save & Restart Server"} diff --git a/src/defaults.ts b/src/defaults.ts index d1f7bb5..2876859 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -2,13 +2,9 @@ import { FtpdSettings } from "./types"; export const DEFAULTS: FtpdSettings = { port: 2121, - root_dir: "/", passive_port_start: 50000, passive_port_end: 50100, + username: "deck", + password: "deck", + anonymous: false, }; - -export const QUICK_PATHS: Array<{ label: string; path: string }> = [ - { label: "Home", path: "/home/deck" }, - { label: "SD Card", path: "/run/media" }, - { label: "Everything", path: "/" }, -]; diff --git a/src/index.tsx b/src/index.tsx index 4a47bbe..e344f39 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,25 +1,24 @@ import { - ButtonItem, PanelSection, PanelSectionRow, ToggleField, Field, + Focusable, + DialogButton, staticClasses, showModal, } from "@decky/ui"; import { addEventListener, removeEventListener, - callable, definePlugin, toaster, } from "@decky/api"; import { useState, useEffect, useCallback, useRef } from "react"; -import { FaNetworkWired } from "react-icons/fa"; +import { FaNetworkWired, FaCog } from "react-icons/fa"; import SettingsModal from "./SettingsModal"; import { FtpdStatus } from "./types"; import { getStatus, startServer, stopServer } from "./backend"; -import { QUICK_PATHS } from "./defaults"; function StatusDot({ running }: { running: boolean }) { return ( @@ -57,13 +56,68 @@ function AddressBadge({ ip, port }: { ip: string; port: number }) { ); } +function AnonymousBanner() { + return ( +
+
+ ⚠ Anonymous access enabled +
+
+ Anyone on your network can read and write to your Deck without a + password. Turn this off in Settings for a private connection. +
+
+ ); +} + +function TitleView() { + return ( + +
decky-ftpd
+ showModal()} + > + + +
+ ); +} + function Content() { const [running, setRunning] = useState(false); const [ip, setIp] = useState(""); const [port, setPort] = useState(21); const [root, setRoot] = useState("/"); + const [username, setUsername] = useState("deck"); + const [anonymous, setAnonymous] = useState(false); const [toggling, setToggling] = useState(false); - const [savingPath, setSavingPath] = useState(null); const topRef = useRef(null); const applyStatus = useCallback((s: FtpdStatus) => { @@ -71,27 +125,9 @@ function Content() { setIp(s.ip); setPort(s.port); setRoot(s.root); + setUsername(s.username ?? "deck"); + setAnonymous(Boolean(s.anonymous)); }, []); - const saveSettings = callable< - [Record], - { success: boolean; error?: string; restarted?: boolean } - >("save_settings"); - - const handleQuickPath = async (path: string) => { - if (path === root || savingPath !== null) return; - setSavingPath(path); - try { - const res = await saveSettings({ root_dir: path }); - if (!res.success) { - toaster.toast({ - title: "decky-ftpd β€” error", - body: res.error ?? "Failed to change path", - }); - } - } finally { - setSavingPath(null); - } - }; useEffect(() => { const resetScroll = () => { @@ -150,8 +186,11 @@ function Content() { } }; + const loginLabel = anonymous ? "anonymous" : username || "deck"; + return (
+ {anonymous && } - {root} + + {loginLabel} + + Β· + {root} } /> - - - {QUICK_PATHS.map((qp) => { - const active = root === qp.path; - return ( - - - {qp.path} - - } - onClick={() => handleQuickPath(qp.path)} - > - {active ? "βœ“ " : ""} - {qp.label} - - - ); - })} - - - - - showModal()} - > - Settings - - -
); } @@ -237,7 +245,7 @@ export default definePlugin(() => { console.log("decky-ftpd: frontend loaded"); return { name: "decky-ftpd", - titleView:
decky-ftpd
, + titleView: , content: , icon: , onDismount() { diff --git a/src/types.ts b/src/types.ts index 9cc0904..8fea2e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,13 +3,17 @@ export interface FtpdStatus { ip: string; port: number; root: string; + username: string; + anonymous: boolean; } export interface FtpdSettings { port: number; - root_dir: string; passive_port_start: number; passive_port_end: number; + username: string; + password: string; + anonymous: boolean; } export interface SaveResult {