artifacty 0.16.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ko/network-sharing.md +17 -0
- package/docs/network-sharing.md +18 -0
- package/package.json +1 -1
- package/src/lib/i18n.js +4 -0
- package/src/lib/network-policy.js +7 -6
- package/src/lib/render.js +2 -0
- package/src/server.js +12 -4
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
# 네트워크 공유
|
|
6
6
|
|
|
7
|
+
## IP 대역과 공유 링크
|
|
8
|
+
|
|
9
|
+
**관리자 → 접근 관리 → 네트워크 접근**에서 허용할 IP 또는 CIDR을 한 줄에
|
|
10
|
+
하나씩 입력합니다. 쉼표로 구분해도 됩니다. 비워두면 모든 주소를 허용합니다.
|
|
11
|
+
현재 접속 IP를 포함해 저장한 뒤 120초 안에 확정하세요. 확정하지 않으면
|
|
12
|
+
기한이 지난 후 다음 접근 시 이전 설정으로 돌아갑니다.
|
|
13
|
+
|
|
14
|
+
**허용 IP 대역 밖에서도 공유 링크 열람 허용**은 기본적으로 꺼져 있습니다.
|
|
15
|
+
켜면 허용 대역 밖에서도 유효한 공유 링크로 해당 아티팩트를 읽을 수 있습니다.
|
|
16
|
+
예외는 공유 페이지와 해당 원본 콘텐츠에만 적용합니다. 대시보드·검색·API·MCP와
|
|
17
|
+
일반 아티팩트 주소는 계속 제한합니다. 만료되거나 취소된 링크는 사용할 수
|
|
18
|
+
없습니다. 비공개 모드에서는 별도의 비공개 모드 공유 허용 설정도 켜야 합니다.
|
|
19
|
+
이 옵션도 IP 대역과 함께 저장하며, 같은 확정·되돌리기 절차를 따릅니다.
|
|
20
|
+
|
|
21
|
+
접근 판단에는 전달 헤더가 아닌 직접 연결된 IP를 사용합니다. 리버스 프록시를
|
|
22
|
+
거치면 프록시 IP를 보므로, 필요한 경우 프록시에서 클라이언트 IP를 제한하세요.
|
|
23
|
+
|
|
7
24
|
Artifacty는 아티팩트를 로컬에 저장합니다. 기본적으로 서버는 한 컴퓨터에서 사용하도록 `127.0.0.1`에 바인딩합니다. 짧은 공유 세션을 위해 로컬 외부 주소에 바인딩할 수 있습니다. 이 경우 다른 컴퓨터에서도 서버에 접근할 수 있습니다.
|
|
8
25
|
|
|
9
26
|
## 권장 기본 설정
|
package/docs/network-sharing.md
CHANGED
|
@@ -44,6 +44,24 @@ Artifacty does not terminate TLS. Do not expose it directly on the public intern
|
|
|
44
44
|
|
|
45
45
|
If Artifacty binds outside loopback, startup output warns that other machines can reach the server. The warning also states that Artifacty does not provide TLS. Background `serve` returns the warning in JSON. Foreground `serve` and `src/server.js` also write it to stderr.
|
|
46
46
|
|
|
47
|
+
## IP ranges and shared links
|
|
48
|
+
|
|
49
|
+
In **Administration → Access → Network access**, enter one allowed IP address
|
|
50
|
+
or CIDR per line. Commas also work. An empty list allows all addresses. Include
|
|
51
|
+
your current connection IP, save, then confirm within 120 seconds. Otherwise,
|
|
52
|
+
the previous policy is restored on the next access after the deadline.
|
|
53
|
+
|
|
54
|
+
**Allow shared links outside these IP ranges** is off by default. Enable it to
|
|
55
|
+
let people outside the allowed ranges read an artifact through its valid shared
|
|
56
|
+
link. Only the shared page and its raw content are exempt. The dashboard,
|
|
57
|
+
search, API, MCP, and ordinary artifact URLs remain restricted. Expired or
|
|
58
|
+
revoked links do not grant access. Private mode still requires the separate
|
|
59
|
+
setting that permits shared links in private mode. Saving this option uses the
|
|
60
|
+
same confirmation and rollback process as the IP ranges.
|
|
61
|
+
|
|
62
|
+
Rules use the direct socket peer, not forwarded IP headers. With a reverse
|
|
63
|
+
proxy, that peer is the proxy; enforce client IP ranges at the proxy when needed.
|
|
64
|
+
|
|
47
65
|
## Browser Write Behavior
|
|
48
66
|
|
|
49
67
|
Remote browsers can read pages allowed by the current access mode and artifact permissions. To submit a browser form, the request origin must match the Artifacty host. For example, `Origin: http://10.0.0.50:8787` matches `Host: 10.0.0.50:8787`. Routes that change data reject cross-origin browser requests to reduce CSRF risk. For scripts and agents, use API or MCP writes with an explicit token header.
|
package/package.json
CHANGED
package/src/lib/i18n.js
CHANGED
|
@@ -361,6 +361,8 @@ export const TRANSLATIONS = {
|
|
|
361
361
|
"access.networkHelp": "Applies in both modes. Enter one IPv4 or IPv6 address or CIDR per line. Leave blank to allow all addresses. Uses the direct connection IP, not forwarded headers. The list must include your current address.",
|
|
362
362
|
"access.currentIp": "Your connection IP",
|
|
363
363
|
"access.allowedCidrs": "Allowed addresses and CIDRs",
|
|
364
|
+
"access.allowSharedLinks": "Allow shared links outside these IP ranges",
|
|
365
|
+
"access.sharedNetworkHelp": "Off by default. A valid shared link grants read-only access to that artifact. The dashboard and APIs stay restricted. Link expiry, revocation, and private-mode sharing rules still apply.",
|
|
364
366
|
"access.saveNetwork": "Save network access",
|
|
365
367
|
|
|
366
368
|
"access.title": "Access",
|
|
@@ -914,6 +916,8 @@ export const TRANSLATIONS = {
|
|
|
914
916
|
"access.networkHelp": "두 모드 모두에 적용됩니다. IPv4 또는 IPv6 주소나 CIDR을 한 줄에 하나씩 입력하세요. 비워두면 모든 주소를 허용합니다. 전달 헤더 대신 직접 연결된 IP를 사용합니다. 현재 접속 주소를 반드시 포함해야 합니다.",
|
|
915
917
|
"access.currentIp": "현재 접속 IP",
|
|
916
918
|
"access.allowedCidrs": "허용할 주소 및 CIDR",
|
|
919
|
+
"access.allowSharedLinks": "허용 IP 대역 밖에서도 공유 링크 열람 허용",
|
|
920
|
+
"access.sharedNetworkHelp": "기본값은 꺼짐입니다. 유효한 공유 링크로 해당 아티팩트만 읽을 수 있습니다. 대시보드와 API는 계속 차단하며, 링크 만료·취소와 비공개 모드의 공유 제한은 그대로 적용합니다.",
|
|
917
921
|
"access.saveNetwork": "네트워크 접근 저장",
|
|
918
922
|
|
|
919
923
|
"access.title": "접근 관리",
|
|
@@ -87,10 +87,10 @@ export function isAddressAllowed(address, allowedCidrs = []) {
|
|
|
87
87
|
|
|
88
88
|
function readPolicy(db) {
|
|
89
89
|
const row = db.prepare("SELECT value FROM meta WHERE key = 'network_policy'").get();
|
|
90
|
-
if (!row) return { allowedCidrs: [] };
|
|
90
|
+
if (!row) return { allowedCidrs: [], allowSharedLinks: false };
|
|
91
91
|
const policy = JSON.parse(row.value);
|
|
92
92
|
if (!policy || !Object.hasOwn(policy, "allowedCidrs")) throw policyError("Stored network policy is invalid.");
|
|
93
|
-
return
|
|
93
|
+
return validatePolicy(policy);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function readPending(db) {
|
|
@@ -99,7 +99,7 @@ function readPending(db) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function publicPending(pending) {
|
|
102
|
-
return pending ? { id: pending.id, expiresAt: pending.expiresAt, allowedCidrs: pending.allowedCidrs } : null;
|
|
102
|
+
return pending ? { id: pending.id, expiresAt: pending.expiresAt, allowedCidrs: pending.allowedCidrs, allowSharedLinks: pending.allowSharedLinks === true } : null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function writePolicy(db, policy, action, audit, metadata = {}) {
|
|
@@ -124,7 +124,8 @@ function requireAdmin(db, access) {
|
|
|
124
124
|
|
|
125
125
|
function validatePolicy(policy, remoteAddress) {
|
|
126
126
|
if (!policy || !Object.hasOwn(policy, "allowedCidrs")) throw policyError("Allowed networks are required.");
|
|
127
|
-
|
|
127
|
+
if (policy.allowSharedLinks !== undefined && typeof policy.allowSharedLinks !== "boolean") throw policyError("Shared-link access must be a boolean.");
|
|
128
|
+
const normalized = { allowedCidrs: normalizeAllowedNetworks(policy.allowedCidrs), allowSharedLinks: policy.allowSharedLinks === true };
|
|
128
129
|
if (remoteAddress !== undefined && !isAddressAllowed(remoteAddress, normalized.allowedCidrs)) {
|
|
129
130
|
throw policyError("Allowed networks must include your current IP address.", "NETWORK_POLICY_LOCKOUT");
|
|
130
131
|
}
|
|
@@ -189,7 +190,7 @@ export function stageNetworkPolicy(store = createStore(), policy = {}, { access,
|
|
|
189
190
|
const normalized = validatePolicy(policy, remoteAddress);
|
|
190
191
|
const pending = {
|
|
191
192
|
id: randomUUID(), expiresAt: new Date(Date.now() + timeoutMs).toISOString(),
|
|
192
|
-
allowedCidrs: normalized.allowedCidrs, previousPolicy: readPolicy(db)
|
|
193
|
+
allowedCidrs: normalized.allowedCidrs, allowSharedLinks: normalized.allowSharedLinks, previousPolicy: readPolicy(db)
|
|
193
194
|
};
|
|
194
195
|
db.prepare("INSERT INTO meta (key, value) VALUES ('network_policy_pending', ?)").run(JSON.stringify(pending));
|
|
195
196
|
writePolicy(db, normalized, "network-policy-stage", { ...audit, actor: audit.actor || access.userId }, { pendingId: pending.id, expiresAt: pending.expiresAt });
|
|
@@ -211,7 +212,7 @@ function finishPending(store, id, { access, audit = {}, remoteAddress } = {}, co
|
|
|
211
212
|
if (confirm && (!parseAddress(remoteAddress) || !isAddressAllowed(remoteAddress, pending.allowedCidrs))) {
|
|
212
213
|
throw policyError("Confirmation requires access from an allowed IP address.", "IP_NOT_ALLOWED", 403);
|
|
213
214
|
}
|
|
214
|
-
const policy = confirm ? { allowedCidrs: pending.allowedCidrs } : pending.previousPolicy;
|
|
215
|
+
const policy = validatePolicy(confirm ? { allowedCidrs: pending.allowedCidrs, allowSharedLinks: pending.allowSharedLinks } : pending.previousPolicy);
|
|
215
216
|
writePolicy(db, policy, confirm ? "network-policy-confirm" : "network-policy-rollback", { ...audit, actor: audit.actor || access.userId }, { pendingId: pending.id });
|
|
216
217
|
db.prepare("DELETE FROM meta WHERE key = 'network_policy_pending'").run();
|
|
217
218
|
return policy;
|
package/src/lib/render.js
CHANGED
|
@@ -900,6 +900,8 @@ export function renderAdminAccessPage({ baseUrl, user, mode, teams = [], users =
|
|
|
900
900
|
<p>${view.text("access.currentIp")}: <code>${escapeHtml(currentIp)}</code></p>
|
|
901
901
|
<form class="editor-form" method="post" action="/admin/access/network">${localeInput(view.locale)}
|
|
902
902
|
<label class="field"><span>${view.text("access.allowedCidrs")}</span><textarea name="allowedCidrs" rows="5" class="form-control">${escapeHtml((networkPolicy.allowedCidrs || []).join("\n"))}</textarea></label>
|
|
903
|
+
<label><input type="checkbox" name="allowSharedLinks" value="true"${networkPolicy.allowSharedLinks ? " checked" : ""} class="form-check-input" aria-describedby="shared-network-help"> ${view.text("access.allowSharedLinks")}</label>
|
|
904
|
+
<p id="shared-network-help" class="muted">${view.text("access.sharedNetworkHelp")}</p>
|
|
903
905
|
<button type="submit" class="btn btn-primary">${view.text("access.saveNetwork")}</button>
|
|
904
906
|
</form>
|
|
905
907
|
</section>
|
package/src/server.js
CHANGED
|
@@ -325,14 +325,22 @@ function isBrowserPath(pathname) {
|
|
|
325
325
|
|
|
326
326
|
export async function handleRequest(args) {
|
|
327
327
|
const { request, store } = args;
|
|
328
|
-
|
|
328
|
+
let networkSharedArtifact;
|
|
329
|
+
try { assertNetworkAccess(store, request.socket.remoteAddress); }
|
|
330
|
+
catch (cause) {
|
|
331
|
+
const match = /^\/s\/([A-Za-z0-9_-]{43})(?:\/raw)?$/.exec(new URL(request.url, "http://artifacty.local").pathname);
|
|
332
|
+
if (cause.code !== "IP_NOT_ALLOWED" || !["GET", "HEAD"].includes(request.method) || !match || !getNetworkPolicy(store).allowSharedLinks) throw cause;
|
|
333
|
+
// Authenticate the capability before admitting an otherwise blocked peer.
|
|
334
|
+
networkSharedArtifact = await resolveShareLink(store, match[1]);
|
|
335
|
+
}
|
|
336
|
+
args = { ...args, networkSharedArtifact };
|
|
329
337
|
const pathname = decodeURIComponent(new URL(request.url, "http://artifacty.local").pathname);
|
|
330
338
|
if (!isBrowserPath(pathname)) return handleScopedRequest(args);
|
|
331
339
|
const settings = await getLanguageSettings(store);
|
|
332
340
|
return runWithLanguageSettings(settings, () => handleScopedRequest(args));
|
|
333
341
|
}
|
|
334
342
|
|
|
335
|
-
async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail }) {
|
|
343
|
+
async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail, networkSharedArtifact }) {
|
|
336
344
|
const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
|
|
337
345
|
const pathname = decodeURIComponent(url.pathname);
|
|
338
346
|
const baseUrl = `http://${host}:${port}`;
|
|
@@ -381,7 +389,7 @@ async function handleScopedRequest({ request, response, store, host, port, secur
|
|
|
381
389
|
|
|
382
390
|
const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
|
|
383
391
|
if (publicShareMatch && method === "GET") {
|
|
384
|
-
const shared = await resolveShareLink(store, publicShareMatch[1]);
|
|
392
|
+
const shared = networkSharedArtifact || await resolveShareLink(store, publicShareMatch[1]);
|
|
385
393
|
response.setHeader("referrer-policy", "no-referrer");
|
|
386
394
|
response.setHeader("cache-control", "no-store");
|
|
387
395
|
if (publicShareMatch[2]) {
|
|
@@ -729,7 +737,7 @@ async function handleScopedRequest({ request, response, store, host, port, secur
|
|
|
729
737
|
} else if (pathname === "/admin/access") {
|
|
730
738
|
await setAccessMode(store, body.mode, options);
|
|
731
739
|
} else if (pathname === "/admin/access/network") {
|
|
732
|
-
await stageNetworkPolicy(store, { allowedCidrs: body.allowedCidrs || "" }, options);
|
|
740
|
+
await stageNetworkPolicy(store, { allowedCidrs: body.allowedCidrs || "", allowSharedLinks: body.allowSharedLinks === "true" || body.allowSharedLinks === "on" }, options);
|
|
733
741
|
} else if (pathname === "/admin/access/network/confirm") {
|
|
734
742
|
await confirmNetworkPolicy(store, body.id, options);
|
|
735
743
|
} else if (pathname === "/admin/access/network/rollback") {
|