feat: Jan API Server should have API Key setting (#5193)
* feat: Jan API Server should have API Key setting * chore: reveal api key icon * Update src-tauri/src/core/server.rs Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * chore: add validation apiKey --------- Co-authored-by: Faisal Amir <urmauur@gmail.com> Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
parent
da10502bdd
commit
cb3ac4b136
@ -1,8 +1,6 @@
|
|||||||
use reqwest::blocking::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{fs, io, path::PathBuf};
|
use std::{fs, io, path::PathBuf};
|
||||||
use tauri::{AppHandle, Manager, Runtime, State};
|
use tauri::{AppHandle, Manager, Runtime, State};
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
|
||||||
|
|
||||||
use super::{server, setup, state::AppState};
|
use super::{server, setup, state::AppState};
|
||||||
|
|
||||||
@ -347,10 +345,15 @@ pub async fn start_server(
|
|||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
prefix: String,
|
prefix: String,
|
||||||
|
api_key: String,
|
||||||
trusted_hosts: Vec<String>,
|
trusted_hosts: Vec<String>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let auth_token = app.state::<AppState>().app_token.clone().unwrap_or_default();
|
let auth_token = app
|
||||||
server::start_server(host, port, prefix, auth_token, trusted_hosts)
|
.state::<AppState>()
|
||||||
|
.app_token
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default();
|
||||||
|
server::start_server(host, port, prefix, auth_token, api_key, trusted_hosts)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct ProxyConfig {
|
|||||||
prefix: String,
|
prefix: String,
|
||||||
auth_token: String,
|
auth_token: String,
|
||||||
trusted_hosts: Vec<String>,
|
trusted_hosts: Vec<String>,
|
||||||
|
api_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a prefix from a path, ensuring proper formatting
|
/// Removes a prefix from a path, ensuring proper formatting
|
||||||
@ -86,6 +87,25 @@ async fn proxy_request(
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !config.api_key.is_empty() {
|
||||||
|
if let Some(authorization) = req.headers().get(hyper::header::AUTHORIZATION) {
|
||||||
|
let auth_str = authorization.to_str().unwrap_or("");
|
||||||
|
|
||||||
|
if auth_str.strip_prefix("Bearer ") != Some(config.api_key.as_str())
|
||||||
|
{
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::UNAUTHORIZED)
|
||||||
|
.body(Body::from("Invalid or missing authorization token"))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::UNAUTHORIZED)
|
||||||
|
.body(Body::from("Missing authorization header"))
|
||||||
|
.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Block access to /configs endpoint
|
// Block access to /configs endpoint
|
||||||
if path.contains("/configs") {
|
if path.contains("/configs") {
|
||||||
return Ok(Response::builder()
|
return Ok(Response::builder()
|
||||||
@ -102,8 +122,8 @@ async fn proxy_request(
|
|||||||
|
|
||||||
// Copy original headers
|
// Copy original headers
|
||||||
for (name, value) in req.headers() {
|
for (name, value) in req.headers() {
|
||||||
if name != hyper::header::HOST {
|
// Skip host & authorization header
|
||||||
// Skip host header
|
if name != hyper::header::HOST && name != hyper::header::AUTHORIZATION {
|
||||||
outbound_req = outbound_req.header(name, value);
|
outbound_req = outbound_req.header(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,14 +172,26 @@ fn is_valid_host(host: &str, trusted_hosts: &[String]) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let host_without_port = if host.starts_with('[') { host.split(']').next().unwrap_or(host).trim_start_matches('[') } else { host.split(':').next().unwrap_or(host) };
|
let host_without_port = if host.starts_with('[') {
|
||||||
|
host.split(']')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(host)
|
||||||
|
.trim_start_matches('[')
|
||||||
|
} else {
|
||||||
|
host.split(':').next().unwrap_or(host)
|
||||||
|
};
|
||||||
let default_valid_hosts = ["localhost", "127.0.0.1"];
|
let default_valid_hosts = ["localhost", "127.0.0.1"];
|
||||||
|
|
||||||
if default_valid_hosts.iter().any(|&valid| host_without_port.to_lowercase() == valid.to_lowercase()) {
|
if default_valid_hosts
|
||||||
|
.iter()
|
||||||
|
.any(|&valid| host_without_port.to_lowercase() == valid.to_lowercase())
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
trusted_hosts.iter().any(|valid| host_without_port.to_lowercase() == valid.to_lowercase())
|
trusted_hosts
|
||||||
|
.iter()
|
||||||
|
.any(|valid| host_without_port.to_lowercase() == valid.to_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the proxy server
|
/// Starts the proxy server
|
||||||
@ -168,6 +200,7 @@ pub async fn start_server(
|
|||||||
port: u16,
|
port: u16,
|
||||||
prefix: String,
|
prefix: String,
|
||||||
auth_token: String,
|
auth_token: String,
|
||||||
|
api_key: String,
|
||||||
trusted_hosts: Vec<String>,
|
trusted_hosts: Vec<String>,
|
||||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// Check if server is already running
|
// Check if server is already running
|
||||||
@ -186,6 +219,7 @@ pub async fn start_server(
|
|||||||
upstream: "http://127.0.0.1:39291".to_string(),
|
upstream: "http://127.0.0.1:39291".to_string(),
|
||||||
prefix,
|
prefix,
|
||||||
auth_token,
|
auth_token,
|
||||||
|
api_key,
|
||||||
trusted_hosts,
|
trusted_hosts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
88
web-app/src/containers/ApiKeyInput.tsx
Normal file
88
web-app/src/containers/ApiKeyInput.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ApiKeyInputProps {
|
||||||
|
showError?: boolean
|
||||||
|
onValidationChange?: (isValid: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeyInput({
|
||||||
|
showError = false,
|
||||||
|
onValidationChange,
|
||||||
|
}: ApiKeyInputProps) {
|
||||||
|
const { apiKey, setApiKey } = useLocalApiServer()
|
||||||
|
const [inputValue, setInputValue] = useState(apiKey.toString())
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const validateApiKey = (value: string) => {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
setError('API Key is required')
|
||||||
|
onValidationChange?.(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
onValidationChange?.(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showError) {
|
||||||
|
validateApiKey(inputValue)
|
||||||
|
}
|
||||||
|
}, [showError, inputValue])
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setInputValue(value)
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (error && value.trim().length > 0) {
|
||||||
|
setError('')
|
||||||
|
onValidationChange?.(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setApiKey(inputValue)
|
||||||
|
// Validate on blur if showError is true
|
||||||
|
if (showError) {
|
||||||
|
validateApiKey(inputValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasError = error && showError
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={`w-full text-sm pr-10 ${
|
||||||
|
hasError
|
||||||
|
? 'border-1 border-destructive focus:border-destructive focus:ring-destructive'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
placeholder="Enter API Key"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="p-1 rounded hover:bg-main-view-fg/5 text-main-view-fg/70"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hasError && (
|
||||||
|
<p className="text-destructive text-xs mt-1 absolute -bottom-5 left-0">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -21,6 +21,8 @@ type LocalApiServerState = {
|
|||||||
// Verbose server logs
|
// Verbose server logs
|
||||||
verboseLogs: boolean
|
verboseLogs: boolean
|
||||||
setVerboseLogs: (value: boolean) => void
|
setVerboseLogs: (value: boolean) => void
|
||||||
|
apiKey: string
|
||||||
|
setApiKey: (value: string) => void
|
||||||
// Trusted hosts
|
// Trusted hosts
|
||||||
trustedHosts: string[]
|
trustedHosts: string[]
|
||||||
addTrustedHost: (host: string) => void
|
addTrustedHost: (host: string) => void
|
||||||
@ -53,6 +55,8 @@ export const useLocalApiServer = create<LocalApiServerState>()(
|
|||||||
trustedHosts: state.trustedHosts.filter((h) => h !== host),
|
trustedHosts: state.trustedHosts.filter((h) => h !== host),
|
||||||
})),
|
})),
|
||||||
setTrustedHosts: (hosts) => set({ trustedHosts: hosts }),
|
setTrustedHosts: (hosts) => set({ trustedHosts: hosts }),
|
||||||
|
apiKey: '',
|
||||||
|
setApiKey: (value) => set({ apiKey: value }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: localStorageKey.settingLocalApiServer,
|
name: localStorageKey.settingLocalApiServer,
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import { useAppState } from '@/hooks/useAppState'
|
|||||||
import { windowKey } from '@/constants/windows'
|
import { windowKey } from '@/constants/windows'
|
||||||
import { IconLogs } from '@tabler/icons-react'
|
import { IconLogs } from '@tabler/icons-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ApiKeyInput } from '@/containers/ApiKeyInput'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const Route = createFileRoute(route.settings.local_api_server as any)({
|
export const Route = createFileRoute(route.settings.local_api_server as any)({
|
||||||
@ -32,12 +34,30 @@ function LocalAPIServer() {
|
|||||||
serverHost,
|
serverHost,
|
||||||
serverPort,
|
serverPort,
|
||||||
apiPrefix,
|
apiPrefix,
|
||||||
|
apiKey,
|
||||||
trustedHosts,
|
trustedHosts,
|
||||||
} = useLocalApiServer()
|
} = useLocalApiServer()
|
||||||
|
|
||||||
const { serverStatus, setServerStatus } = useAppState()
|
const { serverStatus, setServerStatus } = useAppState()
|
||||||
|
const [showApiKeyError, setShowApiKeyError] = useState(false)
|
||||||
|
const [isApiKeyEmpty, setIsApiKeyEmpty] = useState(
|
||||||
|
!apiKey || apiKey.toString().trim().length === 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleApiKeyValidation = (isValid: boolean) => {
|
||||||
|
setIsApiKeyEmpty(!isValid)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleAPIServer = async () => {
|
const toggleAPIServer = async () => {
|
||||||
|
// Validate API key before starting server
|
||||||
|
if (serverStatus === 'stopped') {
|
||||||
|
if (!apiKey || apiKey.toString().trim().length === 0) {
|
||||||
|
setShowApiKeyError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowApiKeyError(false)
|
||||||
|
}
|
||||||
|
|
||||||
setServerStatus('pending')
|
setServerStatus('pending')
|
||||||
if (serverStatus === 'stopped') {
|
if (serverStatus === 'stopped') {
|
||||||
window.core?.api
|
window.core?.api
|
||||||
@ -45,6 +65,7 @@ function LocalAPIServer() {
|
|||||||
host: serverHost,
|
host: serverHost,
|
||||||
port: serverPort,
|
port: serverPort,
|
||||||
prefix: apiPrefix,
|
prefix: apiPrefix,
|
||||||
|
apiKey,
|
||||||
trustedHosts,
|
trustedHosts,
|
||||||
isCorsEnabled: corsEnabled,
|
isCorsEnabled: corsEnabled,
|
||||||
isVerboseEnabled: verboseLogs,
|
isVerboseEnabled: verboseLogs,
|
||||||
@ -52,6 +73,10 @@ function LocalAPIServer() {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
setServerStatus('running')
|
setServerStatus('running')
|
||||||
})
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error('Error starting server:', error)
|
||||||
|
setServerStatus('stopped')
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
window.core?.api
|
window.core?.api
|
||||||
?.stopServer()
|
?.stopServer()
|
||||||
@ -182,6 +207,20 @@ function LocalAPIServer() {
|
|||||||
)}
|
)}
|
||||||
actions={<ApiPrefixInput />}
|
actions={<ApiPrefixInput />}
|
||||||
/>
|
/>
|
||||||
|
<CardItem
|
||||||
|
title="API Key"
|
||||||
|
description="Authenticate requests with an API key"
|
||||||
|
className={cn(
|
||||||
|
isServerRunning && 'opacity-50 pointer-events-none',
|
||||||
|
isApiKeyEmpty && showApiKeyError && 'pb-6'
|
||||||
|
)}
|
||||||
|
actions={
|
||||||
|
<ApiKeyInput
|
||||||
|
showError={showApiKeyError}
|
||||||
|
onValidationChange={handleApiKeyValidation}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<CardItem
|
<CardItem
|
||||||
title="Trusted Hosts"
|
title="Trusted Hosts"
|
||||||
description="Add trusted hosts that can access the API server"
|
description="Add trusted hosts that can access the API server"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user