feat: add support for split dns
This commit is contained in:
parent
2aae6bfc06
commit
06d7d1ccad
43
app/components/Tooltip.tsx
Normal file
43
app/components/Tooltip.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Button as AriaButton,
|
||||||
|
Tooltip as AriaTooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
{children}
|
||||||
|
</TooltipTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||||
|
return (
|
||||||
|
<AriaButton {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Body({ children, className }: Props) {
|
||||||
|
return (
|
||||||
|
<AriaTooltip className={cn(
|
||||||
|
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||||
|
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||||
|
'border border-gray-200 dark:border-zinc-700',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AriaTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Object.assign(Tooltip, { Button, Body })
|
||||||
162
app/routes/_data.dns._index/dialogs/nameserver.tsx
Normal file
162
app/routes/_data.dns._index/dialogs/nameserver.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
import Switch from '~/components/Switch'
|
||||||
|
import TextField from '~/components/TextField'
|
||||||
|
import Tooltip from '~/components/Tooltip'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nameservers: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddNameserver({ nameservers }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const [split, setSplit] = useState(false)
|
||||||
|
const [ns, setNs] = useState('')
|
||||||
|
const [domain, setDomain] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Button>
|
||||||
|
Add nameserver
|
||||||
|
</Dialog.Button>
|
||||||
|
<Dialog.Panel>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Add nameserver
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="font-semibold">
|
||||||
|
Nameserver
|
||||||
|
</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Use this IPv4 or IPv6 address to resolve names.
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!ns) return
|
||||||
|
|
||||||
|
if (split) {
|
||||||
|
const splitNs: Record<string, string[]> = {}
|
||||||
|
for (const [key, value] of Object.entries(nameservers)) {
|
||||||
|
if (key === 'global') continue
|
||||||
|
splitNs[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(splitNs).includes(domain)) {
|
||||||
|
splitNs[domain].push(ns)
|
||||||
|
} else {
|
||||||
|
splitNs[domain] = [ns]
|
||||||
|
}
|
||||||
|
|
||||||
|
submit({
|
||||||
|
'dns_config.restricted_nameservers': splitNs,
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const globalNs = nameservers.global
|
||||||
|
globalNs.push(ns)
|
||||||
|
|
||||||
|
submit({
|
||||||
|
'dns_config.nameservers': globalNs,
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setNs('')
|
||||||
|
setDomain('')
|
||||||
|
setSplit(false)
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="DNS Server"
|
||||||
|
placeholder="1.2.3.4"
|
||||||
|
name="ns"
|
||||||
|
state={[ns, setNs]}
|
||||||
|
className="mt-2 mb-8"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="block">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Dialog.Text className="font-semibold">
|
||||||
|
Restrict to domain
|
||||||
|
</Dialog.Text>
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button className={cn(
|
||||||
|
'text-xs rounded-md px-1.5 py-0.5',
|
||||||
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
|
'text-ui-600 dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||||
|
Split DNS
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Only clients that support split DNS
|
||||||
|
(Tailscale v1.8 or later for most platforms)
|
||||||
|
will use this nameserver. Older clients
|
||||||
|
will ignore it.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
This nameserver will only be used for some domains.
|
||||||
|
</Dialog.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label="Split DNS"
|
||||||
|
defaultSelected={split}
|
||||||
|
onChange={() => { setSplit(!split) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{split
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<Dialog.Text className="font-semibold mt-8">
|
||||||
|
Domain
|
||||||
|
</Dialog.Text>
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
placeholder="example.com"
|
||||||
|
name="domain"
|
||||||
|
state={[domain, setDomain]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Only single-label or fully-qualified queries
|
||||||
|
matching this suffix should use the nameserver.
|
||||||
|
</Dialog.Text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
app/routes/_data.dns._index/nameservers.tsx
Normal file
139
app/routes/_data.dns._index/nameservers.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { useSubmit } from '@remix-run/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from 'react-aria-components'
|
||||||
|
|
||||||
|
import Link from '~/components/Link'
|
||||||
|
import Switch from '~/components/Switch'
|
||||||
|
import TableList from '~/components/TableList'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
import AddNameserver from './dialogs/nameserver'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nameservers: Record<string, string[]>
|
||||||
|
override: boolean
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-2/3">
|
||||||
|
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
Set the nameservers used by devices on the Tailnet
|
||||||
|
to resolve DNS queries.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1054/dns"
|
||||||
|
name="Tailscale DNS Documentation"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{Object.keys(nameservers).map(key => (
|
||||||
|
<NameserverList
|
||||||
|
key={key}
|
||||||
|
isGlobal={key === 'global'}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
nameservers={nameservers[key]}
|
||||||
|
override={override}
|
||||||
|
name={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isDisabled
|
||||||
|
? undefined
|
||||||
|
: (
|
||||||
|
<AddNameserver nameservers={nameservers} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
isGlobal: boolean
|
||||||
|
isDisabled: boolean
|
||||||
|
nameservers: string[]
|
||||||
|
name: string
|
||||||
|
override: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
|
||||||
|
const [localOverride, setLocalOverride] = useState(override)
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-md font-medium opacity-80">
|
||||||
|
{isGlobal ? 'Global Nameservers' : name}
|
||||||
|
</h2>
|
||||||
|
{isGlobal
|
||||||
|
? (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<span className="text-sm opacity-50">
|
||||||
|
Override local DNS
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
label="Override local DNS"
|
||||||
|
defaultSelected={localOverride}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onChange={() => {
|
||||||
|
submit({
|
||||||
|
'dns_config.override_local_dns': !localOverride,
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
setLocalOverride(!localOverride)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</div>
|
||||||
|
<TableList>
|
||||||
|
{nameservers.map((ns, index) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<TableList.Item key={index}>
|
||||||
|
<p className="font-mono text-sm">{ns}</p>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
'text-red-600 dark:text-red-400',
|
||||||
|
'hover:text-red-700 dark:hover:text-red-300',
|
||||||
|
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onPress={() => {
|
||||||
|
if (isGlobal) {
|
||||||
|
submit({
|
||||||
|
'dns_config.nameservers': nameservers
|
||||||
|
.filter((_, i) => i !== index),
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const key = `dns_config.restricted_nameservers."${name}"`
|
||||||
|
submit({
|
||||||
|
[key]: nameservers
|
||||||
|
.filter((_, i) => i !== index),
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</TableList.Item>
|
||||||
|
))}
|
||||||
|
</TableList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,14 +1,8 @@
|
|||||||
import { type ActionFunctionArgs } from '@remix-run/node'
|
import { type ActionFunctionArgs } from '@remix-run/node'
|
||||||
import { json, useFetcher, useLoaderData } from '@remix-run/react'
|
import { json, useLoaderData } from '@remix-run/react'
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button, Input } from 'react-aria-components'
|
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import Notice from '~/components/Notice'
|
import Notice from '~/components/Notice'
|
||||||
import Spinner from '~/components/Spinner'
|
|
||||||
import Switch from '~/components/Switch'
|
|
||||||
import TableList from '~/components/TableList'
|
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||||
import { restartHeadscale } from '~/utils/docker'
|
import { restartHeadscale } from '~/utils/docker'
|
||||||
@ -17,6 +11,7 @@ import { useLiveData } from '~/utils/useLiveData'
|
|||||||
|
|
||||||
import Domains from './domains'
|
import Domains from './domains'
|
||||||
import MagicModal from './magic'
|
import MagicModal from './magic'
|
||||||
|
import Nameservers from './nameservers'
|
||||||
import RenameModal from './rename'
|
import RenameModal from './rename'
|
||||||
|
|
||||||
// We do not want to expose every config value
|
// We do not want to expose every config value
|
||||||
@ -68,9 +63,13 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
export default function Page() {
|
export default function Page() {
|
||||||
useLiveData({ interval: 5000 })
|
useLiveData({ interval: 5000 })
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
const fetcher = useFetcher()
|
|
||||||
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
|
const allNs: Record<string, string[]> = {}
|
||||||
const [ns, setNs] = useState('')
|
for (const key of Object.keys(data.splitDns)) {
|
||||||
|
allNs[key] = data.splitDns[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
allNs.global = data.nameservers
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||||
@ -82,113 +81,11 @@ export default function Page() {
|
|||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||||
<div className="flex flex-col w-2/3">
|
<Nameservers
|
||||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
nameservers={allNs}
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
override={data.overrideLocal}
|
||||||
Set the nameservers used by devices on the Tailnet
|
isDisabled={!data.config.write}
|
||||||
to resolve DNS queries.
|
/>
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h2 className="text-md font-medium opacity-80">
|
|
||||||
Global Nameservers
|
|
||||||
</h2>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<span className="text-sm opacity-50">
|
|
||||||
Override local DNS
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
label="Override local DNS"
|
|
||||||
defaultSelected={localOverride}
|
|
||||||
isDisabled={!data.config.write}
|
|
||||||
onChange={() => {
|
|
||||||
fetcher.submit({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns_config.override_local_dns': !localOverride,
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
setLocalOverride(!localOverride)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TableList>
|
|
||||||
{data.nameservers.map((ns, index) => (
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<TableList.Item key={index}>
|
|
||||||
<p className="font-mono text-sm">{ns}</p>
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'text-sm',
|
|
||||||
'text-red-600 dark:text-red-400',
|
|
||||||
'hover:text-red-700 dark:hover:text-red-300',
|
|
||||||
!data.config.write && 'opacity-50 cursor-not-allowed',
|
|
||||||
)}
|
|
||||||
isDisabled={!data.config.write}
|
|
||||||
onPress={() => {
|
|
||||||
fetcher.submit({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index),
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</TableList.Item>
|
|
||||||
))}
|
|
||||||
{data.config.write
|
|
||||||
? (
|
|
||||||
<TableList.Item>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
className="font-mono text-sm bg-transparent w-full mr-2"
|
|
||||||
placeholder="Nameserver"
|
|
||||||
value={ns}
|
|
||||||
onChange={(event) => {
|
|
||||||
setNs(event.target.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{fetcher.state === 'idle'
|
|
||||||
? (
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'text-sm font-semibold',
|
|
||||||
'text-blue-600 dark:text-blue-400',
|
|
||||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
|
||||||
ns.length === 0 && 'opacity-50 cursor-not-allowed',
|
|
||||||
)}
|
|
||||||
isDisabled={ns.length === 0}
|
|
||||||
onPress={() => {
|
|
||||||
fetcher.submit({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns_config.nameservers': [...data.nameservers, ns],
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
setNs('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<Spinner className="w-3 h-3 mr-0" />
|
|
||||||
)}
|
|
||||||
</TableList.Item>
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
</TableList>
|
|
||||||
{/* TODO: Split DNS and Custom A Records */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Domains
|
<Domains
|
||||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||||
|
|||||||
@ -178,7 +178,35 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(partial)) {
|
for (const [key, value] of Object.entries(partial)) {
|
||||||
configYaml.setIn(key.split('.'), value)
|
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||||
|
// the foo.bar as a single key, and not as two keys, so that needs
|
||||||
|
// to be split correctly.
|
||||||
|
|
||||||
|
// Iterate through each character, and if we find a dot, we check if
|
||||||
|
// the next character is a quote, and if it is, we skip until the next
|
||||||
|
// quote, and then we skip the next character, which should be a dot.
|
||||||
|
// If it's not a quote, we split it.
|
||||||
|
const path = []
|
||||||
|
let temp = ''
|
||||||
|
let inQuote = false
|
||||||
|
|
||||||
|
for (const element of key) {
|
||||||
|
if (element === '"') {
|
||||||
|
inQuote = !inQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element === '.' && !inQuote) {
|
||||||
|
path.push(temp.replaceAll('"', ''))
|
||||||
|
temp = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
temp += element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the remaining element
|
||||||
|
path.push(temp.replaceAll('"', ''))
|
||||||
|
configYaml.setIn(path, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user