'use strict'; 'require view'; 'require form'; 'require rpc'; 'require tools.widgets as widgets'; /* * Important: * * This LuCI page writes UCI options into /etc/config/frps. * UCI option names must not contain ".". * * Therefore we keep UCI-safe option names here, then let init/generator script * convert them to frp TOML keys, for example: * * bind_addr -> bindAddr * bind_port -> bindPort * kcp_bind_port -> kcpBindPort * quic_bind_port -> quicBindPort * admin_port -> webServer.port * admin_tls_cert_file -> webServer.tls.certFile * tls_force -> transport.tls.force * max_pool_count -> transport.maxPoolCount * authentication_method -> auth.method * token_source_type -> auth.tokenSource.type * * Do NOT use option names like "webServer.port" in LuCI form options. */ /* * Debug-only TOML mapping. Keep these TOML: hints in the JS source for * troubleshooting generated configs, but do not show them in LuCI form text * and do not wrap them in _(). * * common.bind_addr -> TOML: bindAddr * common.bind_port -> TOML: bindPort * common.kcp_bind_port -> TOML: kcpBindPort * common.quic_bind_port -> TOML: quicBindPort * common.proxy_bind_addr -> TOML: proxyBindAddr * common.vhost_http_port -> TOML: vhostHTTPPort * common.vhost_https_port -> TOML: vhostHTTPSPort * common.vhost_http_timeout -> TOML: vhostHTTPTimeout * common.tcpmux_httpconnect_port -> TOML: tcpmuxHTTPConnectPort * common.tcpmux_passthrough -> TOML: tcpmuxPassthrough * common.subdomain_host -> TOML: subDomainHost * common.custom_404_page -> TOML: custom404Page * common.udp_packet_size -> TOML: udpPacketSize * common.detailed_errors_to_client -> TOML: detailedErrorsToClient * common.user_conn_timeout -> TOML: userConnTimeout * common.nathole_analysis_data_reserve_hours -> TOML: natholeAnalysisDataReserveHours * common.authentication_method -> TOML: auth.method * common.token -> TOML: auth.token * common.auth_additional_scopes -> TOML: auth.additionalScopes * common.token_source_type -> TOML: auth.tokenSource.type * common.token_source_file_path -> TOML: auth.tokenSource.file.path * common.token_source_exec_command -> TOML: auth.tokenSource.exec.command * common.token_source_exec_args -> TOML: auth.tokenSource.exec.args * common.token_source_exec_env -> TOML: auth.tokenSource.exec.env * common.oidc_issuer -> TOML: auth.oidc.issuer * common.oidc_audience -> TOML: auth.oidc.audience * common.oidc_skip_expiry_check -> TOML: auth.oidc.skipExpiryCheck * common.oidc_skip_issuer_check -> TOML: auth.oidc.skipIssuerCheck * common.max_pool_count -> TOML: transport.maxPoolCount * common.tcp_mux -> TOML: transport.tcpMux * common.tcp_mux_keepalive_interval -> TOML: transport.tcpMuxKeepaliveInterval * common.tcp_keepalive -> TOML: transport.tcpKeepalive * common.heartbeat_timeout -> TOML: transport.heartbeatTimeout * common.tls_force -> TOML: transport.tls.force * common.tls_cert_file -> TOML: transport.tls.certFile * common.tls_key_file -> TOML: transport.tls.keyFile * common.tls_trusted_ca_file -> TOML: transport.tls.trustedCaFile * common.quic_keepalive_period -> TOML: transport.quic.keepalivePeriod * common.quic_max_idle_timeout -> TOML: transport.quic.maxIdleTimeout * common.quic_max_incoming_streams -> TOML: transport.quic.maxIncomingStreams * common.admin_addr -> TOML: webServer.addr * common.admin_port -> TOML: webServer.port * common.admin_user -> TOML: webServer.user * common.admin_pwd -> TOML: webServer.password * common.admin_tls_cert_file -> TOML: webServer.tls.certFile * common.admin_tls_key_file -> TOML: webServer.tls.keyFile * common.assets_dir -> TOML: webServer.assetsDir * common.pprof_enable -> TOML: webServer.pprofEnable * common.enable_prometheus -> TOML: enablePrometheus * common.allow_ports -> TOML: allowPorts * common.max_ports_per_client -> TOML: maxPortsPerClient * common.ssh_tunnel_bind_port -> TOML: sshTunnelGateway.bindPort * common.ssh_tunnel_private_key_file -> TOML: sshTunnelGateway.privateKeyFile * common.ssh_tunnel_auto_gen_private_key_path -> TOML: sshTunnelGateway.autoGenPrivateKeyPath * common.ssh_tunnel_authorized_keys_file -> TOML: sshTunnelGateway.authorizedKeysFile * common.log_file -> TOML: log.to * common.log_level -> TOML: log.level * common.log_max_days -> TOML: log.maxDays * common.disable_log_color -> TOML: log.disablePrintColor * http_plugin.name -> TOML: httpPlugins[].name * http_plugin.addr -> TOML: httpPlugins[].addr * http_plugin.path -> TOML: httpPlugins[].path * http_plugin.ops -> TOML: httpPlugins[].ops * http_plugin.tls_verify -> TOML: httpPlugins[].tlsVerify */ const startupConf = [ [form.Flag, 'enabled', _('Enable service'), null, { enabled: '1', disabled: '0', default: '0', rmempty: false, retain: true, remove: writeFlagDisabled }], [form.Flag, 'stdout', _('Log stdout'), null, { enabled: '1', disabled: '0', default: '1', rmempty: false, retain: true, remove: writeFlagDisabled }], [form.Flag, 'stderr', _('Log stderr'), null, { enabled: '1', disabled: '0', default: '1', rmempty: false, retain: true, remove: writeFlagDisabled }], [widgets.UserSelect, 'user', _('Run daemon as user')], [widgets.GroupSelect, 'group', _('Run daemon as group')], [form.Flag, 'respawn', _('Respawn when crashed'), null, { enabled: '1', disabled: '0', default: '1', rmempty: false, retain: true, remove: writeFlagDisabled }], [form.DynamicList, 'env', _('Environment variable'), _('OS environments passed to frp for config file template, see %s.').format('frp README'), { placeholder: 'ENV_NAME=value', validate: function (section_id, value) { return validateEnv(value); } }], [form.DynamicList, 'conf_inc', _('Additional configs'), _('Extra root-level configuration fragments included before generated HTTP plugin sections. Use raw settings for table-specific extra options.'), { datatype: 'file', placeholder: '/etc/frp/frps.d/frps_extra.toml' }] ]; const commonBaseConf = [ [form.Value, 'bind_addr', _('Bind address'), _('Address that frps binds to.'), { datatype: 'host', placeholder: '0.0.0.0' }], [form.Value, 'bind_port', _('Bind port'), _('Port that frps listens on.'), { datatype: 'port', rmempty: false, placeholder: '7000' }], [form.Value, 'kcp_bind_port', _('KCP bind port'), _('UDP port used for KCP. Empty disables KCP. Do not reuse the same UDP port as QUIC.'), { placeholder: '7000', validate: function (section_id, value) { const portValid = validatePortOrZero(section_id, value); if (portValid !== true) return portValid; const quic = this.section.getOption('quic_bind_port'); const quicValue = quic ? quic.formvalue(section_id) : null; if (value && value !== '0' && quicValue && quicValue !== '0' && value === quicValue) return _('KCP bind port and QUIC bind port must be different.'); return true; } }], [form.Value, 'quic_bind_port', _('QUIC bind port'), _('UDP port used for QUIC. Empty disables QUIC. Do not reuse the same UDP port as KCP.'), { placeholder: '7002', validate: function (section_id, value) { const portValid = validatePortOrZero(section_id, value); if (portValid !== true) return portValid; const kcp = this.section.getOption('kcp_bind_port'); const kcpValue = kcp ? kcp.formvalue(section_id) : null; if (value && value !== '0' && kcpValue && kcpValue !== '0' && value === kcpValue) return _('KCP bind port and QUIC bind port must be different.'); return true; } }], [form.Value, 'proxy_bind_addr', _('Proxy bind address'), _('Address that proxy listeners bind to.'), { datatype: 'host', placeholder: '0.0.0.0' }], [form.Value, 'vhost_http_port', _('Vhost HTTP port'), _('Port for HTTP virtual host requests. Empty or 0 disables it.'), { validate: validatePortOrZero, placeholder: '80' }], [form.Value, 'vhost_https_port', _('Vhost HTTPS port'), _('Port for HTTPS virtual host requests. Empty or 0 disables it.'), { validate: validatePortOrZero, placeholder: '443' }], [form.Value, 'vhost_http_timeout', _('Vhost HTTP timeout'), _('Response header timeout for vhost HTTP server, in seconds.'), { datatype: 'uinteger', placeholder: '60' }], [form.Value, 'tcpmux_httpconnect_port', _('TCPMUX HTTP CONNECT port'), _('Port for TCPMUX HTTP CONNECT requests. Empty or 0 disables it.'), { validate: validatePortOrZero, placeholder: '0' }], [form.Flag, 'tcpmux_passthrough', _('TCPMUX passthrough'), _('Do not update traffic when TCPMUX passthrough is enabled.'), { datatype: 'bool', default: 'false' }], [form.Value, 'subdomain_host', _('Subdomain host'), _('Domain suffix for subdomain-based HTTP/HTTPS proxies.'), { placeholder: 'frps.com' }], [form.Value, 'custom_404_page', _('Custom 404 page'), _('Path to custom 404 page for HTTP requests.'), { datatype: 'file' }], [form.Value, 'udp_packet_size', _('UDP packet size'), _('UDP packet size in bytes. Should match frpc.'), { datatype: 'uinteger', placeholder: '1500' }] ]; const commonAuthConf = [ [form.ListValue, 'authentication_method', _('Authentication method'), _('Authentication method used to authenticate frpc.'), { values: [['token', 'token'], ['oidc', 'oidc']], default: 'token' }], [form.Value, 'token', _('Token'), _('Token used for authentication.'), { depends: { authentication_method: 'token' }, password: true, validate: function (section_id, value) { const tokenSourceType = this.section.getOption('token_source_type'); if (value && tokenSourceType && tokenSourceType.formvalue(section_id)) return _('Token and token source are mutually exclusive.'); return true; } }], [form.MultiValue, 'auth_additional_scopes', _('Additional auth scopes'), _('Additional scopes to include authentication information.'), { values: ['HeartBeats', 'NewWorkConns'] }], [form.ListValue, 'token_source_type', _('Token source type'), _('Load token from an external source. File reads the token from a local file; exec runs a command and requires frp unsafe permission. Mutually exclusive with auth.token.'), { depends: { authentication_method: 'token' }, values: [['', _('Disabled')], ['file', 'file'], ['exec', 'exec']], rmempty: true, validate: function (section_id, value) { const token = this.section.getOption('token'); if (value && token && token.formvalue(section_id)) return _('Token and token source are mutually exclusive.'); return true; } }], [form.Value, 'token_source_file_path', _('Token source file'), _('Path of token file.'), { depends: { authentication_method: 'token', token_source_type: 'file' }, datatype: 'file', rmempty: false, validate: function (section_id, value) { const tokenSourceType = this.section.getOption('token_source_type'); if ( tokenSourceType && tokenSourceType.formvalue(section_id) === 'file' && !String(value || '').trim() ) return _('Token source file path is required when token source type is file.'); return true; } }], [form.Value, 'token_source_exec_command', _('Token source command'), _('Command used to obtain the token. The init script adds --allow-unsafe=TokenSourceExec when this source type is used.'), { depends: { authentication_method: 'token', token_source_type: 'exec' }, datatype: 'file', rmempty: false, validate: function (section_id, value) { const tokenSourceType = this.section.getOption('token_source_type'); if ( tokenSourceType && tokenSourceType.formvalue(section_id) === 'exec' && !String(value || '').trim() ) return _('Token source command is required when token source type is exec.'); return true; } }], [form.DynamicList, 'token_source_exec_args', _('Token source command arguments'), _('Command arguments passed to token source exec.'), { depends: { authentication_method: 'token', token_source_type: 'exec' }, placeholder: '--format' }], [form.DynamicList, 'token_source_exec_env', _('Token source environment'), _('Environment variables passed to token source exec. Use KEY=value.'), { depends: { authentication_method: 'token', token_source_type: 'exec' }, placeholder: 'TOKEN_SERVICE=production', validate: function (section_id, value) { return validateEnv(value); } }], [form.Value, 'oidc_issuer', _('OIDC issuer'), _('OIDC issuer used to verify tokens.'), { depends: { authentication_method: 'oidc' } }], [form.Value, 'oidc_audience', _('OIDC audience'), _('Audience OIDC tokens should contain.'), { depends: { authentication_method: 'oidc' } }], [form.Flag, 'oidc_skip_expiry_check', _('OIDC skip expiry check'), _('Skip checking whether the OIDC token is expired.'), { depends: { authentication_method: 'oidc' }, datatype: 'bool', default: 'false' }], [form.Flag, 'oidc_skip_issuer_check', _('OIDC skip issuer check'), _('Skip checking whether OIDC token issuer matches configured issuer.'), { depends: { authentication_method: 'oidc' }, datatype: 'bool', default: 'false' }] ]; const commonLogConf = [ [form.Value, 'log_file', _('Log output'), _('Log output path or console.'), { placeholder: 'console' }], [form.ListValue, 'log_level', _('Log level'), _('Configuration key: log.level.'), { values: ['trace', 'debug', 'info', 'warn', 'error'], default: 'info' }], [form.Value, 'log_max_days', _('Log max days'), _('Maximum number of days to keep logs.'), { datatype: 'uinteger', placeholder: '3' }], [form.Flag, 'disable_log_color', _('Disable log color'), _('Disable log colors when log output is console.'), { datatype: 'bool', default: 'false' }] ]; const commonWebConf = [ [form.Value, 'admin_addr', _('Web server address'), _('Web server bind address.'), { datatype: 'host', placeholder: '127.0.0.1' }], [form.Value, 'admin_port', _('Web server port'), _('Web server port. Leave empty or set to 0 to disable the web server.'), { validate: validatePortOrZero, placeholder: '7500' }], [form.Value, 'admin_user', _('Web server user'), _('Web server username.'), { placeholder: 'admin' }], [form.Value, 'admin_pwd', _('Web server password'), _('Web server password.'), { password: true, placeholder: 'change_me_to_a_strong_password' }], [form.Flag, 'admin_tls_enable', _('Enable web HTTPS'), _('Enable HTTPS for the frps web server. This switch controls whether webServer.tls.certFile and webServer.tls.keyFile are emitted.'), { enabled: 'true', disabled: 'false', default: 'false', rmempty: false, retain: true, remove: writeFlagDisabled }], [form.Value, 'admin_tls_cert_file', _('TLS certificate path'), _('Path to the certificate file used by the HTTPS web server.'), { datatype: 'file', retain: true, placeholder: '/etc/ssl/acme/example.com.fullchain.crt', validate: function (section_id, value) { if ( adminWebEnabled(this.section, section_id) && adminHttpsEnabled(this.section, section_id) && !String(value || '').trim() ) return _('TLS certificate path is required when web HTTPS is enabled.'); return true; } }], [form.Value, 'admin_tls_key_file', _('TLS private key path'), _('Path to the private key file used by the HTTPS web server.'), { datatype: 'file', retain: true, placeholder: '/etc/ssl/acme/example.com.key', validate: function (section_id, value) { if ( adminWebEnabled(this.section, section_id) && adminHttpsEnabled(this.section, section_id) && !String(value || '').trim() ) return _('TLS private key path is required when web HTTPS is enabled.'); return true; } }], [form.Value, 'assets_dir', _('Assets dir'), _('Web server assets directory.'), { datatype: 'directory' }], [form.Flag, 'pprof_enable', _('Enable pprof'), _('Enable Go pprof debug/profiling endpoints on the web server. It only takes effect when web server port is set.'), { datatype: 'bool', default: 'false', rmempty: false, retain: true, remove: writeFlagDisabled }], [form.Flag, 'enable_prometheus', _('Enable Prometheus'), _('Export Prometheus metrics on webServer /metrics API. It only takes effect when web server port is set.'), { datatype: 'bool', default: 'false', rmempty: false, retain: true, remove: writeFlagDisabled }] ]; const commonTransportConf = [ [form.Value, 'max_pool_count', _('Max pool count'), _('Maximum pool count for each proxy.'), { datatype: 'uinteger', placeholder: '5' }], [form.Flag, 'tcp_mux', _('TCP mux'), _('Enable TCP stream multiplexing. This setting must match frpc.'), { enabled: 'true', disabled: 'false', default: 'true', optional: false, rmempty: false, retain: true, remove: writeFlagDisabled }], [form.Value, 'tcp_mux_keepalive_interval', _('TCP mux keepalive interval'), _('Keepalive interval for TCP mux.'), { datatype: 'uinteger', placeholder: '30' }], [form.Value, 'tcp_keepalive', _('TCP keepalive'), _('TCP keepalive interval. Negative value disables keepalive.'), { datatype: 'integer', placeholder: '7200' }], [form.Value, 'heartbeat_timeout', _('Heartbeat timeout'), _('Heartbeat timeout in seconds.'), { datatype: 'integer', placeholder: '90' }], [form.Value, 'user_conn_timeout', _('User connection timeout'), _('Maximum time to wait for a work connection.'), { datatype: 'uinteger', placeholder: '10' }], [form.Flag, 'detailed_errors_to_client', _('Detailed errors to client'), _('Send specific error details to frpc.'), { enabled: 'true', disabled: 'false', default: 'true', optional: false, rmempty: false, retain: true, remove: writeFlagDisabled }] ]; const commonTlsQuicConf = [ [form.Flag, 'tls_force', _('Force TLS'), _('Only accept TLS-encrypted frpc connections.'), { enabled: 'true', disabled: 'false', default: 'false', optional: false, rmempty: false, retain: true, remove: writeFlagDisabled }], [form.Value, 'tls_cert_file', _('TLS certificate path'), _('Path to the TLS certificate file.'), { datatype: 'file', placeholder: '/etc/ssl/frp/server.crt' }], [form.Value, 'tls_key_file', _('TLS private key path'), _('Path to the TLS private key file.'), { datatype: 'file', placeholder: '/etc/ssl/frp/server.key' }], [form.Value, 'tls_trusted_ca_file', _('TLS trusted CA path'), _('Path to the trusted CA certificate file.'), { datatype: 'file', placeholder: '/etc/ssl/frp/ca.crt' }], [form.Value, 'quic_keepalive_period', _('QUIC keepalive period'), _('Configuration key: transport.quic.keepalivePeriod.'), { datatype: 'uinteger', placeholder: '10' }], [form.Value, 'quic_max_idle_timeout', _('QUIC max idle timeout'), _('Configuration key: transport.quic.maxIdleTimeout.'), { datatype: 'uinteger', placeholder: '30' }], [form.Value, 'quic_max_incoming_streams', _('QUIC max incoming streams'), _('Configuration key: transport.quic.maxIncomingStreams.'), { datatype: 'uinteger', placeholder: '100000' }] ]; const commonAccessConf = [ [form.DynamicList, 'allow_ports', _('Allow ports'), _('Ports clients are allowed to bind. Use single port like 3001 or range like 2000-3000.'), { placeholder: '2000-3000', validate: function (section_id, value) { return validatePortRange(value); } }], [form.Value, 'max_ports_per_client', _('Max ports per client'), _('Maximum ports each client can use. 0 means no limit.'), { datatype: 'uinteger', placeholder: '0' }], [form.Value, 'nathole_analysis_data_reserve_hours', _('NAT hole analysis reserve hours'), _('Retention time for NAT hole punching strategy data.'), { datatype: 'uinteger', placeholder: '168' }] ]; const commonSshTunnelConf = [ [form.Value, 'ssh_tunnel_bind_port', _('SSH tunnel bind port'), _('SSH tunnel gateway bind port. Empty disables this feature.'), { validate: validatePortOrZero, placeholder: '2200' }], [form.Value, 'ssh_tunnel_private_key_file', _('SSH tunnel private key file'), _('Private key file for SSH tunnel gateway.'), { datatype: 'file' }], [form.Value, 'ssh_tunnel_auto_gen_private_key_path', _('SSH tunnel auto-generated private key path'), _('Path for auto-generated private key.'), { datatype: 'file' }], [form.Value, 'ssh_tunnel_authorized_keys_file', _('SSH tunnel authorized keys file'), _('Authorized keys file for SSH tunnel gateway.'), { datatype: 'file' }] ]; const httpPluginConf = [ [form.Value, 'name', _('Plugin name'), _('Unique HTTP plugin name.'), { rmempty: false, optional: false }], [form.Value, 'addr', _('Plugin address'), _('HTTP plugin backend address.'), { placeholder: '127.0.0.1:9000', rmempty: false, optional: false, validate: function (section_id, value) { return validateHttpPluginAddr(value); } }], [form.Value, 'path', _('Plugin path'), _('HTTP plugin request path.'), { placeholder: '/handler', rmempty: false, optional: false, validate: function (section_id, value) { return validateHttpPath(value); } }], [form.MultiValue, 'ops', _('Plugin operations'), _('frps operations handled by this HTTP plugin.'), { values: ['Login', 'NewProxy', 'CloseProxy', 'Ping', 'NewWorkConn', 'NewUserConn'], default: ['Login'], optional: false, rmempty: false, validate: function (section_id, value) { return validateHttpPluginOp(value); } }], [form.Flag, 'tls_verify', _('Verify plugin TLS'), _('Verify the HTTP plugin TLS certificate when the plugin address uses HTTPS.'), { datatype: 'bool', default: 'false' }] ]; function writeFlagDisabled(section_id) { return this.write(section_id, this.disabled || 'false'); } function removeIfPresent(section_id) { const this_cfg = this.uciconfig || this.section.uciconfig || this.map.config; const this_sid = this.ucisection || section_id; const this_opt = this.ucioption || this.option; for (let i = 0; i < this.section.children.length; i++) { const sibling = this.section.children[i]; if (sibling === this || sibling.ucioption == null) continue; const sibling_cfg = sibling.uciconfig || sibling.section.uciconfig || sibling.map.config; const sibling_sid = sibling.ucisection || section_id; const sibling_opt = sibling.ucioption || sibling.option; if (this_cfg != sibling_cfg || this_sid != sibling_sid || this_opt != sibling_opt) continue; if (typeof sibling.isActive === 'function' && sibling.isActive(section_id)) return Promise.resolve(); } if (this.map.data.get(this_cfg, this_sid, this_opt) == null) return Promise.resolve(); return this.map.data.unset(this_cfg, this_sid, this_opt); } function isUciDeleteNotFoundError(err) { const message = err && err.message ? err.message : String(err); return /uci\/delete/.test(message) && /ubus code 4/.test(message); } function guardUciDeleteNotFound(data, config) { data._frpIgnoreMissingDeleteConfigs ??= {}; data._frpIgnoreMissingDeleteConfigs[config] = true; if (data._frpIgnoreMissingDeleteInstalled) return; const callDelete = data.callDelete; data.callDelete = function(conf, sid, options) { const guarded = this._frpIgnoreMissingDeleteConfigs && this._frpIgnoreMissingDeleteConfigs[conf]; return callDelete.apply(this, arguments).catch(L.bind(function(err) { if (!guarded || !isUciDeleteNotFoundError(err)) return Promise.reject(err); if (!Array.isArray(options) || options.length <= 1) return null; return Promise.all(options.map(L.bind(function(opt) { return callDelete.call(this, conf, sid, [ opt ]).catch(function(e) { return isUciDeleteNotFoundError(e) ? null : Promise.reject(e); }); }, this))); }, this)); }; data._frpIgnoreMissingDeleteInstalled = true; } function adminWebEnabled(section, section_id) { const port = section.getOption('admin_port'); const value = port ? port.formvalue(section_id) : null; return !!(value && value !== '0'); } function adminHttpsEnabled(section, section_id) { const enable = section.getOption('admin_tls_enable'); return !!(enable && enable.formvalue(section_id) === 'true'); } function validatePortOrZero(section_id, value) { if (!value) return true; value = String(value).trim(); if (!/^\d+$/.test(value)) return _('Port must be a number between 0 and 65535.'); const port = Number(value); if (port < 0 || port > 65535) return _('Port must be between 0 and 65535.'); return true; } function validatePortRange(value) { if (!value) return true; value = String(value).trim(); const m = value.match(/^(\d+)(?:-(\d+))?$/); if (!m) return _('Use a single port like 3001 or a range like 2000-3000.'); const start = Number(m[1]); const end = Number(m[2] || m[1]); if (start < 1 || start > 65535 || end < 1 || end > 65535) return _('Port must be between 1 and 65535.'); if (start > end) return _('Port range start must not be greater than end.'); return true; } function validateEnv(value) { if (!value) return true; if (/\r|\n/.test(String(value)) || !/^[A-Za-z_][A-Za-z0-9_]*=.*$/.test(String(value))) return _('Environment variable must use KEY=value format.'); return true; } function validateHttpPath(value) { if (!value) return true; if (/\r|\n/.test(String(value)) || String(value).charAt(0) !== '/') return _('HTTP plugin path must start with /.'); return true; } function validateHttpPluginAddr(value) { if (!value) return true; value = String(value).trim(); if (/\r|\n/.test(value)) return _('HTTP plugin address must be a single address.'); if (/^https?:\/\//.test(value)) { if (!/^https?:\/\/[^\s/:]+(?::\d+)?(?:\/.*)?$/.test(value) && !/^https?:\/\/\[[0-9A-Fa-f:.]+\](?::\d+)?(?:\/.*)?$/.test(value)) return _('HTTP plugin URL must include a host.'); return true; } if (/^[^\s/:]+:\d+$/.test(value) || /^\[[0-9A-Fa-f:.]+\]:\d+$/.test(value)) return true; return _('HTTP plugin address must be host:port or an http:// or https:// URL.'); } function validateHttpPluginOp(value) { if (!value) return true; if (!/^[A-Za-z][A-Za-z0-9]*$/.test(String(value))) return _('HTTP plugin operation must be a frps operation name, for example Login or NewProxy.'); return true; } function normalizeDepends(depends) { if (depends == null) return []; return Array.isArray(depends) ? depends : [ depends ]; } function mergeDepends(existing, next) { const current = normalizeDepends(existing); const incoming = normalizeDepends(next); if (current.length === 0) return incoming; if (incoming.length === 0) return current; const merged = []; for (let oldDep of current) { for (let newDep of incoming) { const dep = {}; let conflict = false; for (let key in oldDep) dep[key] = oldDep[key]; for (let key in newDep) { if (Object.prototype.hasOwnProperty.call(dep, key) && dep[key] !== newDep[key]) { conflict = true; break; } dep[key] = newDep[key]; } if (!conflict) merged.push(dep); } } return merged; } function setParams(o, params) { if (!params) return; for (let key in params) { let val = params[key]; if (key === 'values') { for (let v of val) { let args = v; if (!Array.isArray(args)) args = [args]; o.value.apply(o, args); } } else if (key === 'depends') { o.deps = mergeDepends(o.deps, val); } else { o[key] = params[key]; } } if (params['datatype'] === 'bool') { o.enabled = 'true'; o.disabled = 'false'; } } function defTabOpts(s, t, opts, params) { for (let opt of opts) { const o = s.taboption(t, opt[0], opt[1], opt[2], opt[3]); setParams(o, opt[4]); setParams(o, params); /* * Per-option optional must win over tab-wide optional. * This is important for form.Flag with default='true', * otherwise LuCI may treat checked state as default and call remove(). */ if (opt[4] && Object.prototype.hasOwnProperty.call(opt[4], 'optional')) o.optional = opt[4].optional; if ( !(opt[4] && Object.prototype.hasOwnProperty.call(opt[4], 'remove')) && !(params && Object.prototype.hasOwnProperty.call(params, 'remove')) ) o.remove = removeIfPresent; } } function defOpts(s, opts, params) { for (let opt of opts) { const o = s.option(opt[0], opt[1], opt[2], opt[3]); setParams(o, opt[4]); setParams(o, params); if (opt[4] && Object.prototype.hasOwnProperty.call(opt[4], 'optional')) o.optional = opt[4].optional; if ( !(opt[4] && Object.prototype.hasOwnProperty.call(opt[4], 'remove')) && !(params && Object.prototype.hasOwnProperty.call(params, 'remove')) ) o.remove = removeIfPresent; } } const callServiceList = rpc.declare({ object: 'service', method: 'list', params: ['name'], expect: { '': {} } }); function getServiceStatus() { return L.resolveDefault(callServiceList('frps'), {}).then(function (res) { const instances = res.frps && res.frps.instances; if (!instances) return false; for (let name in instances) { if (instances[name] && instances[name].running) return true; } return false; }); } function renderStatus(isRunning) { const color = isRunning ? 'green' : 'red'; const status = isRunning ? _('RUNNING') : _('NOT RUNNING'); return String.format('%s %s', color, _('frp Server'), status); } return view.extend({ render() { let m, s, o; m = new form.Map('frps', _('frp Server')); guardUciDeleteNotFound(m.data, 'frps'); s = m.section(form.NamedSection, '_status'); s.anonymous = true; s.render = function (section_id) { L.Poll.add(function () { return L.resolveDefault(getServiceStatus()).then(function (res) { const view = document.getElementById('service_status'); if (view) view.innerHTML = renderStatus(res); }); }); return E('div', {}, E('fieldset', { class: 'cbi-section' }, [ E('p', { id: 'service_status' }, _('Collecting data ...')) ]) ); }; s = m.section(form.NamedSection, 'common', 'conf'); s.dynamic = true; s.tab('common', _('Common Settings')); s.tab('auth', _('Authentication')); s.tab('transport', _('Transport Settings')); s.tab('tls_quic', _('TLS / QUIC')); s.tab('web', _('Web Server')); s.tab('access', _('Access Control')); s.tab('ssh_tunnel', _('SSH Tunnel')); s.tab('log', _('Log Settings')); s.tab('init', _('Startup Settings')); defTabOpts(s, 'common', commonBaseConf, { optional: true }); defTabOpts(s, 'auth', commonAuthConf, { optional: true }); defTabOpts(s, 'transport', commonTransportConf, { optional: true }); defTabOpts(s, 'tls_quic', commonTlsQuicConf, { optional: true }); defTabOpts(s, 'web', commonWebConf, { optional: true }); defTabOpts(s, 'access', commonAccessConf, { optional: true }); defTabOpts(s, 'ssh_tunnel', commonSshTunnelConf, { optional: true }); defTabOpts(s, 'log', commonLogConf, { optional: true }); o = s.taboption('init', form.SectionValue, 'init', form.TypedSection, 'init', _('Startup Settings')); s = o.subsection; s.anonymous = true; s.dynamic = true; defOpts(s, startupConf); s = m.section(form.GridSection, 'http_plugin', _('HTTP Plugin Settings')); s.anonymous = true; s.addremove = true; s.sortable = true; s.nodescriptions = true; s.addbtntitle = _('Add new HTTP plugin...'); defOpts(s, httpPluginConf, { optional: true }); return m.render(); } });