@samitouri / QOSami-HFS / commits / 49841ada

dev: add_vfs.link

Massimo Melina committed Oct 12, 2023 at 00:02 UTC 49841adaf52f58f7b99868501705758b3afa2f62
13 files changed +353 -296
admin/src/FileForm.ts
+4 -3
@@ -227,7 +227,7 @@ interface LinkFieldProps extends FieldProps<string> {
227 function LinkField({ value, statusApi }: LinkFieldProps) {
228 const { data, reload, error } = statusApi
229 const urls: string[] = data?.urls.https || data?.urls.http
230 - const link = (data?.baseUrl || urls?.[0] || '') + value
230 + const link = (data?.baseUrl || '') + value
231 return h(Box, { display: 'flex' },
232 !urls ? 'error' : // check data is ok
233 h(DisplayField, {
@@ -249,11 +249,12 @@ function LinkField({ value, statusApi }: LinkFieldProps) {
249 export async function changeBaseUrl() {
250 return new Promise(async resolve => {
251 const res = await apiCall('get_status')
252 + const { base_url } = await apiCall('get_config', { only: ['base_url'] })
253 const urls: string[] = res.urls.https || res.urls.http
254 const { close } = newDialog({
255 title: "Base address",
256 Content() {
256 - const [v, setV] = useState(res.baseUrl || '')
257 + const [v, setV] = useState(base_url || '')
258 const proto = new URL(v || urls[0]).protocol + '//'
259 const host = urls.includes(v) ? '' : v.slice(proto.length)
260 const check = h(Check, { sx: { ml: 2 } })
@@ -290,7 +291,7 @@ export async function changeBaseUrl() {
291 icon: Save,
292 children: "Save",
293 async onClick() {
293 - if (v !== res.baseUrl)
294 + if (v !== base_url)
295 await apiCall('set_config', { values: { base_url: v.replace(/\/$/, '') } })
296 resolve(v)
297 close()
admin/src/InternetPage.ts
+4 -3
@@ -20,6 +20,7 @@ export default function InternetPage() {
20 const [mapping, setMapping] = useState(false)
21 const [verifyAgain, setVerifyAgain] = useState(false)
22 const status = useApiEx('get_status')
23 + const { data: config } = useApiEx('get_config', { only: ['base_url'] })
24 const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) =>
25 h && s ? 'error' : h || s ? 'warning' : 'success')
26 const { data: nat, reload: reloadNat, error, loading, element } = useApiEx<GetNat>('get_nat')
@@ -102,13 +103,13 @@ export default function InternetPage() {
103 }
104
105 function baseUrlBox() {
105 - const url = status.data?.baseUrl
106 + const url = config?.base_url
107 const hostname = url && new URL(url).hostname
108 const domain = !isIP(hostname) && hostname
109 return status.element || h(Card, {}, h(CardContent, {},
110 h(Box, { fontSize: 'x-large', mb: 2 }, "Address / Domain"),
111 h(Flex, { flexWrap: 'wrap', alignItems: 'center' },
111 - status.data?.baseUrl || "Automatic, not configured",
112 + url || "Automatic, not configured",
113 h(Button, {
114 size: 'small',
115 onClick() { changeBaseUrl().then(status.reload) }
@@ -157,7 +158,7 @@ export default function InternetPage() {
158 if (!verifyAgain && !await confirmDialog("This test will check if your server is working properly on the Internet")) return
159 setChecking(true)
160 try {
160 - const url = status.data?.baseUrl
161 + const url = config.base_url
162 const urlResult = url && await apiCall('self_check', { url }).catch(() =>
163 alertDialog(md(`Sorry, we couldn't verify your configured address ${url} 😰\nstill, we are going to test your IP address 🤞`), 'warning'))
164 if (urlResult?.success) {
src/acme.ts new
+112
@@ -0,0 +1,112 @@
1 +import { DAY, Dict, HOUR, HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_OK, repeat } from './cross'
2 +import { createServer, RequestListener } from 'http'
3 +import { Middleware } from 'koa'
4 +import { getNatInfo, upnpClient } from './nat'
5 +import { cert, getCertObject, getServerStatus, privateKey } from './listen'
6 +import { ApiError } from './apiMiddleware'
7 +import acme from 'acme-client'
8 +import { debounceAsync } from './debounceAsync'
9 +import fs from 'fs/promises'
10 +import { defineConfig } from './config'
11 +import events from './events'
12 +import { selfCheck } from './selfCheck'
13 +
14 +let acmeMiddlewareEnabled = false
15 +const acmeTokens: Dict<string> = {}
16 +const acmeListener: RequestListener = (req, res) => { // node format
17 + const BASE = '/.well-known/acme-challenge/'
18 + if (!req.url?.startsWith(BASE)) return
19 + const token = req.url.slice(BASE.length)
20 + console.debug("got http challenge", token)
21 + res.statusCode = HTTP_OK
22 + res.end(acmeTokens[token])
23 + return true
24 +}
25 +export const acmeMiddleware: Middleware = (ctx, next) => { // koa format
26 + if (!acmeMiddlewareEnabled || !Boolean(acmeListener(ctx.req, ctx.res)))
27 + return next()
28 +}
29 +
30 +async function generateSSLCert(domain: string, email?: string) {
31 + // will answer challenge through our koa app (if on port 80) or must we spawn a dedicated server?
32 + const { upnp, externalPort } = await getNatInfo()
33 + const { http } = await getServerStatus()
34 + const tempSrv = externalPort === 80 || http.listening && http.port === 80 ? undefined : createServer(acmeListener)
35 + if (tempSrv)
36 + await new Promise<void>((resolve) =>
37 + tempSrv.listen(80, resolve).on('error', (e: any) => {
38 + console.debug("cannot listen on 80", e.code || e)
39 + resolve() // go on anyway
40 + }) )
41 + acmeMiddlewareEnabled = true
42 + console.debug('acme challenge server ready')
43 + try {
44 + const checkUrl = `http://${domain}`
45 + let check = await selfCheck(checkUrl) // some check services may not consider the domain, but we already verified that
46 + if (check && !check.success && upnp && externalPort !== 80) { // consider a short-lived mapping
47 + console.debug("setting temporary port forward")
48 + // @ts-ignore
49 + await upnpClient.createMapping({ private: 80, public: { host: '', port: 80 }, description: 'hfs temporary', ttl: 30 }).catch(() => {})
50 + check = await selfCheck(checkUrl) // repeat test
51 + }
52 + //if (!check) throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
53 + if (!check?.success)
54 + throw new ApiError(HTTP_FAILED_DEPENDENCY, "port 80 is not working on the specified domain")
55 + const acmeClient = new acme.Client({
56 + accountKey: await acme.crypto.createPrivateKey(),
57 + directoryUrl: acme.directory.letsencrypt.production
58 + })
59 + const [key, csr] = await acme.crypto.createCsr({ commonName: domain })
60 + const cert = await acmeClient.auto({
61 + csr,
62 + email,
63 + challengePriority: ['http-01'],
64 + skipChallengeVerification: true, // on NAT, trying to connect to your external ip will likely get your modem instead of the challenge server
65 + termsOfServiceAgreed: true,
66 + async challengeCreateFn(_, c, ka) {
67 + console.debug("producing challenge")
68 + acmeTokens[c.token] = ka
69 + },
70 + async challengeRemoveFn(_, c) {
71 + delete acmeTokens[c.token]
72 + },
73 + })
74 + return { key, cert }
75 + }
76 + finally {
77 + acmeMiddlewareEnabled = false
78 + if (tempSrv) await new Promise(res => tempSrv.close(res))
79 + console.debug('acme terminated')
80 + }
81 +}
82 +
83 +export const makeCert = debounceAsync(async (domain: string, email?: string) => {
84 + if (!domain) return new ApiError(HTTP_BAD_REQUEST, 'bad params')
85 + const res = await generateSSLCert(domain, email)
86 + const CERT_FILE = 'acme.cert'
87 + const KEY_FILE = 'acme.key'
88 + await fs.writeFile(CERT_FILE, res.cert)
89 + await fs.writeFile(KEY_FILE, res.key)
90 + cert.set(CERT_FILE) // update config
91 + privateKey.set(KEY_FILE)
92 +}, 0)
93 +
94 +defineConfig('acme_renew', false) // handle config changes
95 +events.once('https ready', () => repeat(HOUR, renewCert))
96 +
97 +export const acme_domain = defineConfig<string>('acme_domain', '')
98 +export const acme_email = defineConfig<string>('acme_email', '')
99 +
100 +// checks if the cert is near expiration date, and if so renews it
101 +const renewCert = debounceAsync(async () => {
102 + const cert = getCertObject()
103 + if (!cert) return
104 + const now = new Date()
105 + const validTo = new Date(cert.validTo)
106 + // not expiring in a month
107 + if (now > new Date(cert.validFrom) && now < validTo && validTo.getTime() - now.getTime() >= 30 * DAY)
108 + return console.log("certificate still good")
109 + await makeCert(acme_domain.get(), acme_email.get())
110 + .catch(e => console.log("error renewing certificate: ", String(e)))
111 +}, 0, { retain: DAY, retainFailure: HOUR })
112 +
src/adminApis.ts
+3 -3
@@ -2,7 +2,7 @@
2
3 import { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
4 import { configFile, defineConfig, getWholeConfig, setConfig } from './config'
5 -import { getIps, getServerStatus, getUrls } from './listen'
5 +import { getBaseUrlOrDefault, getIps, getServerStatus, getUrls } from './listen'
6 import {
7 API_VERSION,
8 BUILD_TIMESTAMP,
@@ -23,7 +23,7 @@ import { debounceAsync, isLocalHost, makeNetMatcher, onOff, tryJson, wait, waitF
23 import events from './events'
24 import { accountCanLoginAdmin, accountsConfig, getFromAccount } from './perm'
25 import Koa from 'koa'
26 -import { baseUrl, getProxyDetected } from './middlewares'
26 +import { getProxyDetected } from './middlewares'
27 import { writeFile } from 'fs/promises'
28 import { createReadStream } from 'fs'
29 import * as readline from 'readline'
@@ -104,7 +104,7 @@ export const adminApis: ApiHandlers = {
104 ...await getServerStatus(),
105 urls: await getUrls(),
106 ips: await getIps(false),
107 - baseUrl: baseUrl.get(), // can be retrieved with get_config, but it's very handy with urls and low overhead. Case is different because the context is
107 + baseUrl: getBaseUrlOrDefault(),
108 updatePossible: !updateSupported() ? false : await localUpdateAvailable() ? 'local' : true,
109 proxyDetected: getProxyDetected(),
110 frpDetected: localhostAdmin.get() && !getProxyDetected()
src/api.net.ts
+28 -269
@@ -1,253 +1,42 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { ApiError, ApiHandlers } from './apiMiddleware'
4 -import { Client } from 'nat-upnp-ts'
5 -import { HTTP_BAD_REQUEST, HTTP_FAILED_DEPENDENCY, HTTP_OK, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE,
6 - HTTP_PRECONDITION_FAILED, IS_MAC, IS_WINDOWS, SPECIAL_URI
7 -} from './const'
4 +import { HTTP_FAILED_DEPENDENCY, HTTP_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, HTTP_PRECONDITION_FAILED } from './const'
5 import _ from 'lodash'
9 -import { cert, getCertObject, getIps, getServerStatus, privateKey } from './listen'
6 +import { getCertObject } from './listen'
7 import { getProjectInfo } from './github'
11 -import { httpString } from './util-http'
12 -import { exec } from 'child_process'
13 -import {
14 - apiAssertTypes, DAY, debounceAsync, haveTimeout, HOUR, MINUTE, objSameKeys, onlyTruthy, repeat, Dict,
15 - GetNat, promiseBestEffort, wantArray
16 -} from './misc'
17 -import acme from 'acme-client'
18 -import fs from 'fs/promises'
19 -import { createServer, RequestListener } from 'http'
20 -import { Middleware } from 'koa'
8 +import { apiAssertTypes, objSameKeys, onlyTruthy, promiseBestEffort } from './misc'
9 import { lookup, Resolver } from 'dns/promises'
22 -import { defineConfig } from './config'
23 -import events from './events'
24 -import { isIP, isIPv6 } from 'net'
25 -
26 -const upnpClient = new Client({ timeout: 4_000 })
27 -const originalMethod = upnpClient.getGateway
28 -// other client methods call getGateway too, so this will ensure they reuse this same result
29 -upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), 0, { retain: HOUR, retainFailure: 30_000 })
30 -upnpClient.getGateway().catch(() => {})
31 -
32 -export let externalIp = '' // poll external ip
33 -repeat(10 * MINUTE, () => upnpClient.getPublicIp().then(v => {
34 - if (v !== externalIp)
35 - getPublicIps.clearRetain()
36 - return externalIp = v
37 -}))
38 -
39 -const getNatInfo = debounceAsync(async () => {
40 - const gettingIps = getPublicIps() // don't wait, do it in parallel
41 - const res = await upnpClient.getGateway().catch(() => null)
42 - const status = await getServerStatus()
43 - const mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null)
44 - console.debug('mappings found', mappings?.map(x => x.description))
45 - const gatewayIp = res ? new URL(res.gateway.description).hostname : await findGateway().catch(() => undefined)
46 - const localIp = res?.address || (await getIps())[0]
47 - const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined
48 - const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
49 - return {
50 - upnp: Boolean(res),
51 - localIp,
52 - gatewayIp,
53 - publicIps: await gettingIps,
54 - externalIp,
55 - mapped,
56 - internalPort,
57 - externalPort: mapped?.public.port,
58 - proto: status?.https?.listening ? 'https' : status?.http?.listening ? 'http' : undefined,
59 - } satisfies GetNat
60 -})
61 -
62 -const getPublicIps = debounceAsync(async () => {
63 - const res = await getProjectInfo()
64 - const groupedByVersion = Object.values(_.groupBy(res.publicIpServices, x => x.v ?? 4))
65 - const ips = await promiseBestEffort(groupedByVersion.map(singleVersion =>
66 - Promise.any(singleVersion.map(async (svc: any) => {
67 - if (typeof svc === 'string')
68 - svc = { type: 'http', url: svc }
69 - console.debug("trying ip service", svc.url || svc.name)
70 - if (svc.type === 'http')
71 - return httpString(svc.url)
72 - if (svc.type !== 'dns') throw "unsupported"
73 - const resolver = new Resolver({ timeout: 2_000 })
74 - resolver.setServers(svc.ips)
75 - return resolver.resolve(svc.name, svc.dnsRecord)
76 - }).map(async ret => {
77 - const validIps = wantArray(await ret).map(x => x.trim()).filter(isIP)
78 - if (!validIps.length) throw "no good"
79 - return validIps
80 - }) )))
81 - return _.uniq(ips.flat())
82 -}, 0, { retain: 10 * MINUTE })
83 -
84 -function findGateway(): Promise<string | undefined> {
85 - return new Promise((resolve, reject) =>
86 - exec(IS_WINDOWS || IS_MAC ? 'netstat -rn' : 'route -n', (err, out) => {
87 - if (err) return reject(err)
88 - const re = IS_WINDOWS ? /(?:0\.0\.0\.0 +){2}([\d.]+)/ : IS_MAC ? /default +([\d.]+)/ : /^0\.0\.0\.0 +([\d.]+)/
89 - resolve(re.exec(out)?.[1])
90 - }) )
91 -}
92 -
93 -let acmeMiddlewareEnabled = false
94 -const acmeTokens: Dict<string> = {}
95 -const acmeListener: RequestListener = (req, res) => { // node format
96 - const BASE = '/.well-known/acme-challenge/'
97 - if (!req.url?.startsWith(BASE)) return
98 - const token = req.url.slice(BASE.length)
99 - console.debug("got http challenge", token)
100 - res.statusCode = HTTP_OK
101 - res.end(acmeTokens[token])
102 - return true
103 -}
104 -export const acmeMiddleware: Middleware = (ctx, next) => { // koa format
105 - if (!acmeMiddlewareEnabled || !Boolean(acmeListener(ctx.req, ctx.res)))
106 - return next()
107 -}
108 -
109 -let selfCheckMiddlewareEnabled = false
110 -const CHECK_URL = SPECIAL_URI + 'self-check'
111 -export const selfCheckMiddleware: Middleware = (ctx, next) => { // koa format
112 - if (!selfCheckMiddlewareEnabled || !ctx.url.startsWith(CHECK_URL))
113 - return next()
114 - ctx.body = 'HFS'
115 -}
116 -
117 -async function checkDomain(domain: string) {
118 - const resolver = new Resolver()
119 - const prjInfo = await getProjectInfo()
120 - resolver.setServers(prjInfo.dnsServers)
121 - const settled = await Promise.allSettled([
122 - resolver.resolve(domain, 'A'),
123 - resolver.resolve(domain, 'AAAA'),
124 - lookup(domain).then(x => [x.address]),
125 - ])
126 - // merge all results
127 - const domainIps = _.uniq(onlyTruthy(settled.map(x => x.status === 'fulfilled' && x.value)).flat())
128 - if (!domainIps.length)
129 - throw new ApiError(HTTP_FAILED_DEPENDENCY, "domain not working")
130 - const { publicIps } = await getNatInfo() // do this before stopping the server
131 - for (const v6 of [false, true]) {
132 - const domainIpsThisVersion = domainIps.filter(x => isIPv6(x) === v6)
133 - const ipsThisVersion = publicIps.filter(x => isIPv6(x) === v6)
134 - if (domainIpsThisVersion.length && ipsThisVersion.length && !_.intersection(domainIpsThisVersion, ipsThisVersion).length)
135 - throw new ApiError(HTTP_PRECONDITION_FAILED, `configure your domain to point to ${ipsThisVersion} (currently on ${domainIpsThisVersion[0]}) – a change can take hours to be effective`)
136 - }
137 -}
138 -
139 -async function generateSSLCert(domain: string, email?: string) {
140 - // will answer challenge through our koa app (if on port 80) or must we spawn a dedicated server?
141 - const { upnp, externalPort } = await getNatInfo()
142 - const { http } = await getServerStatus()
143 - const tempSrv = externalPort === 80 || http.listening && http.port === 80 ? undefined : createServer(acmeListener)
144 - if (tempSrv)
145 - await new Promise<void>((resolve) =>
146 - tempSrv.listen(80, resolve).on('error', (e: any) => {
147 - console.debug("cannot listen on 80", e.code || e)
148 - resolve() // go on anyway
149 - }) )
150 - acmeMiddlewareEnabled = true
151 - console.debug('acme challenge server ready')
152 - try {
153 - const checkUrl = `http://${domain}`
154 - let check = await selfCheck(checkUrl) // some check services may not consider the domain, but we already verified that
155 - if (check && !check.success && upnp && externalPort !== 80) { // consider a short-lived mapping
156 - console.debug("setting temporary port forward")
157 - // @ts-ignore
158 - await upnpClient.createMapping({ private: 80, public: { host: '', port: 80 }, description: 'hfs temporary', ttl: 30 }).catch(() => {})
159 - check = await selfCheck(checkUrl) // repeat test
160 - }
161 - //if (!check) throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
162 - if (!check?.success)
163 - throw new ApiError(HTTP_FAILED_DEPENDENCY, "port 80 is not working on the specified domain")
164 - const acmeClient = new acme.Client({
165 - accountKey: await acme.crypto.createPrivateKey(),
166 - directoryUrl: acme.directory.letsencrypt.production
167 - })
168 - const [key, csr] = await acme.crypto.createCsr({ commonName: domain })
169 - const cert = await acmeClient.auto({
170 - csr,
171 - email,
172 - challengePriority: ['http-01'],
173 - skipChallengeVerification: true, // on NAT, trying to connect to your external ip will likely get your modem instead of the challenge server
174 - termsOfServiceAgreed: true,
175 - async challengeCreateFn(_, c, ka) {
176 - console.debug("producing challenge")
177 - acmeTokens[c.token] = ka
178 - },
179 - async challengeRemoveFn(_, c) {
180 - delete acmeTokens[c.token]
181 - },
182 - })
183 - return { key, cert }
184 - }
185 - finally {
186 - acmeMiddlewareEnabled = false
187 - if (tempSrv) await new Promise(res => tempSrv.close(res))
188 - console.debug('acme terminated')
189 - }
190 -}
191 -
192 -async function checkService(url: string, serviceKey: string) {
193 - interface PortScannerService {
194 - type?: string
195 - url: string
196 - headers: {[k: string]: string}
197 - method: string
198 - body?: string
199 - regexpFailure: string
200 - regexpSuccess: string
201 - }
202 - const prjInfo = await getProjectInfo()
203 - console.log(`checking server ${url}`)
204 - selfCheckMiddlewareEnabled = true
205 - try {
206 - const parsed = new URL(url)
207 - const family = !isIP(parsed.hostname) ? undefined : isIPv6(parsed.hostname) ? 6 : 4
208 - for (const services of _.chunk(_.shuffle<PortScannerService>(prjInfo[serviceKey]), 2)) {
209 - try {
210 - return await Promise.any(services.map(async (svc) => {
211 - if (!svc.url || svc.type) throw 'unsupported ' + svc.type // only default type supported for now
212 - let { url: serviceUrl, body, regexpSuccess, regexpFailure, ...rest } = svc
213 - const service = new URL(serviceUrl).hostname
214 - console.log('trying external service', service)
215 - body = applySymbols(body)
216 - serviceUrl = applySymbols(serviceUrl)!
217 - const res = await haveTimeout(10_000, httpString(serviceUrl, { family, ...rest, body }))
218 - const success = new RegExp(regexpSuccess).test(res)
219 - const failure = new RegExp(regexpFailure).test(res)
220 - if (success === failure) throw 'inconsistent: ' + service + ': ' + res // this result cannot be trusted
221 - console.debug(service, 'responded', success)
222 - return { success, service, url }
223 - }))
224 - }
225 - catch (e: any) {
226 - console.debug(e?.errors?.map(String) || e?.cause || String(e))
227 - }
228 - }
229 -
230 - function applySymbols(s?: string) {
231 - return s?.replace('$IP', parsed.hostname)
232 - .replace('$PORT', parsed.port || (parsed.protocol === 'https:' ? '443' : '80'))
233 - .replace('$URL', url.replace(/\/$/, '') + CHECK_URL)
234 - }
235 - }
236 - finally {
237 - selfCheckMiddlewareEnabled = false
238 - }
239 -}
240 -
241 -function selfCheck(url: string) {
242 - return checkService(url, 'selfCheckServices')
243 -}
10 +import { isIPv6 } from 'net'
11 +import { getNatInfo, upnpClient } from './nat'
12 +import { makeCert } from './acme'
13 +import { selfCheck } from './selfCheck'
14
15 const apis: ApiHandlers = {
16 get_nat: getNatInfo,
17
248 - check_domain({ domain }) {
18 + async check_domain({ domain }) {
19 apiAssertTypes({ string: domain })
250 - return checkDomain(domain)
20 + const resolver = new Resolver()
21 + const prjInfo = await getProjectInfo()
22 + resolver.setServers(prjInfo.dnsServers)
23 + const settled = await Promise.allSettled([
24 + resolver.resolve(domain, 'A'),
25 + resolver.resolve(domain, 'AAAA'),
26 + lookup(domain).then(x => [x.address]),
27 + ])
28 + // merge all results
29 + const domainIps = _.uniq(onlyTruthy(settled.map(x => x.status === 'fulfilled' && x.value)).flat())
30 + if (!domainIps.length)
31 + throw new ApiError(HTTP_FAILED_DEPENDENCY, "domain not working")
32 + const { publicIps } = await getNatInfo() // do this before stopping the server
33 + for (const v6 of [false, true]) {
34 + const domainIpsThisVersion = domainIps.filter(x => isIPv6(x) === v6)
35 + const ipsThisVersion = publicIps.filter(x => isIPv6(x) === v6)
36 + if (domainIpsThisVersion.length && ipsThisVersion.length && !_.intersection(domainIpsThisVersion, ipsThisVersion).length)
37 + throw new ApiError(HTTP_PRECONDITION_FAILED, `configure your domain to point to ${ipsThisVersion} (currently on ${domainIpsThisVersion[0]}) – a change can take hours to be effective`)
38 + }
39 + return {}
40 },
41
42 async map_port({ external, internal }) {
@@ -291,34 +80,4 @@ const apis: ApiHandlers = {
80 }
81 }
82
294 -export const acme_domain = defineConfig<string>('acme_domain', '')
295 -export const acme_email = defineConfig<string>('acme_email', '')
296 -
297 -export const makeCert = debounceAsync(async (domain: string, email?: string) => {
298 - if (!domain) return new ApiError(HTTP_BAD_REQUEST, 'bad params')
299 - const res = await generateSSLCert(domain, email)
300 - const CERT_FILE = 'acme.cert'
301 - const KEY_FILE = 'acme.key'
302 - await fs.writeFile(CERT_FILE, res.cert)
303 - await fs.writeFile(KEY_FILE, res.key)
304 - cert.set(CERT_FILE) // update config
305 - privateKey.set(KEY_FILE)
306 -}, 0)
307 -
308 -defineConfig('acme_renew', false) // handle config changes
309 -events.once('https ready', () => repeat(HOUR, renewCert))
310 -
311 -// checks if the cert is near expiration date, and if so renews it
312 -const renewCert = debounceAsync(async () => {
313 - const cert = getCertObject()
314 - if (!cert) return
315 - const now = new Date()
316 - const validTo = new Date(cert.validTo)
317 - // not expiring in a month
318 - if (now > new Date(cert.validFrom) && now < validTo && validTo.getTime() - now.getTime() >= 30 * DAY)
319 - return console.log("certificate still good")
320 - await makeCert(acme_domain.get(), acme_email.get())
321 - .catch(e => console.log("error renewing certificate: ", String(e)))
322 -}, 0, { retain: DAY, retainFailure: HOUR })
323 -
83 export default apis
\ No newline at end of file
src/api.vfs.ts
+15 -7
@@ -6,13 +6,14 @@ import _ from 'lodash'
6 import { stat } from 'fs/promises'
7 import { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
8 import { dirname, extname, join, resolve } from 'path'
9 -import { dirStream, isDirectory, isWindowsDrive, makeMatcher, PERM_KEYS, VfsNodeAdminSend } from './misc'
9 +import { dirStream, enforceFinal, isDirectory, isWindowsDrive, makeMatcher, PERM_KEYS, VfsNodeAdminSend } from './misc'
10 import {
11 IS_WINDOWS,
12 HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_ACCEPTABLE,
13 } from './const'
14 import { getDrives } from './util-os'
15 import { Stats } from 'fs'
16 +import { getBaseUrlOrDefault } from './listen'
17
18 // to manipulate the tree we need the original node
19 async function urlToNodeOriginal(uri: string) {
@@ -98,25 +99,32 @@ const apis: ApiHandlers = {
99 async add_vfs({ parent, source, name }) {
100 if (!source && !name)
101 return new ApiError(HTTP_BAD_REQUEST, 'name or source required')
101 - parent = parent ? await urlToNodeOriginal(parent) : vfs
102 - if (!parent)
102 + const parentNode = parent ? await urlToNodeOriginal(parent) : vfs
103 + if (!parentNode)
104 return new ApiError(HTTP_NOT_FOUND, 'parent not found')
104 - if (!await nodeIsDirectory(parent))
105 + if (!await nodeIsDirectory(parentNode))
106 return new ApiError(HTTP_NOT_ACCEPTABLE, 'parent not a folder')
107 if (isWindowsDrive(source))
108 source += '\\' // slash must be included, otherwise it will refer to the cwd of that drive
109 + const isDir = source && await isDirectory(source)
110 + if (source && isDir === undefined)
111 + return new ApiError(HTTP_NOT_FOUND, 'source not found')
112 const child = { source, name }
113 name = getNodeName(child) // could be not given as input
114 const ext = extname(name)
115 const noExt = ext ? name.slice(0, -ext.length) : name
116 let idx = 2
113 - while (parent.children?.find(isSameFilenameAs(name)))
117 + while (parentNode.children?.find(isSameFilenameAs(name)))
118 name = `${noExt} ${idx++}${ext}`
119 child.name = name
120 simplifyName(child)
117 - ;(parent.children ||= []).unshift(child)
121 + ;(parentNode.children ||= []).unshift(child)
122 await saveVfs()
119 - return { name }
123 + const link = getBaseUrlOrDefault()
124 + + (parent ? enforceFinal('/', parent) : '/')
125 + + encodeURIComponent(getNodeName(child))
126 + + (isDir ? '/' : '')
127 + return { name, link }
128 },
129
130 async del_vfs({ uris }) {
src/cross.ts
+2 -2
@@ -112,8 +112,8 @@ export function objSameKeys<S extends object,VR=any>(src: S, newValue:(value:Tru
112 return Object.fromEntries(Object.entries(src).map(([k,v]) => [k, newValue(v,k as keyof S)])) as { [K in keyof S]:VR }
113 }
114
115 -export function enforceFinal(sub:string, s:string) {
116 - return !s || s.endsWith(sub) ? s : s+sub
115 +export function enforceFinal(sub:string, s:string, evenEmpty=false) {
116 + return !evenEmpty && !s || s.endsWith(sub) ? s : s+sub
117 }
118
119 export function truthy<T>(value: T): value is Truthy<T> {
src/index.ts
+2 -1
@@ -19,7 +19,8 @@ import { ok } from 'assert'
19 import _ from 'lodash'
20 import { randomId } from './misc'
21 import session from 'koa-session'
22 -import { acmeMiddleware, selfCheckMiddleware } from './api.net'
22 +import { selfCheckMiddleware } from './selfCheck'
23 +import { acmeMiddleware } from './acme'
24
25 ok(_.intersection(Object.keys(frontEndApis), Object.keys(adminApis)).length === 0) // they share same endpoints, don't clash
26
src/listen.ts
+20 -4
@@ -14,9 +14,9 @@ import findProcess from 'find-process'
14 import { anyAccountCanLoginAdmin } from './adminApis'
15 import _ from 'lodash'
16 import { X509Certificate } from 'crypto'
17 -import { externalIp } from './api.net'
17 import events from './events'
18 import { isIPv6 } from 'net'
19 +import { defaultBaseUrl } from './nat'
20
21 interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
22 let httpSrv: undefined | http.Server & ServerExtra
@@ -24,6 +24,12 @@ let httpsSrv: undefined | http.Server & ServerExtra
24
25 const openBrowserAtStart = defineConfig('open_browser_at_start', !DEV)
26
27 +export const baseUrl = defineConfig('base_url', '')
28 +
29 +export function getBaseUrlOrDefault() {
30 + return baseUrl.get() || defaultBaseUrl.get()
31 +}
32 +
33 export function getHttpsWorkingPort() {
34 return httpsSrv?.listening && (httpsSrv.address() as any)?.port
35 }
@@ -73,6 +79,8 @@ export function getCertObject() {
79
80 const considerHttps = debounceAsync(async () => {
81 stopServer(httpsSrv).then()
82 + defaultBaseUrl.proto = 'http'
83 + defaultBaseUrl.port = getCurrentPort(httpSrv) ?? 0
84 let port = httpsPortCfg.get()
85 try {
86 while (!app)
@@ -111,6 +119,8 @@ const considerHttps = debounceAsync(async () => {
119 httpsSrv.on('connection', newConnection)
120 printUrls(httpsSrv.name)
121 events.emit('https ready')
122 + defaultBaseUrl.proto = 'https'
123 + defaultBaseUrl.port = getCurrentPort(httpsSrv) ?? 0
124 })
125
126
@@ -218,6 +228,10 @@ export function stopServer(srv?: http.Server) {
228 })
229 }
230
231 +function getCurrentPort(srv: typeof httpSrv) {
232 + return (srv?.address() as any)?.port as number | undefined
233 +}
234 +
235 export async function getServerStatus() {
236 return {
237 http: await serverStatus(httpSrv, portCfg.get()),
@@ -230,7 +244,7 @@ export async function getServerStatus() {
244 return {
245 ..._.pick(srv, ['listening', 'error']),
246 busy,
233 - port: (srv?.address() as any)?.port as number || configuredPort,
247 + port: getCurrentPort(srv) || configuredPort,
248 configuredPort,
249 srv,
250 }
@@ -243,11 +257,13 @@ export async function getIps(external=true) {
257 nets && !ignore.test(name)
258 && v4first(onlyTruthy(nets.map(net => !net.internal && net.address)))[0] // for each interface we consider only 1 address
259 )).flat()
246 - const e = external && externalIp
260 + const e = external && defaultBaseUrl.externalIp
261 if (e && !ips.includes(e))
262 ips.unshift(e)
249 - return v4first(ips)
263 + const ret = v4first(ips)
264 .filter((x,i,a) => a.length > 1 || !x.startsWith('169.254')) // 169.254 = dhcp failure on the interface, but keep it if it's our only one
265 + defaultBaseUrl.localIp = ret[0] || ''
266 + return ret
267
268 function v4first(a: string[]) {
269 return _.sortBy(a, isIPv6) // works because `false` comes first
src/middlewares.ts
+1 -3
@@ -34,7 +34,7 @@ import formidable from 'formidable'
34 import { uploadWriter } from './upload'
35 import { allowAdmin, favicon } from './adminApis'
36 import { constants } from 'zlib'
37 -import { getHttpsWorkingPort } from './listen'
37 +import { baseUrl, getHttpsWorkingPort } from './listen'
38 import { defineConfig } from './config'
39 import { getLangData } from './lang'
40
@@ -172,8 +172,6 @@ const errorMessages = {
172 [HTTP_FORBIDDEN]: "Forbidden",
173 }
174
175 -export const baseUrl = defineConfig('base_url', '')
176 -
175 async function sendFolderList(node: VfsNode, ctx: Koa.Context) {
176 let { depth=0, folders, prepend } = ctx.query
177 ctx.type = 'text'
src/nat.ts new
+97
@@ -0,0 +1,97 @@
1 +import { proxy } from 'valtio'
2 +import { Client } from 'nat-upnp-ts'
3 +import { debounceAsync } from './debounceAsync'
4 +import { GetNat, haveTimeout, HOUR, MINUTE, promiseBestEffort, repeat, wantArray } from './cross'
5 +import { getProjectInfo } from './github'
6 +import _ from 'lodash'
7 +import { httpString } from './util-http'
8 +import { Resolver } from 'dns/promises'
9 +import { isIP } from 'net'
10 +import { baseUrl, getIps, getServerStatus } from './listen'
11 +import { exec } from 'child_process'
12 +import { IS_MAC, IS_WINDOWS } from './const'
13 +
14 +export const defaultBaseUrl = proxy({
15 + proto: 'http',
16 + publicIps: [] as string[],
17 + externalIp: '',
18 + localIp: '',
19 + port: 0,
20 + get() {
21 + const defPort = this.proto === 'https' ? 443 : 80
22 + return `${this.proto}://${ this.publicIps[0] || this.externalIp || this.localIp}${!this.port || this.port === defPort ? '' : ':' + this.port}`
23 + }
24 +})
25 +
26 +export function getBaseUrlOrDefault() {
27 + return baseUrl.get() || defaultBaseUrl.get()
28 +}
29 +
30 +export const upnpClient = new Client({ timeout: 4_000 })
31 +const originalMethod = upnpClient.getGateway
32 +// other client methods call getGateway too, so this will ensure they reuse this same result
33 +upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), 0, { retain: HOUR, retainFailure: 30_000 })
34 +upnpClient.getGateway().catch(() => {})
35 +
36 +// poll external ip
37 +repeat(10 * MINUTE, () => upnpClient.getPublicIp().then(v => {
38 + if (v === defaultBaseUrl.externalIp) return
39 + getPublicIps.clearRetain()
40 + return defaultBaseUrl.externalIp = v
41 +}))
42 +
43 +const getPublicIps = debounceAsync(async () => {
44 + const res = await getProjectInfo()
45 + const groupedByVersion = Object.values(_.groupBy(res.publicIpServices, x => x.v ?? 4))
46 + const ips = await promiseBestEffort(groupedByVersion.map(singleVersion =>
47 + Promise.any(singleVersion.map(async (svc: any) => {
48 + if (typeof svc === 'string')
49 + svc = { type: 'http', url: svc }
50 + console.debug("trying ip service", svc.url || svc.name)
51 + if (svc.type === 'http')
52 + return httpString(svc.url)
53 + if (svc.type !== 'dns') throw "unsupported"
54 + const resolver = new Resolver({ timeout: 2_000 })
55 + resolver.setServers(svc.ips)
56 + return resolver.resolve(svc.name, svc.dnsRecord)
57 + }).map(async ret => {
58 + const validIps = wantArray(await ret).map(x => x.trim()).filter(isIP)
59 + if (!validIps.length) throw "no good"
60 + return validIps
61 + }) )))
62 + return _.uniq(ips.flat())
63 +}, 0, { retain: 10 * MINUTE })
64 +
65 +export const getNatInfo = debounceAsync(async () => {
66 + const gettingIps = getPublicIps() // don't wait, do it in parallel
67 + const res = await upnpClient.getGateway().catch(() => null)
68 + const status = await getServerStatus()
69 + const mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null)
70 + console.debug('mappings found', mappings?.map(x => x.description))
71 + const gatewayIp = res ? new URL(res.gateway.description).hostname : await findGateway().catch(() => undefined)
72 + const localIp = res?.address || (await getIps())[0]
73 + const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined
74 + const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
75 + const externalPort = mapped?.public.port
76 + defaultBaseUrl.port = externalPort || internalPort || 0
77 + return {
78 + upnp: Boolean(res),
79 + localIp,
80 + gatewayIp,
81 + publicIps: defaultBaseUrl.publicIps = await gettingIps,
82 + externalIp: defaultBaseUrl.externalIp,
83 + mapped,
84 + internalPort,
85 + externalPort,
86 + proto: status?.https?.listening ? 'https' : status?.http?.listening ? 'http' : '',
87 + } satisfies GetNat
88 +})
89 +
90 +function findGateway(): Promise<string | undefined> {
91 + return new Promise((resolve, reject) =>
92 + exec(IS_WINDOWS || IS_MAC ? 'netstat -rn' : 'route -n', (err, out) => {
93 + if (err) return reject(err)
94 + const re = IS_WINDOWS ? /(?:0\.0\.0\.0 +){2}([\d.]+)/ : IS_MAC ? /default +([\d.]+)/ : /^0\.0\.0\.0 +([\d.]+)/
95 + resolve(re.exec(out)?.[1])
96 + }) )
97 +}
src/selfCheck.ts new
+64
@@ -0,0 +1,64 @@
1 +import { SPECIAL_URI } from './cross-const'
2 +import { Middleware } from 'koa'
3 +import { getProjectInfo } from './github'
4 +import { isIP, isIPv6 } from 'net'
5 +import _ from 'lodash'
6 +import { haveTimeout } from './cross'
7 +import { httpString } from './util-http'
8 +
9 +let selfCheckMiddlewareEnabled = false
10 +const CHECK_URL = SPECIAL_URI + 'self-check'
11 +export const selfCheckMiddleware: Middleware = (ctx, next) => { // koa format
12 + if (!selfCheckMiddlewareEnabled || !ctx.url.startsWith(CHECK_URL))
13 + return next()
14 + ctx.body = 'HFS'
15 +}
16 +
17 +export async function selfCheck(url: string) {
18 + interface PortScannerService {
19 + type?: string
20 + url: string
21 + headers: {[k: string]: string}
22 + method: string
23 + body?: string
24 + regexpFailure: string
25 + regexpSuccess: string
26 + }
27 + const prjInfo = await getProjectInfo()
28 + console.log(`checking server ${url}`)
29 + selfCheckMiddlewareEnabled = true
30 + try {
31 + const parsed = new URL(url)
32 + const family = !isIP(parsed.hostname) ? undefined : isIPv6(parsed.hostname) ? 6 : 4
33 + for (const services of _.chunk(_.shuffle<PortScannerService>(prjInfo.selfCheckServices), 2)) {
34 + try {
35 + return await Promise.any(services.map(async (svc) => {
36 + if (!svc.url || svc.type) throw 'unsupported ' + svc.type // only default type supported for now
37 + let { url: serviceUrl, body, regexpSuccess, regexpFailure, ...rest } = svc
38 + const service = new URL(serviceUrl).hostname
39 + console.log('trying external service', service)
40 + body = applySymbols(body)
41 + serviceUrl = applySymbols(serviceUrl)!
42 + const res = await haveTimeout(10_000, httpString(serviceUrl, { family, ...rest, body }))
43 + const success = new RegExp(regexpSuccess).test(res)
44 + const failure = new RegExp(regexpFailure).test(res)
45 + if (success === failure) throw 'inconsistent: ' + service + ': ' + res // this result cannot be trusted
46 + console.debug(service, 'responded', success)
47 + return { success, service, url }
48 + }))
49 + }
50 + catch (e: any) {
51 + console.debug(e?.errors?.map(String) || e?.cause || String(e))
52 + }
53 + }
54 +
55 + function applySymbols(s?: string) {
56 + return s?.replace('$IP', parsed.hostname)
57 + .replace('$PORT', parsed.port || (parsed.protocol === 'https:' ? '443' : '80'))
58 + .replace('$URL', url.replace(/\/$/, '') + CHECK_URL)
59 + }
60 + }
61 + finally {
62 + selfCheckMiddlewareEnabled = false
63 + }
64 +}
src/util-files.ts
+1 -1
@@ -16,7 +16,7 @@ import fsx from 'fs-x-attributes'
16
17 export async function isDirectory(path: string) {
18 try { return (await fs.stat(path)).isDirectory() }
19 - catch { return false }
19 + catch {}
20 }
21
22 export async function readFileBusy(path: string): Promise<string> {