Skip to content

Commit fcac7f0

Browse files
authored
Merge pull request #5 from codevski/feat/auth
Authentication
2 parents 1d41de4 + 8831d37 commit fcac7f0

8 files changed

Lines changed: 255 additions & 105 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
All notable changes to this project will be documented here.
44

5+
## [0.1.4] - 2026-05-20
6+
7+
### Added
8+
- Username/password authentication, on by default with credentials `deck` / `deck` (editable in Settings).
9+
- Anonymous (no-password) mode as an opt-in toggle, gated by a confirmation prompt.
10+
- Login row on the main panel showing the active username (or "anonymous").
11+
12+
### Changed
13+
- 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).
14+
- Settings modal: removed the Root directory field; added Username, Password, and Anonymous access controls.
15+
- Quick Paths panel removed from the main screen.
16+
- 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.
17+
- Login and Sharing rows merged into a single line (`<user> · /`) to tighten the FTP Server section.
18+
19+
### Migration
20+
- 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.
21+
522
## [0.1.3] - 2026-04-18
623

724
### Fixed

README.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
* 🔗 **Instant connect** — your Deck's local IP and port are displayed right in the panel
4040
* 📁 **Full read/write access** to `/`
4141
- Games, saves, emulators, homebrew all transferable.
42-
* 🔌 **Zero config**anonymous login, no credentials to set up
42+
* 🔒 **Authentication on by default**default credentials `deck` / `deck`, fully editable in Settings. Anonymous mode is opt-in.
4343
***Fully offline** — no internet required on the Deck after install
4444
* 🛡️ **Local network only** — never exposed to the public internet
4545

@@ -52,13 +52,15 @@
5252

5353
### Connecting
5454

55-
| Field | Value |
56-
|----------|------------------------------|
57-
| Protocol | FTP (not SFTP or FTP-SSL) |
58-
| Host | IP shown in the QAM panel |
59-
| Port | `2121` |
60-
| Username | `anonymous` (or leave blank) |
61-
| Password | *(anything or empty)* |
55+
| Field | Value |
56+
|----------|-------------------------------------------|
57+
| Protocol | FTP (not SFTP or FTP-SSL) |
58+
| Host | IP shown in the QAM panel |
59+
| Port | `2121` |
60+
| Username | `deck` (default — change in Settings) |
61+
| Password | `deck` (default — change in Settings) |
62+
63+
> Prefer no-password access on a trusted home network? Enable **Anonymous access** in Settings and confirm the warning prompt.
6264
6365
### Recommended FTP clients
6466

@@ -121,10 +123,21 @@ You can [download](https://github.com/codevski/decky-ftpd/releases) the latest r
121123

122124
## Roadmap
123125

124-
- [x] Settings page — custom port, root directory, passive port range
125-
- [ ] Optional username/password auth
126-
- [ ] MicroSD card quick-access shortcut
126+
### Server
127+
- [x] Settings page, custom port, passive port range
128+
- [x] Username/password authentication (default on, anonymous opt-in)
127129
- [ ] Active connection count in the status line
130+
- [ ] Configurable root directory (reintroduce safely once the symlink edge cases are sorted)
131+
132+
### Client (PSP/3DS Sync)
133+
- [ ] Pull mode connect to a remote FTP server and mirror a path locally
134+
- [ ] Configurable remote IP, remote path, and local destination
135+
- [ ] Remembered last-used remote IP (persisted via settingsManager)
136+
- [ ] Progress feedback and sync log in QAM
137+
- [ ] Auto-detect SD card destinations for sync target
138+
139+
### Future
140+
- [ ] Steam Machine compatibility (dynamic mount point detection)
128141

129142
## Credits
130143

main.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
PY_MODULES_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "py_modules")
1919

20+
FTP_ROOT = "/"
21+
2022

2123
class Plugin:
2224
_server: "FTPServer | None" = None
@@ -27,9 +29,11 @@ class Plugin:
2729

2830
DEFAULTS = {
2931
"port": 2121,
30-
"root_dir": "/",
3132
"passive_port_start": 50000,
3233
"passive_port_end": 50100,
34+
"username": "deck",
35+
"password": "deck",
36+
"anonymous": False,
3337
}
3438

3539
async def _main(self):
@@ -44,9 +48,10 @@ async def _main(self):
4448
self._settings = settings
4549

4650
decky.logger.info(
47-
"decky-ftpd loaded (port=%d, root=%s)",
51+
"decky-ftpd loaded (port=%d, root=%s, anonymous=%s)",
4852
self._get("port"),
49-
self._get("root_dir"),
53+
FTP_ROOT,
54+
self._get("anonymous"),
5055
)
5156

5257
async def _unload(self):
@@ -67,7 +72,9 @@ async def _emit_status(self):
6772
"running": self._running,
6873
"ip": get_local_ip() if self._running else "",
6974
"port": self._get("port"),
70-
"root": self._get("root_dir"),
75+
"root": FTP_ROOT,
76+
"username": self._get("username"),
77+
"anonymous": bool(self._get("anonymous")),
7178
},
7279
)
7380
except Exception as e:
@@ -83,10 +90,25 @@ async def start_server(self) -> dict:
8390
from pyftpdlib.handlers import FTPHandler
8491
from pyftpdlib.servers import FTPServer
8592

93+
anonymous = bool(self._get("anonymous"))
94+
username = str(self._get("username") or "").strip()
95+
password = str(self._get("password") or "")
96+
8697
with warnings.catch_warnings():
8798
warnings.simplefilter("ignore", RuntimeWarning)
8899
authorizer = DummyAuthorizer()
89-
authorizer.add_anonymous(self._get("root_dir"), perm="elradfmwMT")
100+
if anonymous:
101+
authorizer.add_anonymous(FTP_ROOT, perm="elradfmwMT")
102+
else:
103+
if not username or not password:
104+
return {
105+
"success": False,
106+
"error": (
107+
"Username and password must be set, or enable "
108+
"anonymous mode in settings."
109+
),
110+
}
111+
authorizer.add_user(username, password, FTP_ROOT, perm="elradfmwMT")
90112

91113
p_start = self._get("passive_port_start")
92114
p_end = self._get("passive_port_end")
@@ -105,7 +127,9 @@ class DeckFTPHandler(FTPHandler):
105127

106128
def _serve():
107129
decky.logger.info(
108-
"decky-ftpd: server started on port %d", self._get("port")
130+
"decky-ftpd: server started on port %d (anonymous=%s)",
131+
self._get("port"),
132+
anonymous,
109133
)
110134
try:
111135
server.serve_forever()
@@ -164,7 +188,9 @@ async def get_status(self) -> dict:
164188
"running": self._running,
165189
"ip": get_local_ip() if self._running else "",
166190
"port": self._get("port"),
167-
"root": self._get("root_dir"),
191+
"root": FTP_ROOT,
192+
"username": self._get("username"),
193+
"anonymous": bool(self._get("anonymous")),
168194
}
169195

170196
def _get(self, key: str):
@@ -179,18 +205,20 @@ async def save_settings(self, new_settings: dict) -> dict:
179205
assert self._settings is not None
180206

181207
port = int(new_settings.get("port", self._get("port")))
182-
root = str(new_settings.get("root_dir", self._get("root_dir")))
183208
p_start = int(
184209
new_settings.get("passive_port_start", self._get("passive_port_start"))
185210
)
186211
p_end = int(
187212
new_settings.get("passive_port_end", self._get("passive_port_end"))
188213
)
214+
anonymous = bool(new_settings.get("anonymous", self._get("anonymous")))
215+
username = str(
216+
new_settings.get("username", self._get("username") or "")
217+
).strip()
218+
password = str(new_settings.get("password", self._get("password") or ""))
189219

190220
if not (1024 <= port <= 65535):
191221
return {"success": False, "error": "Port must be 1024–65535."}
192-
if not root.startswith("/"):
193-
return {"success": False, "error": "Root must be an absolute path."}
194222
if not (1024 <= p_start <= 65535 and 1024 <= p_end <= 65535):
195223
return {"success": False, "error": "Passive ports must be 1024–65535."}
196224
if p_end <= p_start:
@@ -203,11 +231,24 @@ async def save_settings(self, new_settings: dict) -> dict:
203231
"success": False,
204232
"error": "Control port must not sit inside the passive range.",
205233
}
234+
if not anonymous:
235+
if not username:
236+
return {
237+
"success": False,
238+
"error": "Username cannot be empty when anonymous mode is off.",
239+
}
240+
if not password:
241+
return {
242+
"success": False,
243+
"error": "Password cannot be empty when anonymous mode is off.",
244+
}
206245

207246
self._settings.setSetting("port", port)
208-
self._settings.setSetting("root_dir", root)
209247
self._settings.setSetting("passive_port_start", p_start)
210248
self._settings.setSetting("passive_port_end", p_end)
249+
self._settings.setSetting("anonymous", anonymous)
250+
self._settings.setSetting("username", username)
251+
self._settings.setSetting("password", password)
211252
self._settings.commit()
212253

213254
restarted = False

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "decky-ftpd",
3-
"version": "0.1.3",
3+
"version": "0.1.4",
44
"description": "FTP server for Steam Deck Game Mode, no desktop required",
55
"type": "module",
66
"scripts": {

src/SettingsModal.tsx

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { ButtonItem, ModalRoot, TextField } from "@decky/ui";
1+
import {
2+
ButtonItem,
3+
ConfirmModal,
4+
ModalRoot,
5+
TextField,
6+
ToggleField,
7+
showModal,
8+
} from "@decky/ui";
29
import { callable, toaster } from "@decky/api";
310
import { useEffect, useState } from "react";
411
import { FtpdSettings } from "./types";
512
import { DEFAULTS } from "./defaults";
613

714
const getSettings = callable<[], FtpdSettings>("get_settings");
815
const saveSettings = callable<
9-
[Record<string, string | number>],
16+
[Record<string, string | number | boolean>],
1017
{ success: boolean; error?: string; restarted?: boolean }
1118
>("save_settings");
1219

@@ -16,28 +23,64 @@ interface Props {
1623

1724
export default function SettingsModal({ closeModal }: Props) {
1825
const [portStr, setPortStr] = useState(String(DEFAULTS.port));
19-
const [rootDir, setRootDir] = useState(DEFAULTS.root_dir);
26+
const [username, setUsername] = useState(DEFAULTS.username);
27+
const [password, setPassword] = useState(DEFAULTS.password);
28+
const [anonymous, setAnonymous] = useState(DEFAULTS.anonymous);
29+
const [anonToggleKey, setAnonToggleKey] = useState(0);
2030

2131
const [loading, setLoading] = useState(true);
2232
const [saving, setSaving] = useState(false);
2333

2434
useEffect(() => {
2535
getSettings()
26-
.then((s) => {
27-
const cur = s ?? DEFAULTS;
36+
.then((setting) => {
37+
const cur = setting ?? DEFAULTS;
2838
setPortStr(String(cur.port));
29-
setRootDir(cur.root_dir);
39+
setUsername(cur.username ?? DEFAULTS.username);
40+
setPassword(cur.password ?? DEFAULTS.password);
41+
setAnonymous(Boolean(cur.anonymous));
3042
})
3143
.catch((e) => console.error("[decky-ftpd] get_settings failed", e))
3244
.finally(() => setLoading(false));
3345
}, []);
3446

47+
const handleAnonymousToggle = (next: boolean) => {
48+
if (!next) {
49+
setAnonymous(false);
50+
return;
51+
}
52+
const resetVisualToggle = () => {
53+
setAnonymous(false);
54+
setAnonToggleKey((k) => k + 1);
55+
};
56+
showModal(
57+
<ConfirmModal
58+
strTitle="Disable authentication?"
59+
strDescription={
60+
"Anonymous mode means anyone on the same Wi-Fi network can read, " +
61+
"write, and delete files on your Steam Deck without a password. " +
62+
"Only enable this on a trusted home network you control.\n\n" +
63+
"Continue?"
64+
}
65+
strOKButtonText="Enable anonymous"
66+
strCancelButtonText="Cancel"
67+
bDestructiveWarning
68+
bAlertDialog
69+
onOK={() => setAnonymous(true)}
70+
onCancel={resetVisualToggle}
71+
onEscKeypress={resetVisualToggle}
72+
/>,
73+
);
74+
};
75+
3576
const onSave = async () => {
3677
setSaving(true);
3778
try {
3879
const res = await saveSettings({
3980
port: portStr,
40-
root_dir: rootDir.trim(),
81+
username: username.trim(),
82+
password,
83+
anonymous,
4184
});
4285
if (res.success) {
4386
toaster.toast({
@@ -77,12 +120,40 @@ export default function SettingsModal({ closeModal }: Props) {
77120
value={portStr}
78121
onChange={(e) => setPortStr(e.target.value)}
79122
/>
123+
124+
<div style={{ marginTop: 16, marginBottom: 4, fontWeight: 600 }}>
125+
Authentication
126+
</div>
127+
128+
<TextField
129+
label="Username"
130+
description="FTP login username. Default 'deck'."
131+
value={username}
132+
disabled={anonymous}
133+
onChange={(e) => setUsername(e.target.value)}
134+
/>
80135
<TextField
81-
label="Root directory"
82-
description="Absolute path exposed over FTP. Default / (full filesystem)."
83-
value={rootDir}
84-
onChange={(e) => setRootDir(e.target.value)}
136+
label="Password"
137+
description="FTP login password. Default 'deck' — change this if you share a network."
138+
value={password}
139+
disabled={anonymous}
140+
bIsPassword
141+
onChange={(e) => setPassword(e.target.value)}
85142
/>
143+
<ToggleField
144+
key={`anon-toggle-${anonToggleKey}`}
145+
label="Anonymous access (no password)"
146+
description={
147+
<span style={{ color: anonymous ? "#fca5a5" : "#94a3b8" }}>
148+
{anonymous
149+
? "⚠ Anyone on your network can connect with no password and full read/write access. Only use on a trusted home network."
150+
: "Off: a username and password are required to connect. Recommended."}
151+
</span>
152+
}
153+
checked={anonymous}
154+
onChange={handleAnonymousToggle}
155+
/>
156+
86157
<div style={{ marginTop: 16 }}>
87158
<ButtonItem layout="below" disabled={saving} onClick={onSave}>
88159
{saving ? "Saving…" : "Save & Restart Server"}

src/defaults.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,9 @@ import { FtpdSettings } from "./types";
22

33
export const DEFAULTS: FtpdSettings = {
44
port: 2121,
5-
root_dir: "/",
65
passive_port_start: 50000,
76
passive_port_end: 50100,
7+
username: "deck",
8+
password: "deck",
9+
anonymous: false,
810
};
9-
10-
export const QUICK_PATHS: Array<{ label: string; path: string }> = [
11-
{ label: "Home", path: "/home/deck" },
12-
{ label: "SD Card", path: "/run/media" },
13-
{ label: "Everything", path: "/" },
14-
];

0 commit comments

Comments
 (0)