CH-J Server Managergerenciamento de servidor sobre SSH
E-mail
Fonte publicada

CH-J Server Manager

Navegue por diretórios e arquivos para uma versão específica do aplicativo.

Transferir fonte ZIP
CH-J / Server Manager 377 arquivos
core-20261019T111616Z-160a00bfcddc Adicionado: · Construído:
usersPlugin.test.js Adicionado: Data não registada 20,2 KB · 271 linhas
Código Transferir ficheiro
1"use strict";
3const test = require("node:test");
4const assert = require("node:assert/strict");
5const fs = require("node:fs");
6const path = require("node:path");
7const { EventEmitter } = require("node:events");
8const { SessionManager } = require("../src/main/sessions/sessionManager");
9const { PrivilegedExecutor, SUDO_PROMPT } = require("../src/main/sessions/privilegedExec");
10const keys = require("../src/main/users/authorizedKeys");
11const policy = require("../src/main/users/sshPolicy");
12const { UserAdminService, validateComment, validateHome, validateName, validateShell, splitSections } = require("../src/main/users/userAdminService");
14// Public test keys; fingerprints were computed independently with ssh-keygen -lf.
15const KEY_A = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMtHrhcqui+RtemmVnYdQeb4FFPCnLpDGHdbW1x27n3f fixture-a";
16const KEY_B = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDxTAuuNqjy7bQQmrG2sNb9sQ3D7YDvUolTWt9PplroB fixture-b";
17const KEY_RSA = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgoqY+n4WTJHhQDDG3/Wq2ikgvEMDAEqf659oJvPxoT1786rRE8b1pA/TO7HdR3huqWVEOFgTtsasnwzzYbtk30CsrjdnfIYDt8g6wQcEirkZ4zlhX7SDLmgytRytIEawCsPruj31gh6YoYKTr8XV2TxZtBlkZCbG47+MYUz7tNw== fixture-rsa";
18const FP_A = "SHA256:4wwpzucK1K7t/mhInHzZAspG1n28pLehytTkdkEty+4";
19const FP_B = "SHA256:YbW5raLxpO5Bw7+M/3TL7Oe13zRU0AJWZrJJ/UIvGWA";
20const FP_RSA = "SHA256:XU/sToLGlIj0r/7pEWX2OmVd/wVtkA2KhY4OOxKg48g";
22test("authorized_keys parsing matches ssh-keygen fingerprints and keeps options and comments", () => {
23 const content = `# comment line\r\n${KEY_RSA}\r\ncommand="echo a b",from="10.0.0.0/8" ${KEY_A}\n\ngarbage line here\n`;
24 const entries = keys.parseAuthorizedKeys(content);
25 assert.deepEqual(entries.map((entry) => [entry.line, entry.valid, entry.fingerprint || null]), [[2, true, FP_RSA], [3, true, FP_A], [5, false, null]]);
26 assert.equal(entries[1].options, 'command="echo a b",from="10.0.0.0/8"');
27 assert.equal(entries[1].comment, "fixture-a");
28 assert.equal(entries[1].ed25519, true);
29 assert.equal(entries[0].ed25519, false);
30});
32test("adding and removing a key changes exactly one line and preserves all other bytes", () => {
33 const original = Buffer.from(`# keep\r\n${KEY_RSA}\r\nno-pty ${KEY_A}`, "latin1");
34 const key = keys.normalizePublicKey(KEY_B);
35 assert.equal(key.fingerprint, FP_B);
36 const added = keys.appendKey(original, key);
37 assert.equal(added.toString("latin1"), `${original.toString("latin1")}\n${KEY_B}\n`);
38 assert.throws(() => keys.appendKey(added, keys.normalizePublicKey(KEY_B)), { code: "USERS_KEY_DUPLICATE" });
39 const entry = keys.parseAuthorizedKeys(added).find((candidate) => candidate.fingerprint === FP_A);
40 const removed = keys.removeKey(added, entry.line, FP_A);
41 assert.equal(removed.toString("latin1"), `# keep\r\n${KEY_RSA}\r\n${KEY_B}\n`);
42 assert.throws(() => keys.removeKey(added, entry.line, FP_B), { code: "USERS_KEY_NOT_FOUND" });
43 assert.throws(() => keys.removeKey(added, 99, FP_A), { code: "USERS_KEY_NOT_FOUND" });
44 // UTF-8 comments survive the latin1 byte-preserving round trip.
45 const utf8 = keys.appendKey(Buffer.alloc(0), keys.normalizePublicKey(KEY_A, "Jiří Novák"));
46 assert.equal(utf8.toString("utf8"), `${KEY_A.replace("fixture-a", "Jiří Novák")}\n`);
47 assert.equal(keys.parseAuthorizedKeys(utf8)[0].comment, "Jiří Novák");
48});
50test("only one plain Ed25519 public key is accepted for upload", () => {
51 assert.throws(() => keys.normalizePublicKey(KEY_RSA), { code: "USERS_KEY_TYPE_UNSUPPORTED" });
52 assert.throws(() => keys.normalizePublicKey(`${KEY_A}\n${KEY_B}`), { code: "USERS_KEY_INVALID" });
53 assert.throws(() => keys.normalizePublicKey(`command="id" ${KEY_A}`), { code: "USERS_KEY_INVALID" });
54 assert.throws(() => keys.normalizePublicKey("-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----"), { code: /USERS_KEY_(PRIVATE|INVALID)/ });
55 assert.throws(() => keys.normalizePublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMtH x"), { code: "USERS_KEY_INVALID" });
56 assert.throws(() => keys.normalizePublicKey(KEY_A.replace("AAAAC3NzaC1lZDI1NTE5", "AAAAB3NzaC1yc2EAAAAD")), { code: "USERS_KEY_INVALID" });
57 assert.equal(keys.normalizePublicKey(`${KEY_A}\r\n`).fingerprint, FP_A);
58});
60test("managed sshd policy is generated deterministically and manual edits are detected", () => {
61 const policies = new Map([
62 ["bob", policy.normalizePolicy({ ipv6: "deny", password: "no" })],
63 ["alice", policy.normalizePolicy({ access: "deny" })],
64 ["carol", policy.normalizePolicy({})]
65 ]);
66 const text = policy.generateManagedConfig(policies);
67 assert.match(text, /^# CH-J Server Manager/);
68 assert.match(text, /\nDenyUsers alice\nDenyUsers bob@::\/0\nMatch User bob\n\tPasswordAuthentication no\n\tKbdInteractiveAuthentication no\nMatch all\n$/);
69 assert.doesNotMatch(text, /carol/, "inherit-only policies are not written");
70 const parsed = policy.parseManagedConfig(text);
71 assert.equal(parsed.consistent, true);
72 assert.deepEqual([...parsed.policies.keys()], ["alice", "bob"]);
73 assert.equal(policy.parseManagedConfig(text.replace("DenyUsers alice\n", "")).consistent, false);
74 assert.equal(policy.parseManagedConfig(`${text}PermitRootLogin yes\n`).consistent, false);
75 assert.equal(policy.parseManagedConfig("").consistent, true);
76 assert.equal(policy.generateManagedConfig(new Map([["x", policy.normalizePolicy({})]])), "");
77 assert.throws(() => policy.normalizePolicy({ password: "maybe" }), { code: "USERS_SSH_POLICY_INVALID" });
78 assert.throws(() => policy.normalizePolicy({ ipv4: "deny\nPermitRootLogin yes" }), { code: "USERS_SSH_POLICY_INVALID" });
79});
81test("sshd -T output is evaluated per user, address family and authentication method", () => {
82 const dump = policy.parseEffective([
83 "passwordauthentication yes", "kbdinteractiveauthentication no", "pubkeyauthentication yes", "permitrootlogin prohibit-password",
84 "authenticationmethods any", "denyusers bob@0.0.0.0/0", "denyusers eve", "denyusers mallory@192.168.*", "addressfamily any",
85 "listenaddress 0.0.0.0:22", "listenaddress [::]:22"
86 ].join("\n"));
87 assert.equal(policy.evaluateAccess(dump, { username: "bob", address: "203.0.113.5" }).allowed, false);
88 assert.equal(policy.evaluateAccess(dump, { username: "bob", address: "2001:db8::5" }).allowed, true);
89 assert.equal(policy.evaluateAccess(dump, { username: "eve", address: "2001:db8::5" }).allowed, false);
90 assert.equal(policy.evaluateAccess(dump, { username: "mallory", address: "192.168.1.4" }).allowed, false);
91 assert.equal(policy.evaluateAccess(dump, { username: "mallory", address: "10.1.1.1" }).allowed, true);
92 assert.equal(policy.evaluateAccess(dump, { username: "root", address: "10.1.1.1", method: "password" }).methodAllowed, false);
93 assert.equal(policy.evaluateAccess(dump, { username: "root", address: "10.1.1.1", method: "publickey" }).methodAllowed, true);
94 assert.deepEqual(policy.listenerFamilies(dump), { ipv4: true, ipv6: true });
95 const restricted = policy.parseEffective("passwordauthentication yes\npubkeyauthentication yes\nauthenticationmethods publickey,password\nallowusers admin\nallowusers deploy@10.0.0.0/8\nallowgroups ops\nallowgroups openssh users\naddressfamily inet");
96 assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "10.2.3.4", method: "publickey" }).methodAllowed, false, "publickey alone does not satisfy publickey,password");
97 assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "10.2.3.4" }).allowed, true);
98 assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "172.16.0.1" }).allowed, false);
99 assert.equal(policy.evaluateAccess(restricted, { username: "admin", groups: ["users"], address: "10.2.3.4" }).allowed, false, "AllowGroups applies too");
100 assert.equal(policy.evaluateAccess(restricted, { username: "admin", groups: ["openssh users"], address: "10.2.3.4" }).allowed, true, "group names with spaces are one entry");
101 assert.equal(policy.evaluateAccess(policy.parseEffective("allowusers ops@*.example.com"), { username: "ops", address: "10.0.0.1" }).allowed, null, "hostnames cannot be evaluated without DNS");
102 assert.deepEqual(policy.listenerFamilies(restricted), { ipv4: true, ipv6: false });
103 assert.equal(policy.hostMatches("::/0", "203.0.113.5"), false);
104 assert.equal(policy.hostMatches("0.0.0.0/0", "2001:db8::1"), false);
105 assert.equal(policy.hostMatches("2001:db8::/32", "2001:db8::1"), true);
106 assert.deepEqual(policy.verifyPolicyEffect({ ...policy.POLICY_DEFAULTS, password: "no", ipv4: "deny" }, "bob", dump, "IPv4"), ["IPv4: PasswordAuthentication is yes"]);
107});
109test("Core validation rejects names, paths and text that could reach a shell or passwd fields", () => {
110 for (const value of ["bob;id", "$(id)", "`id`", "a b", "-rf", "Root", "1abc", "x".repeat(33), "", "bo\nb", "../x"]) {
111 assert.throws(() => validateName(value), { code: "USERS_NAME_INVALID" }, value);
112 }
113 assert.equal(validateName("_svc-1"), "_svc-1");
114 for (const value of ["/bin/sh;id", "bin/sh", "/bin/../tmp/x", "/bin/s h", "/bin/$(x)"]) assert.throws(() => validateShell(value), { code: "USERS_SHELL_INVALID" }, value);
115 for (const value of ["/", "/etc", "/etc/x", "/usr/local/x", "/home/../etc", "/home/a b", "home/x", "/home/$(id)", "/root", "/var", "/proc/self"]) {
116 assert.throws(() => validateHome(value), { code: "USERS_HOME_INVALID" }, value);
117 }
118 assert.equal(validateHome("/home/novak/"), "/home/novak");
119 assert.equal(validateHome("/var/www/site"), "/var/www/site");
120 for (const value of ["a:b", "line\nbreak", "x".repeat(101), "back\\slash"]) assert.throws(() => validateComment(value), { code: "USERS_COMMENT_INVALID" });
121 assert.equal(validateComment("Jiří Novák, IT"), "Jiří Novák, IT");
122});
124test("remote helper script prints no password hashes and keeps secrets out of argv", () => {
125 const script = fs.readFileSync(path.join(__dirname, "../src/main/users/remoteUsers.sh"), "utf8");
126 assert.match(script, /Password hashes never leave the server/);
127 assert.doesNotMatch(script, /getent shadow[^\n]*\$2[^\n]*print \$2/);
128 assert.match(script, /printf '%s:%s\\n' "\$1" "\$secret" \| chpasswd/);
129 assert.match(script, /read_frame SECRET/);
130 assert.doesNotMatch(script, /\beval\b/);
131 const sections = splitSections("\x1eCHJ_SECTION=meta\nuid=0\n\x1eCHJ_SECTION=passwd\nroot:x:0:0::/root:/bin/sh\n");
132 assert.deepEqual(Object.keys(sections), ["meta", "passwd"]);
133});
135class FakeChannel extends EventEmitter {
136 constructor() { super(); this.stderr = new EventEmitter(); this.writes = []; this.ended = false; this.closed = false; }
137 write(data) { this.writes.push(String(data)); this.emit("input", String(data)); }
138 end() { if (this.ended) return; this.ended = true; this.emit("input-end"); }
139 close() { if (this.closed) return; this.closed = true; setImmediate(() => this.emit("close", null)); }
140 out(text) { this.emit("data", Buffer.from(text)); }
141 err(text) { this.stderr.emit("data", Buffer.from(text)); }
142 exit(code) { if (!this.closed) { this.closed = true; setImmediate(() => this.emit("close", code)); } }
145function managerWith(username, scenario) {
146 const manager = new SessionManager({ profileService: {}, latencyMonitor: { start: () => () => {} } });
147 const commands = [];
148 const channels = [];
149 const client = {
150 exec(command, callback) {
151 commands.push(command);
152 const channel = new FakeChannel();
153 channels.push(channel);
154 callback(null, channel);
155 setImmediate(() => {
156 if (command.includes("sudoNonInteractive")) {
157 channel.out(`user=${username}\nuid=${username === "root" ? 0 : 1000}\ngroups=${username},sudo\nclient=198.51.100.7\nsudoInstalled=1\nsudoNonInteractive=1\nsudoMessage=sudo: a password is required\n`);
158 channel.exit(0);
159 } else scenario(channel, command);
160 });
161 }
162 };
163 manager.sessions.set("s1", { sessionId: "s1", profileId: "p1", host: "server.test", username, state: "connected", stream: {}, client, decoder: { end: () => "" } });
164 return { manager, executor: new PrivilegedExecutor({ sessionManager: manager }), commands, channels };
167test("privileged executor sends the sudo password only after sudo prompts and frames only after READY", async () => {
168 const { executor, commands, channels } = managerWith("admin", (channel) => {
169 channel.once("input", (password) => {
170 assert.equal(password, "s3cret\n");
171 channel.once("input", (frame) => { channel.out(`got ${frame}`); channel.exit(0); });
172 channel.out("\x1eCHJ_READY\n");
173 });
174 channel.err(SUDO_PROMPT);
175 });
176 const result = await executor.run("s1", { script: "echo fixed", args: ["inventory", "a'b"], frames: [{ tag: "SECRET", data: "account-pass" }], sudoPassword: "s3cret" });
177 assert.match(commands[1], /^LC_ALL=C sudo -S -p '\[chj-sudo-auth\]' -- sh -c 'echo fixed' chj 'inventory' 'a'"'"'b'$/);
178 assert.ok(commands.every((command) => !command.includes("s3cret") && !command.includes("account-pass")), "secrets never appear in argv");
179 assert.equal(channels[1].writes[1], `CHJSECRET:${Buffer.from("account-pass").toString("base64")}\n`);
180 assert.match(result.stdout, /got CHJSECRET:/);
181 assert.equal((await executor.probe("s1")).mode, "sudo-password");
182 assert.equal((await executor.probe("s1")).verified, true);
183});
185test("privileged executor reports missing, wrong and insufficient sudo authorization without hanging", async () => {
186 let scenario = (channel) => { channel.on("input-end", () => channel.exit(1)); channel.err(SUDO_PROMPT); };
187 const { executor, channels } = managerWith("admin", (channel, command) => scenario(channel, command));
188 await assert.rejects(() => executor.run("s1", { script: "x" }), { code: "SUDO_PASSWORD_REQUIRED" });
189 assert.equal(channels[1].writes.length, 0, "no input without a password");
190 scenario = (channel) => {
191 channel.once("input", () => setImmediate(() => channel.err(`Sorry, try again.\n${SUDO_PROMPT}`)));
192 channel.on("input-end", () => channel.exit(1));
193 channel.err(SUDO_PROMPT);
194 };
195 await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "wrong", frames: [{ tag: "DATA", data: "payload" }] }), { code: "SUDO_PASSWORD_INCORRECT" });
196 assert.deepEqual(channels[2].writes, ["wrong\n"], "the frame is never sent to a sudo password prompt");
197 scenario = (channel) => { channel.err("sudo: sorry, you must have a tty to run sudo\n"); channel.exit(1); };
198 await assert.rejects(() => executor.run("s1", { script: "x" }), { code: "SUDO_TTY_REQUIRED" });
199 scenario = (channel) => {
200 channel.once("input", () => { channel.err("admin is not in the sudoers file.\n"); channel.exit(1); });
201 channel.err(SUDO_PROMPT);
202 };
203 await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "right" }), { code: "SUDO_NOT_PERMITTED" });
204 const before = channels.length;
205 await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "right" }), { code: "SUDO_NOT_PERMITTED" });
206 assert.equal(channels.length, before, "a verified lack of sudo is not retried or prompted again");
207});
209test("privileged executor maps remote error markers and survives disconnects", async () => {
210 let scenario = (channel) => { channel.out("\x1eCHJ_READY\n\x1eCHJ_ERR=PROTECTED_USER\n"); channel.err("The root account is protected.\n"); channel.exit(3); };
211 const { manager, executor, commands } = managerWith("root", (channel, command) => scenario(channel, command));
212 await assert.rejects(() => executor.run("s1", { script: "x", errorPrefix: "USERS_" }), { code: "USERS_PROTECTED_USER", message: "The root account is protected." });
213 assert.ok(commands[1].startsWith("LC_ALL=C sh -c "), "root sessions do not use sudo");
214 scenario = (channel) => { channel.out("\x1eCHJ_READY\n\x1eCHJ_STAGED\n"); };
215 const pending = executor.run("s1", { script: "x", interact: async (io) => { await io.waitFor(/\x1eCHJ_STAGED\n/); await io.waitFor(/never/, 60000); } });
216 setTimeout(() => manager._finalize(manager.sessions.get("s1"), "test"), 20);
217 await assert.rejects(() => pending, { code: "SESSION_NOT_CONNECTED" });
218});
220test("last working administrator and protected accounts cannot be removed", () => {
221 const service = new UserAdminService({ sessionManager: {}, privilegedExecutor: {} });
222 const inventory = (mode) => ({
223 privilege: { mode },
224 users: [
225 { username: "root", uid: 0, sudo: "full", loginShell: true, expired: false },
226 { username: "alice", uid: 1000, sudo: "full", loginShell: true, expired: false },
227 { username: "bob", uid: 1001, sudo: "none", loginShell: true, expired: false },
228 { username: "carol", uid: 1002, sudo: "full", loginShell: true, expired: true }
229 ]
230 });
231 assert.throws(() => service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "delete", {}), { code: "USERS_LAST_ADMIN" });
232 assert.throws(() => service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "shell", { shell: "/usr/sbin/nologin" }), { code: "USERS_LAST_ADMIN" });
233 service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "shell", { shell: "/bin/bash" });
234 service._assertNotLastAdmin(inventory("sudo-password"), { username: "bob" }, "delete", {});
235 service._assertNotLastAdmin(inventory("root"), { username: "alice" }, "delete", {});
236 for (const protection of ["root", "system", "session"]) assert.throws(() => service._assertMutable({ protection }, "delete"), { code: "USERS_PROTECTED_USER" });
237 service._assertMutable({ protection: null }, "delete");
238});
240test("users plugin UI renders server data as text and asks for sudo only on demand", () => {
241 const root = path.join(__dirname, "../src/main/firstPartyPlugins/chj.users/0.0.1");
242 const script = fs.readFileSync(path.join(root, "ui/index.js"), "utf8");
243 const html = fs.readFileSync(path.join(root, "ui/index.html"), "utf8");
244 const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
245 assert.doesNotMatch(script, /\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML\(|document\.write\(/);
246 assert.doesNotMatch(html, /id="sudoPassword"[^>]*class="toolbar/);
247 assert.match(html, /<dialog id="sudoDialog"/);
248 assert.match(script, /SUDO_PASSWORD_REQUIRED/);
249 assert.deepEqual(manifest.permissions, ["session.read", "users.read", "users.manage", "keys.generate"]);
250 assert.equal(manifest.pluginApi, "^1.3.0");
251});
253test("root SSH policy: PermitRootLogin only for root, compatible managed format and alias handling", async () => {
254 const legacy = policy.generateManagedConfig(new Map([["bob", policy.normalizePolicy({ password: "no" })]]));
255 assert.doesNotMatch(legacy, /permitRootLogin/, "files written before the option stay byte-identical");
256 assert.equal(policy.parseManagedConfig(legacy).consistent, true);
257 const withRoot = policy.generateManagedConfig(new Map([["root", policy.normalizePolicy({ permitRootLogin: "prohibit-password", ipv6: "deny" }, "root")]]));
258 assert.match(withRoot, /# chj-policy root access=allow password=inherit pubkey=inherit ipv4=allow ipv6=deny permitRootLogin=prohibit-password/);
259 assert.match(withRoot, /DenyUsers root@::\/0\nMatch User root\n\tPermitRootLogin prohibit-password\nMatch all\n$/);
260 assert.equal(policy.parseManagedConfig(withRoot).policies.get("root").permitRootLogin, "prohibit-password");
261 assert.throws(() => policy.normalizePolicy({ permitRootLogin: "yes" }, "bob"), { code: "USERS_SSH_POLICY_INVALID" });
262 assert.throws(() => policy.normalizePolicy({ permitRootLogin: "maybe" }, "root"), { code: "USERS_SSH_POLICY_INVALID" });
263 const dump = policy.parseEffective("permitrootlogin without-password\npasswordauthentication yes\npubkeyauthentication yes");
264 assert.deepEqual(policy.verifyPolicyEffect({ ...policy.POLICY_DEFAULTS, permitRootLogin: "prohibit-password" }, "root", dump, "IPv4"), []);
265 const evaluation = policy.evaluateAccess(dump, { username: "root", address: "192.0.2.1" });
266 assert.equal(evaluation.methods.password, false); assert.equal(evaluation.methods.publickey, true);
267 assert.equal(policy.evaluateAccess(policy.parseEffective("permitrootlogin no\npubkeyauthentication yes"), { username: "root", address: "192.0.2.1" }).allowed, false);
268 const service = new UserAdminService({ sessionManager: {}, privilegedExecutor: { probe: async () => { throw new Error("must not run"); } } });
269 await assert.rejects(() => service.sshPolicy("s", { username: "root", policy: { permitRootLogin: "yes" } }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" });
270 await assert.rejects(() => service.sshPolicy("s", { username: "root", policy: { password: "yes" } }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" });
271});
SHA-2562551cf481a74ec984bfd264d6d7abcf09eb22fcad3228da676877ede314166c1

CH-J Proprietary Software License 1.14 · A fonte é fornecida sob o CH-J Proprietary Software License 1.14. A sua disponibilidade não altera os termos licença nem concede direitos adicionais.

Ficheiro SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Ficheiros