Initial commit: QuiC Call server
This commit is contained in:
commit
4f88a7eab1
34 changed files with 4044 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Binaries
|
||||||
|
./qcc
|
||||||
|
./qccd
|
||||||
|
|
||||||
|
# Config
|
||||||
|
config.yaml
|
||||||
|
plan.txt
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
acme/
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temp
|
||||||
|
/tmp
|
||||||
|
*.log
|
||||||
659
LICENSE
Normal file
659
LICENSE
Normal file
|
|
@ -0,0 +1,659 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, may become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may be
|
||||||
|
otherwise available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the option of
|
||||||
|
following the terms and conditions either of that numbered version or of any
|
||||||
|
later version published by the Free Software Foundation. If the Program
|
||||||
|
does not specify a version number of the GNU Affero General Public License,
|
||||||
|
you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide whether future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions.
|
||||||
|
However, no additional obligations are imposed on any author or copyright
|
||||||
|
holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
69
README.md
Normal file
69
README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# QuiC Call
|
||||||
|
|
||||||
|
A server for audio/video calls between people. A family phone system replacement.
|
||||||
|
|
||||||
|
No WebRTC. No STUN/TURN. Everything goes through the server.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. Client connects via QUIC (TLS + ACME Let's Encrypt)
|
||||||
|
2. Server sends a PoW (Hashcash) challenge
|
||||||
|
3. Client solves it, sends solution with their Ed25519 public key
|
||||||
|
4. Server allocates a phone number (`+0 XXX YYY ZZZ`), issues an X.509 certificate proving ownership
|
||||||
|
5. Client can re-roll the number once per 24h
|
||||||
|
6. To call someone, dial their number — server relays media blindly (E2EE)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Transport**: QUIC (hysteria fork of quic-go) over UDP
|
||||||
|
- **TLS**: Automatic via certmagic + Cloudflare DNS-01 (ACME)
|
||||||
|
- **Auth**: Proof-of-Work (Hashcash SHA-256), no passwords
|
||||||
|
- **PKI**: Ed25519 CA, X.509 client certs with phone number as CN
|
||||||
|
- **DB**: Custom append-only frame-based engine with AES-256-GCM at-rest encryption (hash-chain integrity)
|
||||||
|
- **Signaling**: Binary varint frames over QUIC streams
|
||||||
|
- **Media relay**: Unreliable QUIC datagrams, server forwards blindly
|
||||||
|
- **Congestion control**: Brutal (CBR) from Hysteria
|
||||||
|
- **E2EE**: X25519 + HKDF + ChaCha20-Poly1305 (key exchange during signaling)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Pre-alpha. Server compiles and passes vet. No client application exists yet.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o qccd ./cmd/qccd
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
cp config.yaml.example config.yaml
|
||||||
|
# edit config.yaml — set ACME domains, email, Cloudflare API token
|
||||||
|
|
||||||
|
# Run
|
||||||
|
./qccd server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See `config.yaml.example`.
|
||||||
|
|
||||||
|
Key options:
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `server.listen` | `:1963` | UDP listen address |
|
||||||
|
| `pow.difficulty` | `22` | PoW leading zero bits |
|
||||||
|
| `identity.cooldown` | `24h` | Minimum time between number re-rolls |
|
||||||
|
| `identity.prefix` | `+0` | Phone number prefix |
|
||||||
|
| `ca.key_type` | `ed25519` | CA key type |
|
||||||
|
| `relay.bandwidth_up` | `100 mbps` | Media relay upstream bandwidth |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `github.com/apernet/quic-go` — QUIC transport (Hysteria fork)
|
||||||
|
- `github.com/caddyserver/certmagic` + `github.com/libdns/cloudflare` — ACME TLS
|
||||||
|
- `golang.org/x/crypto` — X25519, ChaCha20-Poly1305, HKDF
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0-only
|
||||||
54
cmd/qccd/config.go
Normal file
54
cmd/qccd/config.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package qccd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initConfig() {
|
||||||
|
if cfgFile != "" {
|
||||||
|
viper.SetConfigFile(cfgFile)
|
||||||
|
} else {
|
||||||
|
viper.SetConfigName("config")
|
||||||
|
viper.SetConfigType("yaml")
|
||||||
|
viper.AddConfigPath(".")
|
||||||
|
viper.AddConfigPath("./data")
|
||||||
|
}
|
||||||
|
|
||||||
|
viper.SetEnvPrefix("QCC")
|
||||||
|
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
viper.SetDefault("server.listen", ":1963")
|
||||||
|
viper.SetDefault("acme.type", "dns")
|
||||||
|
viper.SetDefault("pow.difficulty", 22)
|
||||||
|
viper.SetDefault("pow.challenge_ttl", "30s")
|
||||||
|
viper.SetDefault("identity.cooldown", "24h")
|
||||||
|
viper.SetDefault("identity.prefix", "+0")
|
||||||
|
viper.SetDefault("ca.key_type", "ed25519")
|
||||||
|
viper.SetDefault("ca.validity", "87600h")
|
||||||
|
viper.SetDefault("relay.bandwidth_up", "100 mbps")
|
||||||
|
viper.SetDefault("relay.bandwidth_down", "100 mbps")
|
||||||
|
viper.SetDefault("relay.media_timeout", "30s")
|
||||||
|
viper.SetDefault("data_dir", "./data")
|
||||||
|
viper.SetDefault("log_level", "info")
|
||||||
|
|
||||||
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
|
if _, ok := err.(viper.ConfigFileNotFoundError); ok && cfgFile == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "Warning: config.yaml not found, using defaults")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error reading config file: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := viper.GetString("data_dir")
|
||||||
|
if err := os.MkdirAll(filepath.Join(dataDir, "acme"), 0700); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating data directory: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
cmd/qccd/main.go
Normal file
30
cmd/qccd/main.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package qccd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cfgFile string
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: filepath.Base(os.Args[0]),
|
||||||
|
Short: "QuiC Call server daemon",
|
||||||
|
Long: "QuiC Call — audio/video call server with PoW authentication and X.509 PKI. A phone system replacement.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cobra.OnInitialize(initConfig)
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (default ./config.yaml)")
|
||||||
|
rootCmd.AddCommand(serverCmd)
|
||||||
|
}
|
||||||
55
cmd/qccd/server.go
Normal file
55
cmd/qccd/server.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package qccd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/internal/config"
|
||||||
|
"github.com/niko/qcc/internal/server"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var serverCmd = &cobra.Command{
|
||||||
|
Use: "server",
|
||||||
|
Short: "Start QuiC Call server",
|
||||||
|
Run: runServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(cmd *cobra.Command, args []string) {
|
||||||
|
logger, _ := zap.NewProduction()
|
||||||
|
defer logger.Sync()
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
srv, err := server.New(cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Failed to create server", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sig := <-sigCh
|
||||||
|
logger.Info("Received signal, shutting down", zap.String("signal", sig.String()))
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
logger.Info("Starting server",
|
||||||
|
zap.String("listen", cfg.Server.Listen),
|
||||||
|
zap.Strings("domains", cfg.ACME.Domains),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := srv.Start(ctx); err != nil {
|
||||||
|
logger.Fatal("Server error", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Server stopped gracefully")
|
||||||
|
}
|
||||||
32
config.yaml.example
Normal file
32
config.yaml.example
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
server:
|
||||||
|
listen: ":1963"
|
||||||
|
|
||||||
|
acme:
|
||||||
|
domains: ["n1kt.ru"]
|
||||||
|
email: "admin@n1kt.ru"
|
||||||
|
ca: "letsencrypt"
|
||||||
|
type: "dns"
|
||||||
|
dns:
|
||||||
|
name: "cloudflare"
|
||||||
|
config:
|
||||||
|
cloudflare_api_token: "" # или через env: CF_API_TOKEN
|
||||||
|
|
||||||
|
pow:
|
||||||
|
difficulty: 22
|
||||||
|
challenge_ttl: 30s
|
||||||
|
|
||||||
|
identity:
|
||||||
|
cooldown: 24h
|
||||||
|
prefix: "+0"
|
||||||
|
|
||||||
|
ca:
|
||||||
|
key_type: "ed25519"
|
||||||
|
validity: 87600h # 10 лет
|
||||||
|
|
||||||
|
relay:
|
||||||
|
bandwidth_up: "100 mbps"
|
||||||
|
bandwidth_down: "100 mbps"
|
||||||
|
media_timeout: 30s
|
||||||
|
|
||||||
|
data_dir: "./data"
|
||||||
|
log_level: "info"
|
||||||
41
go.mod
Normal file
41
go.mod
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
module github.com/niko/qcc
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716
|
||||||
|
github.com/caddyserver/certmagic v0.17.2
|
||||||
|
github.com/libdns/cloudflare v0.1.1
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
go.uber.org/zap v1.24.0
|
||||||
|
golang.org/x/crypto v0.47.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/klauspost/cpuid/v2 v2.1.1 // indirect
|
||||||
|
github.com/libdns/libdns v0.2.2 // indirect
|
||||||
|
github.com/mholt/acmez v1.0.4 // indirect
|
||||||
|
github.com/miekg/dns v1.1.50 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
|
)
|
||||||
141
go.sum
Normal file
141
go.sum
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
|
||||||
|
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
||||||
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
|
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||||
|
github.com/caddyserver/certmagic v0.17.2 h1:o30seC1T/dBqBCNNGNHWwj2i5/I/FMjBbTAhjADP3nE=
|
||||||
|
github.com/caddyserver/certmagic v0.17.2/go.mod h1:ouWUuC490GOLJzkyN35eXfV8bSbwMwSf4bdhkIxtdQE=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.1.1 h1:t0wUqjowdm8ezddV5k0tLWVklVuvLJpoHeb4WBdydm0=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.1.1/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/libdns/cloudflare v0.1.1 h1:FVPfWwP8zZCqj268LZjmkDleXlHPlFU9KC4OJ3yn054=
|
||||||
|
github.com/libdns/cloudflare v0.1.1/go.mod h1:9VK91idpOjg6v7/WbjkEW49bSCxj00ALesIFDhJ8PBU=
|
||||||
|
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
|
||||||
|
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||||
|
github.com/mholt/acmez v1.0.4 h1:N3cE4Pek+dSolbsofIkAYz6H1d3pE+2G0os7QHslf80=
|
||||||
|
github.com/mholt/acmez v1.0.4/go.mod h1:qFGLZ4u+ehWINeJZjzPlsnjJBCPAADWTcIqE/7DAYQY=
|
||||||
|
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
|
||||||
|
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||||
|
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||||
|
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||||
|
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||||
|
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||||
|
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||||
|
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
|
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
|
golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
96
internal/auth/pow.go
Normal file
96
internal/auth/pow.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"math/bits"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PoWChallenge struct {
|
||||||
|
ServerNonce [8]byte
|
||||||
|
Timestamp int64
|
||||||
|
Bits int
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoWSolution struct {
|
||||||
|
ClientNonce [8]byte
|
||||||
|
ClientPubKey [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChallenge(bits int) *PoWChallenge {
|
||||||
|
var nonce [8]byte
|
||||||
|
binary.BigEndian.PutUint64(nonce[:], uint64(time.Now().UnixNano()>>10))
|
||||||
|
return &PoWChallenge{
|
||||||
|
ServerNonce: nonce,
|
||||||
|
Timestamp: time.Now().UnixNano(),
|
||||||
|
Bits: bits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PoWChallenge) Verify(solution *PoWSolution, challengeTTL time.Duration) error {
|
||||||
|
if time.Since(time.Unix(0, c.Timestamp)) > challengeTTL {
|
||||||
|
return fmt.Errorf("challenge expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
hashInput := make([]byte, 32)
|
||||||
|
binary.BigEndian.PutUint64(hashInput[0:8], uint64(c.Timestamp))
|
||||||
|
copy(hashInput[8:16], c.ServerNonce[:])
|
||||||
|
copy(hashInput[16:24], solution.ClientNonce[:])
|
||||||
|
copy(hashInput[24:], solution.ClientPubKey[:])
|
||||||
|
|
||||||
|
hash := sha256.Sum256(hashInput)
|
||||||
|
|
||||||
|
leadingZeros := 0
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
if hash[i] == 0 {
|
||||||
|
leadingZeros += 8
|
||||||
|
} else {
|
||||||
|
leadingZeros += bits.LeadingZeros8(hash[i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if leadingZeros < c.Bits {
|
||||||
|
return fmt.Errorf("insufficient PoW difficulty: got %d, need %d", leadingZeros, c.Bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PoWChallenge) Marshal() []byte {
|
||||||
|
buf := make([]byte, 20)
|
||||||
|
copy(buf[0:8], c.ServerNonce[:])
|
||||||
|
binary.BigEndian.PutUint64(buf[8:16], uint64(c.Timestamp))
|
||||||
|
binary.BigEndian.PutUint32(buf[16:20], uint32(c.Bits))
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalChallenge(data []byte) *PoWChallenge {
|
||||||
|
if len(data) < 20 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := &PoWChallenge{}
|
||||||
|
copy(c.ServerNonce[:], data[0:8])
|
||||||
|
c.Timestamp = int64(binary.BigEndian.Uint64(data[8:16]))
|
||||||
|
c.Bits = int(binary.BigEndian.Uint32(data[16:20]))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalSolution(nonce [8]byte, pubKey [32]byte) []byte {
|
||||||
|
buf := make([]byte, 40)
|
||||||
|
copy(buf[0:8], nonce[:])
|
||||||
|
copy(buf[8:40], pubKey[:])
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalSolution(data []byte) *PoWSolution {
|
||||||
|
if len(data) < 40 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := &PoWSolution{}
|
||||||
|
copy(s.ClientNonce[:], data[0:8])
|
||||||
|
copy(s.ClientPubKey[:], data[8:40])
|
||||||
|
return s
|
||||||
|
}
|
||||||
150
internal/ca/ca.go
Normal file
150
internal/ca/ca.go
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
package ca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CA struct {
|
||||||
|
key crypto.Signer
|
||||||
|
cert *x509.Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrLoad(dataDir string, keyType string, validity time.Duration) (*CA, error) {
|
||||||
|
keyPath := filepath.Join(dataDir, "ca.key")
|
||||||
|
certPath := filepath.Join(dataDir, "ca.crt")
|
||||||
|
|
||||||
|
caKey, err := loadOrGenKey(keyPath, keyType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ca key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCert, err := loadOrGenCert(certPath, caKey, validity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ca cert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CA{key: caKey, cert: caCert}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrGenKey(path, keyType string) (crypto.Signer, error) {
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("invalid CA key PEM")
|
||||||
|
}
|
||||||
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return key.(crypto.Signer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var signer crypto.Signer
|
||||||
|
switch keyType {
|
||||||
|
case "ed25519":
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signer = priv
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported key type: %s", keyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := x509.MarshalPKCS8PrivateKey(signer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pemData := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: b})
|
||||||
|
if err := os.WriteFile(path, pemData, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrGenCert(path string, key crypto.Signer, validity time.Duration) (*x509.Certificate, error) {
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("invalid CA cert PEM")
|
||||||
|
}
|
||||||
|
return x509.ParseCertificate(block.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub := key.Public()
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: "QuiC Call Root CA",
|
||||||
|
},
|
||||||
|
NotBefore: now,
|
||||||
|
NotAfter: now.Add(validity),
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
MaxPathLenZero: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, pub, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pemData := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
if err := os.WriteFile(path, pemData, 0644); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return x509.ParseCertificate(certDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ca *CA) IssueCert(commonName string, pub crypto.PublicKey, validity time.Duration) ([]byte, error) {
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: commonName,
|
||||||
|
Organization: []string{"QuiC Call"},
|
||||||
|
},
|
||||||
|
NotBefore: now,
|
||||||
|
NotAfter: now.Add(validity),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{
|
||||||
|
x509.ExtKeyUsageClientAuth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, pub, ca.key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create cert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return certDER, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ca *CA) CAKey() crypto.Signer {
|
||||||
|
return ca.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ca *CA) CACert() *x509.Certificate {
|
||||||
|
return ca.cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ca *CA) CACertPEM() []byte {
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.cert.Raw})
|
||||||
|
}
|
||||||
322
internal/call/signaling.go
Normal file
322
internal/call/signaling.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
package call
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/apernet/quic-go"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/internal/protocol"
|
||||||
|
"github.com/niko/qcc/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CallSession interface {
|
||||||
|
Number() string
|
||||||
|
PubKey() [32]byte
|
||||||
|
Stream() protocol.FrameReadWriter
|
||||||
|
Conn() *quic.Conn
|
||||||
|
SetCallID(id uint64)
|
||||||
|
CallID() uint64
|
||||||
|
ClearCall()
|
||||||
|
IsAuthenticated() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionRegistry interface {
|
||||||
|
GetByNumber(number string) CallSession
|
||||||
|
GetAll() []CallSession
|
||||||
|
Register(sess CallSession, number string)
|
||||||
|
Unregister(number string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrutalApplier func(conn *quic.Conn, bps uint64)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
calls map[uint64]*Call
|
||||||
|
userCalls map[string]uint64
|
||||||
|
nextCallID uint64
|
||||||
|
logger *zap.Logger
|
||||||
|
sessions SessionRegistry
|
||||||
|
mediaTimeout time.Duration
|
||||||
|
brutalBPS uint64
|
||||||
|
applyBrutal BrutalApplier
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Call struct {
|
||||||
|
ID uint64
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
FromSess CallSession
|
||||||
|
ToSess CallSession
|
||||||
|
StartedAt int64
|
||||||
|
Active bool
|
||||||
|
FromE2EEKey [32]byte
|
||||||
|
ToE2EEKey [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(logger *zap.Logger, sessions SessionRegistry, mediaTimeout time.Duration, brutalBPS uint64, applyBrutal BrutalApplier) *Manager {
|
||||||
|
m := &Manager{
|
||||||
|
calls: make(map[uint64]*Call),
|
||||||
|
userCalls: make(map[string]uint64),
|
||||||
|
nextCallID: 1,
|
||||||
|
logger: logger,
|
||||||
|
sessions: sessions,
|
||||||
|
mediaTimeout: mediaTimeout,
|
||||||
|
brutalBPS: brutalBPS,
|
||||||
|
applyBrutal: applyBrutal,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go m.cleanupLoop()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
m.cleanupStale()
|
||||||
|
case <-m.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
close(m.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) cleanupStale() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for id, call := range m.calls {
|
||||||
|
if !call.Active && time.Since(time.Unix(0, call.StartedAt)) > m.mediaTimeout {
|
||||||
|
delete(m.calls, id)
|
||||||
|
delete(m.userCalls, call.From)
|
||||||
|
delete(m.userCalls, call.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDialPayload(payload []byte) (targetNumber string, e2eePubKey [32]byte) {
|
||||||
|
if len(payload) < 1 {
|
||||||
|
return "", [32]byte{}
|
||||||
|
}
|
||||||
|
numLen := int(payload[0])
|
||||||
|
if len(payload) < 1+numLen+32 {
|
||||||
|
return "", [32]byte{}
|
||||||
|
}
|
||||||
|
targetNumber = string(payload[1 : 1+numLen])
|
||||||
|
copy(e2eePubKey[:], payload[1+numLen:1+numLen+32])
|
||||||
|
return targetNumber, e2eePubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalRingPayload(number string, e2eePubKey [32]byte) []byte {
|
||||||
|
buf := make([]byte, 1+len(number)+32)
|
||||||
|
buf[0] = byte(len(number))
|
||||||
|
copy(buf[1:], number)
|
||||||
|
copy(buf[1+len(number):], e2eePubKey[:])
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) HandleDial(sess CallSession, payload []byte) {
|
||||||
|
targetNumber, callerE2EE := parseDialPayload(payload)
|
||||||
|
if targetNumber == "" {
|
||||||
|
sendError(sess, types.ErrInvalidRequest, "empty target number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fromNumber := sess.Number()
|
||||||
|
if fromNumber == targetNumber {
|
||||||
|
sendError(sess, types.ErrSelfCall, "cannot call yourself")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, busy := m.userCalls[fromNumber]; busy {
|
||||||
|
m.mu.Unlock()
|
||||||
|
sendError(sess, types.ErrBusy, "you already have an active call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSess := m.sessions.GetByNumber(targetNumber)
|
||||||
|
if targetSess == nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
sendError(sess, types.ErrNumberNotFound, "target offline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, busy := m.userCalls[targetNumber]; busy {
|
||||||
|
m.mu.Unlock()
|
||||||
|
sendError(sess, types.ErrBusy, "target is busy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callID := m.nextCallID
|
||||||
|
m.nextCallID++
|
||||||
|
call := &Call{
|
||||||
|
ID: callID,
|
||||||
|
From: fromNumber,
|
||||||
|
To: targetNumber,
|
||||||
|
FromSess: sess,
|
||||||
|
ToSess: targetSess,
|
||||||
|
StartedAt: time.Now().UnixNano(),
|
||||||
|
FromE2EEKey: callerE2EE,
|
||||||
|
}
|
||||||
|
m.calls[callID] = call
|
||||||
|
m.userCalls[fromNumber] = callID
|
||||||
|
m.userCalls[targetNumber] = callID
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
sess.SetCallID(callID)
|
||||||
|
targetSess.SetCallID(callID)
|
||||||
|
|
||||||
|
ringPayload := marshalRingPayload(fromNumber, callerE2EE)
|
||||||
|
if st := targetSess.Stream(); st != nil {
|
||||||
|
st.WriteFrame(byte(types.OpRing), ringPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Call initiated",
|
||||||
|
zap.Uint64("call_id", callID),
|
||||||
|
zap.String("from", fromNumber),
|
||||||
|
zap.String("to", targetNumber))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) HandleAccept(sess CallSession, payload []byte) {
|
||||||
|
callID := sess.CallID()
|
||||||
|
if callID == 0 {
|
||||||
|
sendError(sess, types.ErrCallNotFound, "no active call")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var calleeE2EE [32]byte
|
||||||
|
if len(payload) >= 32 {
|
||||||
|
copy(calleeE2EE[:], payload[:32])
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
call, exists := m.calls[callID]
|
||||||
|
if !exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
sendError(sess, types.ErrCallNotFound, "call not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call.ToE2EEKey = calleeE2EE
|
||||||
|
call.Active = true
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if m.brutalBPS > 0 && m.applyBrutal != nil {
|
||||||
|
if c := call.FromSess.Conn(); c != nil {
|
||||||
|
m.applyBrutal(c, m.brutalBPS)
|
||||||
|
}
|
||||||
|
if c := call.ToSess.Conn(); c != nil {
|
||||||
|
m.applyBrutal(c, m.brutalBPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peerPayload := make([]byte, 32)
|
||||||
|
copy(peerPayload, calleeE2EE[:])
|
||||||
|
|
||||||
|
if st := call.FromSess.Stream(); st != nil {
|
||||||
|
st.WriteFrame(byte(types.OpPeerAccept), peerPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Call accepted",
|
||||||
|
zap.Uint64("call_id", callID),
|
||||||
|
zap.Bool("e2ee", calleeE2EE != [32]byte{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) HandleReject(sess CallSession, _ []byte) {
|
||||||
|
m.endCall(sess, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) HandleEnd(sess CallSession, _ []byte) {
|
||||||
|
m.endCall(sess, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) endCall(sess CallSession, notify bool) {
|
||||||
|
callID := sess.CallID()
|
||||||
|
if callID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
call, exists := m.calls[callID]
|
||||||
|
if !exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(m.calls, callID)
|
||||||
|
delete(m.userCalls, call.From)
|
||||||
|
delete(m.userCalls, call.To)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
sess.ClearCall()
|
||||||
|
|
||||||
|
var otherSess CallSession
|
||||||
|
if sess == call.FromSess {
|
||||||
|
otherSess = call.ToSess
|
||||||
|
} else {
|
||||||
|
otherSess = call.FromSess
|
||||||
|
}
|
||||||
|
|
||||||
|
if otherSess != nil {
|
||||||
|
otherSess.ClearCall()
|
||||||
|
if notify {
|
||||||
|
if st := otherSess.Stream(); st != nil {
|
||||||
|
st.WriteFrame(byte(types.OpPeerEnd), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Call ended",
|
||||||
|
zap.Uint64("call_id", callID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetCall(callID uint64) *Call {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.calls[callID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) RelayMedia(sender *quic.Conn, data []byte) {
|
||||||
|
pkt, ok := protocol.UnmarshalMediaPacket(data)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
call, exists := m.calls[pkt.CallID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !exists || !call.Active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var target *quic.Conn
|
||||||
|
if sender == call.FromSess.Conn() {
|
||||||
|
target = call.ToSess.Conn()
|
||||||
|
} else if sender == call.ToSess.Conn() {
|
||||||
|
target = call.FromSess.Conn()
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := target.SendDatagram(data); err != nil {
|
||||||
|
m.logger.Warn("Failed to relay media datagram",
|
||||||
|
zap.Uint64("call_id", pkt.CallID),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendError(sess CallSession, code types.ErrorCode, msg string) {
|
||||||
|
payload := make([]byte, 2+len(msg))
|
||||||
|
binary.BigEndian.PutUint16(payload[0:2], uint16(code))
|
||||||
|
copy(payload[2:], msg)
|
||||||
|
if st := sess.Stream(); st != nil {
|
||||||
|
st.WriteFrame(byte(types.OpError), payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/config/config.go
Normal file
64
internal/config/config.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `mapstructure:"server"`
|
||||||
|
ACME ACMEConfig `mapstructure:"acme"`
|
||||||
|
PoW PoWConfig `mapstructure:"pow"`
|
||||||
|
Identity IdentityConfig `mapstructure:"identity"`
|
||||||
|
CA CAConfig `mapstructure:"ca"`
|
||||||
|
Relay RelayConfig `mapstructure:"relay"`
|
||||||
|
DataDir string `mapstructure:"data_dir"`
|
||||||
|
LogLevel string `mapstructure:"log_level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Listen string `mapstructure:"listen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ACMEConfig struct {
|
||||||
|
Domains []string `mapstructure:"domains"`
|
||||||
|
Email string `mapstructure:"email"`
|
||||||
|
CA string `mapstructure:"ca"`
|
||||||
|
Type string `mapstructure:"type"`
|
||||||
|
DNS ACMEDNSConfig `mapstructure:"dns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ACMEDNSConfig struct {
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
|
Config map[string]string `mapstructure:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoWConfig struct {
|
||||||
|
Difficulty int `mapstructure:"difficulty"`
|
||||||
|
ChallengeTTL time.Duration `mapstructure:"challenge_ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentityConfig struct {
|
||||||
|
Cooldown time.Duration `mapstructure:"cooldown"`
|
||||||
|
Prefix string `mapstructure:"prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CAConfig struct {
|
||||||
|
KeyType string `mapstructure:"key_type"`
|
||||||
|
Validity time.Duration `mapstructure:"validity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelayConfig struct {
|
||||||
|
BandwidthUp string `mapstructure:"bandwidth_up"`
|
||||||
|
BandwidthDown string `mapstructure:"bandwidth_down"`
|
||||||
|
MediaTimeout time.Duration `mapstructure:"media_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
cfg := &Config{}
|
||||||
|
if err := viper.Unmarshal(cfg); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
144
internal/congestion/brutal/brutal.go
Normal file
144
internal/congestion/brutal/brutal.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package brutal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/apernet/quic-go/congestion"
|
||||||
|
"github.com/apernet/quic-go/monotime"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/internal/congestion/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pktInfoSlotCount = 5
|
||||||
|
minSampleCount = 50
|
||||||
|
minAckRate = 0.8
|
||||||
|
congestionWindowMultiplier = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ congestion.CongestionControl = &BrutalSender{}
|
||||||
|
|
||||||
|
type BrutalSender struct {
|
||||||
|
rttStats congestion.RTTStatsProvider
|
||||||
|
bps congestion.ByteCount
|
||||||
|
maxDatagramSize congestion.ByteCount
|
||||||
|
pacer *common.Pacer
|
||||||
|
|
||||||
|
pktInfoSlots [pktInfoSlotCount]pktInfo
|
||||||
|
ackRate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type pktInfo struct {
|
||||||
|
Timestamp int64
|
||||||
|
AckCount uint64
|
||||||
|
LossCount uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBrutalSender(bps uint64) *BrutalSender {
|
||||||
|
bs := &BrutalSender{
|
||||||
|
bps: congestion.ByteCount(bps),
|
||||||
|
maxDatagramSize: congestion.InitialPacketSize,
|
||||||
|
ackRate: 1,
|
||||||
|
}
|
||||||
|
bs.pacer = common.NewPacer(func() congestion.ByteCount {
|
||||||
|
return congestion.ByteCount(float64(bs.bps) / bs.ackRate)
|
||||||
|
})
|
||||||
|
return bs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) SetRTTStatsProvider(rttStats congestion.RTTStatsProvider) {
|
||||||
|
b.rttStats = rttStats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) TimeUntilSend(bytesInFlight congestion.ByteCount) monotime.Time {
|
||||||
|
return b.pacer.TimeUntilSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) HasPacingBudget(now monotime.Time) bool {
|
||||||
|
return b.pacer.Budget(now) >= b.maxDatagramSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) CanSend(bytesInFlight congestion.ByteCount) bool {
|
||||||
|
return bytesInFlight <= b.GetCongestionWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) GetCongestionWindow() congestion.ByteCount {
|
||||||
|
rtt := b.rttStats.SmoothedRTT()
|
||||||
|
if rtt <= 0 {
|
||||||
|
return 10240
|
||||||
|
}
|
||||||
|
cwnd := congestion.ByteCount(float64(b.bps) * rtt.Seconds() * congestionWindowMultiplier / b.ackRate)
|
||||||
|
if cwnd < b.maxDatagramSize {
|
||||||
|
cwnd = b.maxDatagramSize
|
||||||
|
}
|
||||||
|
return cwnd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) OnPacketSent(sentTime monotime.Time, bytesInFlight congestion.ByteCount,
|
||||||
|
packetNumber congestion.PacketNumber, bytes congestion.ByteCount, isRetransmittable bool,
|
||||||
|
) {
|
||||||
|
b.pacer.SentPacket(sentTime, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) OnPacketAcked(number congestion.PacketNumber, ackedBytes congestion.ByteCount,
|
||||||
|
priorInFlight congestion.ByteCount, eventTime monotime.Time,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) OnCongestionEvent(number congestion.PacketNumber, lostBytes congestion.ByteCount,
|
||||||
|
priorInFlight congestion.ByteCount,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) OnCongestionEventEx(priorInFlight congestion.ByteCount, eventTime monotime.Time, ackedPackets []congestion.AckedPacketInfo, lostPackets []congestion.LostPacketInfo) {
|
||||||
|
currentTimestamp := int64(time.Duration(eventTime) / time.Second)
|
||||||
|
slot := currentTimestamp % pktInfoSlotCount
|
||||||
|
if b.pktInfoSlots[slot].Timestamp == currentTimestamp {
|
||||||
|
b.pktInfoSlots[slot].LossCount += uint64(len(lostPackets))
|
||||||
|
b.pktInfoSlots[slot].AckCount += uint64(len(ackedPackets))
|
||||||
|
} else {
|
||||||
|
b.pktInfoSlots[slot].Timestamp = currentTimestamp
|
||||||
|
b.pktInfoSlots[slot].AckCount = uint64(len(ackedPackets))
|
||||||
|
b.pktInfoSlots[slot].LossCount = uint64(len(lostPackets))
|
||||||
|
}
|
||||||
|
b.updateAckRate(currentTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) SetMaxDatagramSize(size congestion.ByteCount) {
|
||||||
|
b.maxDatagramSize = size
|
||||||
|
b.pacer.SetMaxDatagramSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) updateAckRate(currentTimestamp int64) {
|
||||||
|
minTimestamp := currentTimestamp - pktInfoSlotCount
|
||||||
|
var ackCount, lossCount uint64
|
||||||
|
for _, info := range b.pktInfoSlots {
|
||||||
|
if info.Timestamp < minTimestamp {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ackCount += info.AckCount
|
||||||
|
lossCount += info.LossCount
|
||||||
|
}
|
||||||
|
if ackCount+lossCount < minSampleCount {
|
||||||
|
b.ackRate = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rate := float64(ackCount) / float64(ackCount+lossCount)
|
||||||
|
if rate < minAckRate {
|
||||||
|
b.ackRate = minAckRate
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.ackRate = rate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) InSlowStart() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) InRecovery() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrutalSender) MaybeExitSlowStart() {}
|
||||||
|
|
||||||
|
func (b *BrutalSender) OnRetransmissionTimeout(packetsRetransmitted bool) {}
|
||||||
74
internal/congestion/common/pacer.go
Normal file
74
internal/congestion/common/pacer.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/apernet/quic-go/congestion"
|
||||||
|
"github.com/apernet/quic-go/monotime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxBurstPackets = 10
|
||||||
|
maxBurstPacingDelayMultiplier = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type Pacer struct {
|
||||||
|
budgetAtLastSent congestion.ByteCount
|
||||||
|
maxDatagramSize congestion.ByteCount
|
||||||
|
lastSentTime monotime.Time
|
||||||
|
getBandwidth func() congestion.ByteCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPacer(getBandwidth func() congestion.ByteCount) *Pacer {
|
||||||
|
p := &Pacer{
|
||||||
|
budgetAtLastSent: maxBurstPackets * congestion.InitialPacketSize,
|
||||||
|
maxDatagramSize: congestion.InitialPacketSize,
|
||||||
|
getBandwidth: getBandwidth,
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pacer) SentPacket(sendTime monotime.Time, size congestion.ByteCount) {
|
||||||
|
budget := p.Budget(sendTime)
|
||||||
|
if size > budget {
|
||||||
|
p.budgetAtLastSent = 0
|
||||||
|
} else {
|
||||||
|
p.budgetAtLastSent = budget - size
|
||||||
|
}
|
||||||
|
p.lastSentTime = sendTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pacer) Budget(now monotime.Time) congestion.ByteCount {
|
||||||
|
if p.lastSentTime.IsZero() {
|
||||||
|
return p.maxBurstSize()
|
||||||
|
}
|
||||||
|
budget := p.budgetAtLastSent + (p.getBandwidth()*congestion.ByteCount(now.Sub(p.lastSentTime).Nanoseconds()))/1e9
|
||||||
|
if budget < 0 {
|
||||||
|
budget = congestion.ByteCount(1<<62 - 1)
|
||||||
|
}
|
||||||
|
return min(p.maxBurstSize(), budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pacer) maxBurstSize() congestion.ByteCount {
|
||||||
|
return max(
|
||||||
|
congestion.ByteCount((maxBurstPacingDelayMultiplier*congestion.MinPacingDelay).Nanoseconds())*p.getBandwidth()/1e9,
|
||||||
|
maxBurstPackets*p.maxDatagramSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pacer) TimeUntilSend() monotime.Time {
|
||||||
|
if p.budgetAtLastSent >= p.maxDatagramSize {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
diff := 1e9 * uint64(p.maxDatagramSize-p.budgetAtLastSent)
|
||||||
|
bw := uint64(p.getBandwidth())
|
||||||
|
d := diff / bw
|
||||||
|
if diff%bw > 0 {
|
||||||
|
d++
|
||||||
|
}
|
||||||
|
return p.lastSentTime.Add(max(congestion.MinPacingDelay, time.Duration(d)*time.Nanosecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pacer) SetMaxDatagramSize(s congestion.ByteCount) {
|
||||||
|
p.maxDatagramSize = s
|
||||||
|
}
|
||||||
43
internal/crypto/atrest.go
Normal file
43
internal/crypto/atrest.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EncryptRecord(plaintext []byte, key [32]byte) (nonce [12]byte, ciphertext []byte, authTag []byte, err error) {
|
||||||
|
block, err := aes.NewCipher(key[:])
|
||||||
|
if err != nil {
|
||||||
|
return nonce, nil, nil, err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nonce, nil, nil, err
|
||||||
|
}
|
||||||
|
_, err = io.ReadFull(rand.Reader, nonce[:])
|
||||||
|
if err != nil {
|
||||||
|
return nonce, nil, nil, err
|
||||||
|
}
|
||||||
|
sealed := aead.Seal(nil, nonce[:], plaintext, nil)
|
||||||
|
tagStart := len(sealed) - aead.Overhead()
|
||||||
|
ciphertext = sealed[:tagStart]
|
||||||
|
authTag = sealed[tagStart:]
|
||||||
|
return nonce, ciphertext, authTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptRecord(ciphertext []byte, nonce [12]byte, authTag []byte, key [32]byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sealed := make([]byte, len(ciphertext)+len(authTag))
|
||||||
|
copy(sealed, ciphertext)
|
||||||
|
copy(sealed[len(ciphertext):], authTag)
|
||||||
|
return aead.Open(nil, nonce[:], sealed, nil)
|
||||||
|
}
|
||||||
74
internal/crypto/e2ee.go
Normal file
74
internal/crypto/e2ee.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
|
"golang.org/x/crypto/curve25519"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type E2EEKeypair struct {
|
||||||
|
PrivateKey [32]byte
|
||||||
|
PublicKey [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateE2EEKeypair() (*E2EEKeypair, error) {
|
||||||
|
priv := make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, priv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Clamp for X25519
|
||||||
|
priv[0] &= 248
|
||||||
|
priv[31] &= 127
|
||||||
|
priv[31] |= 64
|
||||||
|
|
||||||
|
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var privArr [32]byte
|
||||||
|
var pubArr [32]byte
|
||||||
|
copy(privArr[:], priv)
|
||||||
|
copy(pubArr[:], pub)
|
||||||
|
return &E2EEKeypair{PrivateKey: privArr, PublicKey: pubArr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeriveE2EEKey(privateKey, publicKey [32]byte) []byte {
|
||||||
|
shared, _ := curve25519.X25519(privateKey[:], publicKey[:])
|
||||||
|
|
||||||
|
salt := make([]byte, 32)
|
||||||
|
info := []byte("qcc-e2ee-media-key")
|
||||||
|
hkdf := hkdf.New(sha256.New, shared, salt, info)
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(hkdf, key); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptE2EE(key []byte, plaintext []byte) ([]byte, error) {
|
||||||
|
aead, err := chacha20poly1305.New(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, chacha20poly1305.NonceSizeX)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return aead.Seal(nonce, nonce, plaintext, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptE2EE(key []byte, ciphertext []byte) ([]byte, error) {
|
||||||
|
aead, err := chacha20poly1305.New(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(ciphertext) < chacha20poly1305.NonceSizeX {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
nonce := ciphertext[:chacha20poly1305.NonceSizeX]
|
||||||
|
return aead.Open(nil, nonce, ciphertext[chacha20poly1305.NonceSizeX:], nil)
|
||||||
|
}
|
||||||
242
internal/identity/manager.go
Normal file
242
internal/identity/manager.go
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/internal/store"
|
||||||
|
"github.com/niko/qcc/pkg/types"
|
||||||
|
"golang.org/x/crypto/blake2b"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Identity struct {
|
||||||
|
Number string
|
||||||
|
PubKey [32]byte
|
||||||
|
CertDER []byte
|
||||||
|
CreatedAt int64
|
||||||
|
LastReroll int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
store *store.Table
|
||||||
|
masterKey [32]byte
|
||||||
|
logger *zap.Logger
|
||||||
|
prefix string
|
||||||
|
cooldown time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(engine *store.Engine, masterKey [32]byte, prefix string, cooldown time.Duration, logger *zap.Logger) (*Manager, error) {
|
||||||
|
t, err := engine.GetTable("identities")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get identities table: %w", err)
|
||||||
|
}
|
||||||
|
store.SetMasterKey(masterKey)
|
||||||
|
return &Manager{
|
||||||
|
store: t,
|
||||||
|
masterKey: masterKey,
|
||||||
|
logger: logger,
|
||||||
|
prefix: prefix,
|
||||||
|
cooldown: cooldown,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Allocate(pubKey [32]byte) (*Identity, error) {
|
||||||
|
var number string
|
||||||
|
for attempts := 0; attempts < 100; attempts++ {
|
||||||
|
number = GenerateNumber(m.prefix)
|
||||||
|
keyHash := numberKeyHash(number)
|
||||||
|
if !m.store.Has(keyHash) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyHash := numberKeyHash(number)
|
||||||
|
if m.store.Has(keyHash) {
|
||||||
|
return nil, fmt.Errorf("failed to generate unique number")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
ident := &Identity{
|
||||||
|
Number: number,
|
||||||
|
PubKey: pubKey,
|
||||||
|
CreatedAt: now,
|
||||||
|
LastReroll: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := m.marshalIdentity(ident)
|
||||||
|
if err := m.store.Insert(keyHash, types.RecIdentity, 0, payload, m.masterKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("store identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ident, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Reroll(pubKey [32]byte) (*Identity, error) {
|
||||||
|
var foundIdent *Identity
|
||||||
|
var foundHash uint64
|
||||||
|
|
||||||
|
err := m.store.Iterate(func(keyHash uint64, payload []byte) bool {
|
||||||
|
ident := m.unmarshalIdentity(payload)
|
||||||
|
if ident != nil && ident.PubKey == pubKey {
|
||||||
|
foundIdent = ident
|
||||||
|
foundHash = keyHash
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, m.masterKey)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if foundIdent == nil {
|
||||||
|
return nil, fmt.Errorf("identity not found for pubkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(time.Unix(0, foundIdent.LastReroll))
|
||||||
|
if elapsed < m.cooldown {
|
||||||
|
retryAfter := m.cooldown - elapsed
|
||||||
|
return nil, &CooldownError{RetryAfter: retryAfter}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.store.Delete(foundHash)
|
||||||
|
|
||||||
|
number := GenerateNumber(m.prefix)
|
||||||
|
keyHash := numberKeyHash(number)
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
|
newIdent := &Identity{
|
||||||
|
Number: number,
|
||||||
|
PubKey: pubKey,
|
||||||
|
CreatedAt: foundIdent.CreatedAt,
|
||||||
|
LastReroll: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := m.marshalIdentity(newIdent)
|
||||||
|
if err := m.store.Insert(keyHash, types.RecIdentity, 0, payload, m.masterKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newIdent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetByNumber(number string) (*Identity, error) {
|
||||||
|
keyHash := numberKeyHash(number)
|
||||||
|
payload, err := m.store.Get(keyHash, m.masterKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("identity not found: %s", number)
|
||||||
|
}
|
||||||
|
ident := m.unmarshalIdentity(payload)
|
||||||
|
if ident == nil {
|
||||||
|
return nil, fmt.Errorf("invalid identity data")
|
||||||
|
}
|
||||||
|
return ident, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Exists(number string) bool {
|
||||||
|
return m.store.Has(numberKeyHash(number))
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberKeyHash(number string) uint64 {
|
||||||
|
h, _ := blake2b.New(8, nil)
|
||||||
|
h.Write([]byte(number))
|
||||||
|
return binary.BigEndian.Uint64(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) marshalIdentity(ident *Identity) []byte {
|
||||||
|
buf := make([]byte, 8+32+2+len(ident.CertDER)+8+8)
|
||||||
|
off := 0
|
||||||
|
PutString(buf, &off, ident.Number)
|
||||||
|
copy(buf[off:off+32], ident.PubKey[:])
|
||||||
|
off += 32
|
||||||
|
PutBytes(buf, &off, ident.CertDER)
|
||||||
|
binary.BigEndian.PutUint64(buf[off:off+8], uint64(ident.CreatedAt))
|
||||||
|
off += 8
|
||||||
|
binary.BigEndian.PutUint64(buf[off:off+8], uint64(ident.LastReroll))
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) unmarshalIdentity(data []byte) *Identity {
|
||||||
|
if len(data) < 50 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ident := &Identity{}
|
||||||
|
off := 0
|
||||||
|
number, ok := GetString(data, &off)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ident.Number = number
|
||||||
|
if off+32 > len(data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy(ident.PubKey[:], data[off:off+32])
|
||||||
|
off += 32
|
||||||
|
certDER, ok := GetBytes(data, &off)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ident.CertDER = certDER
|
||||||
|
if off+16 > len(data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ident.CreatedAt = int64(binary.BigEndian.Uint64(data[off:]))
|
||||||
|
off += 8
|
||||||
|
ident.LastReroll = int64(binary.BigEndian.Uint64(data[off:]))
|
||||||
|
return ident
|
||||||
|
}
|
||||||
|
|
||||||
|
type CooldownError struct {
|
||||||
|
RetryAfter time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CooldownError) Error() string {
|
||||||
|
return fmt.Sprintf("cooldown: retry after %s", e.RetryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PutString(buf []byte, off *int, s string) {
|
||||||
|
l := len(s)
|
||||||
|
binary.BigEndian.PutUint16(buf[*off:*off+2], uint16(l))
|
||||||
|
*off += 2
|
||||||
|
copy(buf[*off:*off+l], s)
|
||||||
|
*off += l
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetString(data []byte, off *int) (string, bool) {
|
||||||
|
if *off+2 > len(data) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
l := int(binary.BigEndian.Uint16(data[*off:]))
|
||||||
|
*off += 2
|
||||||
|
if *off+l > len(data) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s := string(data[*off : *off+l])
|
||||||
|
*off += l
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func PutBytes(buf []byte, off *int, b []byte) {
|
||||||
|
l := len(b)
|
||||||
|
binary.BigEndian.PutUint16(buf[*off:*off+2], uint16(l))
|
||||||
|
*off += 2
|
||||||
|
copy(buf[*off:*off+l], b)
|
||||||
|
*off += l
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBytes(data []byte, off *int) ([]byte, bool) {
|
||||||
|
if *off+2 > len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
l := int(binary.BigEndian.Uint16(data[*off:]))
|
||||||
|
*off += 2
|
||||||
|
if *off+l > len(data) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
b := make([]byte, l)
|
||||||
|
copy(b, data[*off:*off+l])
|
||||||
|
*off += l
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
40
internal/identity/number.go
Normal file
40
internal/identity/number.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateNumber(prefix string) string {
|
||||||
|
n1 := randInt(100, 999)
|
||||||
|
n2 := randInt(100, 999)
|
||||||
|
n3 := randInt(1000, 9999)
|
||||||
|
return fmt.Sprintf("%s %d %d %d", prefix, n1, n2, n3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randInt(min, max int) int {
|
||||||
|
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
||||||
|
return min + int(n.Int64())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeNumber(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
if len(parts) == 4 && strings.HasPrefix(parts[0], "+") {
|
||||||
|
return fmt.Sprintf("%s %s %s %s", parts[0], parts[1], parts[2], parts[3])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateNumber(s string) bool {
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(parts[0]) < 2 || parts[0][0] != '+' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
119
internal/protocol/frame.go
Normal file
119
internal/protocol/frame.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FrameReadWriter interface {
|
||||||
|
ReadFrame() (*Frame, error)
|
||||||
|
WriteFrame(opCode byte, payload []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFramePayloadSize = 1 << 24
|
||||||
|
|
||||||
|
type Frame struct {
|
||||||
|
OpCode byte
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeFrame(opCode byte, payload []byte) []byte {
|
||||||
|
payloadLen := uint64(len(payload))
|
||||||
|
buf := make([]byte, 1+varintLen(payloadLen)+len(payload))
|
||||||
|
buf[0] = opCode
|
||||||
|
off := 1
|
||||||
|
off += encodeVarint(buf[off:], uint64(payloadLen))
|
||||||
|
copy(buf[off:], payload)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFrame(r io.Reader) (*Frame, error) {
|
||||||
|
opBuf := make([]byte, 1)
|
||||||
|
if _, err := io.ReadFull(r, opBuf); err != nil {
|
||||||
|
return nil, fmt.Errorf("read opcode: %w", err)
|
||||||
|
}
|
||||||
|
opCode := opBuf[0]
|
||||||
|
|
||||||
|
payloadLen, err := readVarint(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read payload length: %w", err)
|
||||||
|
}
|
||||||
|
if payloadLen > maxFramePayloadSize {
|
||||||
|
return nil, fmt.Errorf("payload too large: %d", payloadLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := make([]byte, payloadLen)
|
||||||
|
if payloadLen > 0 {
|
||||||
|
if _, err := io.ReadFull(r, payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("read payload: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Frame{OpCode: opCode, Payload: payload}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteFrame(w io.Writer, opCode byte, payload []byte) error {
|
||||||
|
data := EncodeFrame(opCode, payload)
|
||||||
|
_, err := w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeVarint(buf []byte, v uint64) int {
|
||||||
|
i := 0
|
||||||
|
for v >= 0x80 {
|
||||||
|
buf[i] = byte(v) | 0x80
|
||||||
|
v >>= 7
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
buf[i] = byte(v)
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func varintLen(v uint64) int {
|
||||||
|
l := 0
|
||||||
|
for {
|
||||||
|
l++
|
||||||
|
if v < 0x80 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
v >>= 7
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func readVarint(r io.Reader) (uint64, error) {
|
||||||
|
var v uint64
|
||||||
|
var shift uint
|
||||||
|
for {
|
||||||
|
b := make([]byte, 1)
|
||||||
|
if _, err := io.ReadFull(r, b); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
v |= uint64(b[0]&0x7F) << shift
|
||||||
|
if b[0]&0x80 == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
shift += 7
|
||||||
|
if shift > 63 {
|
||||||
|
return 0, fmt.Errorf("varint too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadUint64(buf []byte) uint64 {
|
||||||
|
return binary.BigEndian.Uint64(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PutUint64(buf []byte, v uint64) {
|
||||||
|
binary.BigEndian.PutUint64(buf, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadUint32(buf []byte) uint32 {
|
||||||
|
return binary.BigEndian.Uint32(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PutUint32(buf []byte, v uint32) {
|
||||||
|
binary.BigEndian.PutUint32(buf, v)
|
||||||
|
}
|
||||||
82
internal/protocol/media.go
Normal file
82
internal/protocol/media.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MediaHeaderSize = 8 + 4 + 1 + 1 + 4
|
||||||
|
|
||||||
|
type MediaPacket struct {
|
||||||
|
CallID uint64
|
||||||
|
SeqNum uint32
|
||||||
|
Codec types.CodecType
|
||||||
|
Flags types.MediaFlags
|
||||||
|
Timestamp uint32
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalMediaPacket(pkt *MediaPacket) []byte {
|
||||||
|
size := MediaHeaderSize + len(pkt.Payload)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
off := 0
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], pkt.CallID)
|
||||||
|
off += 8
|
||||||
|
binary.LittleEndian.PutUint32(buf[off:], pkt.SeqNum)
|
||||||
|
off += 4
|
||||||
|
buf[off] = byte(pkt.Codec)
|
||||||
|
off++
|
||||||
|
buf[off] = byte(pkt.Flags)
|
||||||
|
off++
|
||||||
|
binary.BigEndian.PutUint32(buf[off:], pkt.Timestamp)
|
||||||
|
off += 4
|
||||||
|
copy(buf[off:], pkt.Payload)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalMediaPacket(data []byte) (*MediaPacket, bool) {
|
||||||
|
if len(data) < MediaHeaderSize {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
pkt := &MediaPacket{}
|
||||||
|
off := 0
|
||||||
|
pkt.CallID = binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
pkt.SeqNum = binary.LittleEndian.Uint32(data[off:])
|
||||||
|
off += 4
|
||||||
|
pkt.Codec = types.CodecType(data[off])
|
||||||
|
off++
|
||||||
|
pkt.Flags = types.MediaFlags(data[off])
|
||||||
|
off++
|
||||||
|
pkt.Timestamp = binary.BigEndian.Uint32(data[off:])
|
||||||
|
off += 4
|
||||||
|
pkt.Payload = make([]byte, len(data)-off)
|
||||||
|
copy(pkt.Payload, data[off:])
|
||||||
|
return pkt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadMediaPacket(r io.Reader) (*MediaPacket, error) {
|
||||||
|
header := make([]byte, MediaHeaderSize)
|
||||||
|
if _, err := io.ReadFull(r, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkt := &MediaPacket{}
|
||||||
|
off := 0
|
||||||
|
pkt.CallID = binary.BigEndian.Uint64(header[off:])
|
||||||
|
off += 8
|
||||||
|
pkt.SeqNum = binary.LittleEndian.Uint32(header[off:])
|
||||||
|
off += 4
|
||||||
|
pkt.Codec = types.CodecType(header[off])
|
||||||
|
off++
|
||||||
|
pkt.Flags = types.MediaFlags(header[off])
|
||||||
|
off++
|
||||||
|
pkt.Timestamp = binary.BigEndian.Uint32(header[off:])
|
||||||
|
payload, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkt.Payload = payload
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
46
internal/server/bandwidth.go
Normal file
46
internal/server/bandwidth.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_byte = 1
|
||||||
|
kilobyte = _byte * 1000
|
||||||
|
megabyte = kilobyte * 1000
|
||||||
|
gigabyte = megabyte * 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseBandwidth(s string) (uint64, error) {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
split := 0
|
||||||
|
for i, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
split = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if split == 0 {
|
||||||
|
return 0, errors.New("invalid bandwidth format")
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s[:split], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
unit := strings.TrimSpace(s[split:])
|
||||||
|
|
||||||
|
switch unit {
|
||||||
|
case "b", "bps":
|
||||||
|
return v / 8, nil
|
||||||
|
case "k", "kb", "kbps":
|
||||||
|
return v * kilobyte / 8, nil
|
||||||
|
case "m", "mb", "mbps":
|
||||||
|
return v * megabyte / 8, nil
|
||||||
|
case "g", "gb", "gbps":
|
||||||
|
return v * gigabyte / 8, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New("unsupported bandwidth unit")
|
||||||
|
}
|
||||||
|
}
|
||||||
374
internal/server/server.go
Normal file
374
internal/server/server.go
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/apernet/quic-go"
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"github.com/libdns/cloudflare"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/internal/auth"
|
||||||
|
"github.com/niko/qcc/internal/ca"
|
||||||
|
"github.com/niko/qcc/internal/call"
|
||||||
|
"github.com/niko/qcc/internal/congestion/brutal"
|
||||||
|
"github.com/niko/qcc/internal/config"
|
||||||
|
"github.com/niko/qcc/internal/identity"
|
||||||
|
"github.com/niko/qcc/internal/protocol"
|
||||||
|
"github.com/niko/qcc/internal/store"
|
||||||
|
"github.com/niko/qcc/internal/transport"
|
||||||
|
"github.com/niko/qcc/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
logger *zap.Logger
|
||||||
|
st *store.Engine
|
||||||
|
ca *ca.CA
|
||||||
|
identMgr *identity.Manager
|
||||||
|
callMgr *call.Manager
|
||||||
|
trans *transport.Server
|
||||||
|
|
||||||
|
sessions map[string]*Session
|
||||||
|
sessionsMu sync.RWMutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, logger *zap.Logger) (*Server, error) {
|
||||||
|
st, err := store.NewEngine(cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(masterKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate master key: %w", err)
|
||||||
|
}
|
||||||
|
var mk [32]byte
|
||||||
|
copy(mk[:], masterKey)
|
||||||
|
|
||||||
|
ca, err := ca.NewOrLoad(cfg.DataDir, cfg.CA.KeyType, cfg.CA.Validity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init ca: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identMgr, err := identity.NewManager(st, mk, cfg.Identity.Prefix, cfg.Identity.Cooldown, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init identity manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
logger: logger,
|
||||||
|
st: st,
|
||||||
|
ca: ca,
|
||||||
|
identMgr: identMgr,
|
||||||
|
sessions: make(map[string]*Session),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
|
ctx, s.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
tlsCfg, err := s.setupTLS()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tls setup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brutalBPS, err := parseBandwidth(s.cfg.Relay.BandwidthUp)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to parse relay bandwidth, using default", zap.Error(err))
|
||||||
|
brutalBPS = 10 * 1000 * 1000 / 8 // 10 mbps default
|
||||||
|
}
|
||||||
|
|
||||||
|
s.callMgr = call.NewManager(
|
||||||
|
s.logger,
|
||||||
|
s,
|
||||||
|
s.cfg.Relay.MediaTimeout,
|
||||||
|
brutalBPS,
|
||||||
|
func(conn *quic.Conn, bps uint64) {
|
||||||
|
conn.SetCongestionControl(brutal.NewBrutalSender(bps))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
s.trans, err = transport.NewServer(s.cfg.Server.Listen, tlsCfg, s.logger, s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("transport: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.trans.Start(ctx)
|
||||||
|
s.shutdown()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) shutdown() {
|
||||||
|
s.logger.Info("Shutting down server...")
|
||||||
|
|
||||||
|
if s.callMgr != nil {
|
||||||
|
s.callMgr.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.trans != nil {
|
||||||
|
s.trans.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
for number, sess := range s.sessions {
|
||||||
|
if conn := sess.Conn(); conn != nil {
|
||||||
|
conn.CloseWithError(0, "server shutdown")
|
||||||
|
}
|
||||||
|
delete(s.sessions, number)
|
||||||
|
}
|
||||||
|
s.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
if s.st != nil {
|
||||||
|
s.st.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Server shutdown complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setupTLS() (*tls.Config, error) {
|
||||||
|
if len(s.cfg.ACME.Domains) == 0 || s.cfg.ACME.Email == "" {
|
||||||
|
s.logger.Warn("ACME not configured, starting without TLS (insecure)")
|
||||||
|
return &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsSolver := &certmagic.DNS01Solver{
|
||||||
|
DNSProvider: &cloudflare.Provider{
|
||||||
|
APIToken: s.cfg.ACME.DNS.Config["cloudflare_api_token"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return transport.NewTLSConfig(
|
||||||
|
s.cfg.ACME.Domains,
|
||||||
|
s.cfg.ACME.Email,
|
||||||
|
s.cfg.ACME.CA,
|
||||||
|
dnsSolver,
|
||||||
|
s.logger,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) OnConnect(ctx context.Context, conn *quic.Conn) {
|
||||||
|
sess := NewSession(conn)
|
||||||
|
s.logger.Info("New connection",
|
||||||
|
zap.String("remote", conn.RemoteAddr().String()))
|
||||||
|
|
||||||
|
go s.handleStreams(ctx, conn, sess)
|
||||||
|
go s.handleDatagrams(ctx, conn, sess)
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
s.Unregister(sess.Number())
|
||||||
|
conn.CloseWithError(0, "shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStreams(ctx context.Context, conn *quic.Conn, sess *Session) {
|
||||||
|
for {
|
||||||
|
str, err := conn.AcceptStream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.handleStream(ctx, str, sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDatagrams(ctx context.Context, conn *quic.Conn, sess *Session) {
|
||||||
|
for {
|
||||||
|
data, err := conn.ReceiveDatagram(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.callMgr != nil && sess.IsAuthenticated() && sess.CallID() != 0 {
|
||||||
|
s.callMgr.RelayMedia(conn, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStream(ctx context.Context, stream *quic.Stream, sess *Session) {
|
||||||
|
st := transport.NewStream(stream)
|
||||||
|
sess.SetStream(st)
|
||||||
|
|
||||||
|
for {
|
||||||
|
frame, err := st.ReadFrame()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch types.OpCode(frame.OpCode) {
|
||||||
|
case types.OpGetChallenge:
|
||||||
|
s.handleGetChallenge(sess)
|
||||||
|
case types.OpSolve:
|
||||||
|
s.handleSolve(sess, frame.Payload)
|
||||||
|
case types.OpReroll:
|
||||||
|
s.handleReroll(sess)
|
||||||
|
case types.OpDial:
|
||||||
|
if !sess.IsAuthenticated() {
|
||||||
|
sendOpError(st, types.ErrInvalidRequest, "not authenticated")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.callMgr.HandleDial(sess, frame.Payload)
|
||||||
|
case types.OpAccept:
|
||||||
|
s.callMgr.HandleAccept(sess, frame.Payload)
|
||||||
|
case types.OpReject:
|
||||||
|
s.callMgr.HandleReject(sess, frame.Payload)
|
||||||
|
case types.OpEnd:
|
||||||
|
s.callMgr.HandleEnd(sess, frame.Payload)
|
||||||
|
default:
|
||||||
|
sendOpError(st, types.ErrInvalidRequest, "unknown opcode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetChallenge(sess *Session) {
|
||||||
|
challenge := auth.NewChallenge(s.cfg.PoW.Difficulty)
|
||||||
|
if st := sess.Stream(); st != nil {
|
||||||
|
st.WriteFrame(byte(types.OpChallenge), challenge.Marshal())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSolve(sess *Session, payload []byte) {
|
||||||
|
solution := auth.UnmarshalSolution(payload)
|
||||||
|
if solution == nil {
|
||||||
|
sendOpError(sess.Stream(), types.ErrPoWInvalid, "invalid solution format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge := auth.NewChallenge(s.cfg.PoW.Difficulty)
|
||||||
|
if err := challenge.Verify(solution, s.cfg.PoW.ChallengeTTL); err != nil {
|
||||||
|
sendOpError(sess.Stream(), types.ErrPoWInvalid, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var pubKey [32]byte
|
||||||
|
copy(pubKey[:], solution.ClientPubKey[:])
|
||||||
|
|
||||||
|
ident, err := s.identMgr.Allocate(pubKey)
|
||||||
|
if err != nil {
|
||||||
|
sendOpError(sess.Stream(), types.ErrInternal, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := s.ca.IssueCert(ident.Number, ed25519.PublicKey(pubKey[:]), s.cfg.CA.Validity)
|
||||||
|
if err != nil {
|
||||||
|
sendOpError(sess.Stream(), types.ErrInternal, "cert issuance failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ident.CertDER = certDER
|
||||||
|
|
||||||
|
sess.Authenticate(ident.Number, pubKey, certDER)
|
||||||
|
s.Register(sess, ident.Number)
|
||||||
|
|
||||||
|
payload = marshalIdentityResponse(ident.Number, certDER, s.ca.CACertPEM())
|
||||||
|
|
||||||
|
if st := sess.Stream(); st != nil {
|
||||||
|
st.WriteFrame(byte(types.OpIdentity), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Client authenticated",
|
||||||
|
zap.String("number", ident.Number))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReroll(sess *Session) {
|
||||||
|
if !sess.IsAuthenticated() {
|
||||||
|
sendOpError(sess.Stream(), types.ErrInvalidRequest, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newIdent, err := s.identMgr.Reroll(sess.PubKey())
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := err.(*identity.CooldownError); ok {
|
||||||
|
sendOpError(sess.Stream(), types.ErrCooldown, err.Error())
|
||||||
|
} else {
|
||||||
|
sendOpError(sess.Stream(), types.ErrInternal, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := s.ca.IssueCert(newIdent.Number, ed25519.PublicKey(newIdent.PubKey[:]), s.cfg.CA.Validity)
|
||||||
|
if err != nil {
|
||||||
|
sendOpError(sess.Stream(), types.ErrInternal, "cert issuance failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newIdent.CertDER = certDER
|
||||||
|
|
||||||
|
s.Unregister(sess.Number())
|
||||||
|
sess.Authenticate(newIdent.Number, newIdent.PubKey, certDER)
|
||||||
|
s.Register(sess, newIdent.Number)
|
||||||
|
|
||||||
|
payload := marshalIdentityResponse(newIdent.Number, certDER, s.ca.CACertPEM())
|
||||||
|
|
||||||
|
if st := sess.Stream(); st != nil {
|
||||||
|
st.WriteFrame(byte(types.OpIdentity), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Number rerolled",
|
||||||
|
zap.String("old", sess.Number()),
|
||||||
|
zap.String("new", newIdent.Number))
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalIdentityResponse(number string, certDER, caCertPEM []byte) []byte {
|
||||||
|
payload := make([]byte, len(number)+1+2+len(certDER)+len(caCertPEM))
|
||||||
|
off := 0
|
||||||
|
copy(payload[off:], []byte(number))
|
||||||
|
off += len(number)
|
||||||
|
payload[off] = 0
|
||||||
|
off++
|
||||||
|
binary.BigEndian.PutUint16(payload[off:off+2], uint16(len(certDER)))
|
||||||
|
off += 2
|
||||||
|
copy(payload[off:], certDER)
|
||||||
|
off += len(certDER)
|
||||||
|
copy(payload[off:], caCertPEM)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Register(sess call.CallSession, number string) {
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
defer s.sessionsMu.Unlock()
|
||||||
|
s.sessions[number] = sess.(*Session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Unregister(number string) {
|
||||||
|
if number == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
defer s.sessionsMu.Unlock()
|
||||||
|
delete(s.sessions, number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetByNumber(number string) call.CallSession {
|
||||||
|
s.sessionsMu.RLock()
|
||||||
|
defer s.sessionsMu.RUnlock()
|
||||||
|
return s.sessions[number]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetAll() []call.CallSession {
|
||||||
|
s.sessionsMu.RLock()
|
||||||
|
defer s.sessionsMu.RUnlock()
|
||||||
|
result := make([]call.CallSession, 0, len(s.sessions))
|
||||||
|
for _, sess := range s.sessions {
|
||||||
|
result = append(result, sess)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendOpError(st protocol.FrameReadWriter, code types.ErrorCode, msg string) {
|
||||||
|
if st == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := make([]byte, 2+len(msg))
|
||||||
|
binary.BigEndian.PutUint16(payload[0:2], uint16(code))
|
||||||
|
copy(payload[2:], msg)
|
||||||
|
st.WriteFrame(byte(types.OpError), payload)
|
||||||
|
}
|
||||||
92
internal/server/session.go
Normal file
92
internal/server/session.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/apernet/quic-go"
|
||||||
|
"github.com/niko/qcc/internal/protocol"
|
||||||
|
"github.com/niko/qcc/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
conn *quic.Conn
|
||||||
|
stream *transport.Stream
|
||||||
|
|
||||||
|
authenticated bool
|
||||||
|
number string
|
||||||
|
pubKey [32]byte
|
||||||
|
certDER []byte
|
||||||
|
|
||||||
|
activeCallID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSession(conn *quic.Conn) *Session {
|
||||||
|
return &Session{
|
||||||
|
conn: conn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) SetStream(st *transport.Stream) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.stream = st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Stream() protocol.FrameReadWriter {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.stream == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.stream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Conn() *quic.Conn {
|
||||||
|
return s.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Authenticate(number string, pubKey [32]byte, certDER []byte) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.authenticated = true
|
||||||
|
s.number = number
|
||||||
|
s.pubKey = pubKey
|
||||||
|
s.certDER = certDER
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) IsAuthenticated() bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Number() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.number
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) PubKey() [32]byte {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.pubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) SetCallID(id uint64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.activeCallID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) CallID() uint64 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.activeCallID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ClearCall() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.activeCallID = 0
|
||||||
|
}
|
||||||
59
internal/store/encrypt.go
Normal file
59
internal/store/encrypt.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/internal/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var MasterKey [32]byte
|
||||||
|
|
||||||
|
func SetMasterKey(key [32]byte) {
|
||||||
|
MasterKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptRecordPayload(plaintext []byte, key [32]byte) (nonce [12]byte, ciphertext []byte, authTag []byte, err error) {
|
||||||
|
return crypto.EncryptRecord(plaintext, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptRecordPayload(ciphertext []byte, nonce [12]byte, authTag []byte, key [32]byte) ([]byte, error) {
|
||||||
|
return crypto.DecryptRecord(ciphertext, nonce, authTag, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptIndexData(data []byte) (nonce [12]byte, ciphertext []byte, authTag []byte, err error) {
|
||||||
|
block, err := aes.NewCipher(MasterKey[:])
|
||||||
|
if err != nil {
|
||||||
|
return nonce, nil, nil, err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nonce, nil, nil, err
|
||||||
|
}
|
||||||
|
_, err = io.ReadFull(rand.Reader, nonce[:])
|
||||||
|
if err != nil {
|
||||||
|
return nonce, nil, nil, err
|
||||||
|
}
|
||||||
|
sealed := aead.Seal(nil, nonce[:], data, nil)
|
||||||
|
tagStart := len(sealed) - aead.Overhead()
|
||||||
|
ciphertext = sealed[:tagStart]
|
||||||
|
authTag = sealed[tagStart:]
|
||||||
|
return nonce, ciphertext, authTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptIndexData(ciphertext []byte, nonce [12]byte, authTag []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(MasterKey[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sealed := make([]byte, len(ciphertext)+len(authTag))
|
||||||
|
copy(sealed, ciphertext)
|
||||||
|
copy(sealed[len(ciphertext):], authTag)
|
||||||
|
return aead.Open(nil, nonce[:], sealed, nil)
|
||||||
|
}
|
||||||
170
internal/store/engine.go
Normal file
170
internal/store/engine.go
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
DataDir string
|
||||||
|
Tables map[string]*Table
|
||||||
|
frames map[string]*frameState
|
||||||
|
}
|
||||||
|
|
||||||
|
type frameState struct {
|
||||||
|
lastFrameID uint64
|
||||||
|
lastHash [32]byte
|
||||||
|
frameCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngine(dataDir string) (*Engine, error) {
|
||||||
|
dirs := []string{
|
||||||
|
dataDir,
|
||||||
|
filepath.Join(dataDir, "identities"),
|
||||||
|
filepath.Join(dataDir, "calls"),
|
||||||
|
filepath.Join(dataDir, "indexes"),
|
||||||
|
filepath.Join(dataDir, "acme"),
|
||||||
|
}
|
||||||
|
for _, d := range dirs {
|
||||||
|
if err := os.MkdirAll(d, 0700); err != nil {
|
||||||
|
return nil, fmt.Errorf("mkdir %s: %w", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e := &Engine{
|
||||||
|
DataDir: dataDir,
|
||||||
|
Tables: make(map[string]*Table),
|
||||||
|
frames: make(map[string]*frameState),
|
||||||
|
}
|
||||||
|
if err := e.initDB(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) initDB() error {
|
||||||
|
path := filepath.Join(e.DataDir, "qcc.db")
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
header := make([]byte, 14)
|
||||||
|
copy(header[0:4], []byte("QCC\x00"))
|
||||||
|
header[4] = 1
|
||||||
|
header[5] = 0
|
||||||
|
_, err = f.Write(header)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetTable(name string) (*Table, error) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
|
if t, ok := e.Tables[name]; ok {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fs, ok := e.frames[name]
|
||||||
|
if !ok {
|
||||||
|
fs = &frameState{}
|
||||||
|
e.frames[name] = fs
|
||||||
|
lastFrame, lastHash, count := e.scanFrames(name)
|
||||||
|
fs.lastFrameID = lastFrame
|
||||||
|
fs.lastHash = lastHash
|
||||||
|
fs.frameCount = count
|
||||||
|
}
|
||||||
|
|
||||||
|
tablePath := filepath.Join(e.DataDir, name)
|
||||||
|
t := &Table{
|
||||||
|
engine: e,
|
||||||
|
name: name,
|
||||||
|
path: tablePath,
|
||||||
|
lastHash: fs.lastHash,
|
||||||
|
nextID: fs.lastFrameID + 1,
|
||||||
|
frameCount: fs.frameCount,
|
||||||
|
index: make(map[uint64]IndexEntry),
|
||||||
|
}
|
||||||
|
e.Tables[name] = t
|
||||||
|
|
||||||
|
if err := t.loadLastIndex(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) scanFrames(name string) (uint64, [32]byte, int) {
|
||||||
|
dir := filepath.Join(e.DataDir, name)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, [32]byte{}, 0
|
||||||
|
}
|
||||||
|
var lastID uint64
|
||||||
|
var lastHash [32]byte
|
||||||
|
count := 0
|
||||||
|
var prevFrameID uint64
|
||||||
|
var prevHash [32]byte
|
||||||
|
for _, entry := range entries {
|
||||||
|
if filepath.Ext(entry.Name()) != ".frm" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(data) < 66 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
storedChecksum := data[len(data)-32:]
|
||||||
|
computedChecksum := ComputeFrameChecksum(data[:len(data)-32])
|
||||||
|
if string(storedChecksum) != string(computedChecksum[:]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := UnmarshalFrame(data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
if f.Header.PrevFrameID != prevFrameID ||
|
||||||
|
string(f.Header.PrevHash[:]) != string(prevHash[:]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Header.FrameID > lastID {
|
||||||
|
lastID = f.Header.FrameID
|
||||||
|
lastHash = computedChecksum
|
||||||
|
}
|
||||||
|
prevFrameID = f.Header.FrameID
|
||||||
|
prevHash = computedChecksum
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return lastID, lastHash, count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) UpdateFrameState(name string, frameID uint64, hash [32]byte) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if fs, ok := e.frames[name]; ok {
|
||||||
|
if frameID > fs.lastFrameID {
|
||||||
|
fs.lastFrameID = frameID
|
||||||
|
fs.lastHash = hash
|
||||||
|
fs.frameCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Close() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
for _, t := range e.Tables {
|
||||||
|
if err := t.flushIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
195
internal/store/frame.go
Normal file
195
internal/store/frame.go
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FrameHeader struct {
|
||||||
|
Magic [4]byte
|
||||||
|
Version uint16
|
||||||
|
FrameID uint64
|
||||||
|
PrevFrameID uint64
|
||||||
|
PrevHash [32]byte
|
||||||
|
Timestamp int64
|
||||||
|
NumRecords uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
const FrameHeaderSize = 4 + 2 + 8 + 8 + 32 + 8 + 4
|
||||||
|
const RecordHeaderSize = 1 + 1 + 2 + 8 + 4 + 12 + 16
|
||||||
|
|
||||||
|
type RecordHeader struct {
|
||||||
|
RecType types.RecType
|
||||||
|
Flags uint8
|
||||||
|
TableID uint16
|
||||||
|
KeyHash uint64
|
||||||
|
PayloadSize uint32
|
||||||
|
Nonce [12]byte
|
||||||
|
AuthTag [16]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type Frame struct {
|
||||||
|
Header FrameHeader
|
||||||
|
Records []Record
|
||||||
|
}
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
Header RecordHeader
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFrameHeader(prevFrameID uint64, prevHash [32]byte) FrameHeader {
|
||||||
|
var magic [4]byte
|
||||||
|
copy(magic[:], []byte("QCCF"))
|
||||||
|
return FrameHeader{
|
||||||
|
Magic: magic,
|
||||||
|
Version: 1,
|
||||||
|
FrameID: prevFrameID + 1,
|
||||||
|
PrevFrameID: prevFrameID,
|
||||||
|
PrevHash: prevHash,
|
||||||
|
Timestamp: time.Now().UnixNano(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalFrame(f *Frame) ([]byte, error) {
|
||||||
|
size := FrameHeaderSize
|
||||||
|
for _, rec := range f.Records {
|
||||||
|
size += RecordHeaderSize + len(rec.Payload)
|
||||||
|
}
|
||||||
|
padding := (8 - (size % 8)) % 8
|
||||||
|
size += padding + 32
|
||||||
|
|
||||||
|
buf := make([]byte, size)
|
||||||
|
off := 0
|
||||||
|
|
||||||
|
copy(buf[off:], f.Header.Magic[:])
|
||||||
|
off += 4
|
||||||
|
binary.BigEndian.PutUint16(buf[off:], f.Header.Version)
|
||||||
|
off += 2
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], f.Header.FrameID)
|
||||||
|
off += 8
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], f.Header.PrevFrameID)
|
||||||
|
off += 8
|
||||||
|
copy(buf[off:], f.Header.PrevHash[:])
|
||||||
|
off += 32
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], uint64(f.Header.Timestamp))
|
||||||
|
off += 8
|
||||||
|
binary.BigEndian.PutUint32(buf[off:], uint32(len(f.Records)))
|
||||||
|
off += 4
|
||||||
|
|
||||||
|
for _, rec := range f.Records {
|
||||||
|
buf[off] = byte(rec.Header.RecType)
|
||||||
|
off++
|
||||||
|
buf[off] = rec.Header.Flags
|
||||||
|
off++
|
||||||
|
binary.BigEndian.PutUint16(buf[off:], rec.Header.TableID)
|
||||||
|
off += 2
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], rec.Header.KeyHash)
|
||||||
|
off += 8
|
||||||
|
binary.BigEndian.PutUint32(buf[off:], rec.Header.PayloadSize)
|
||||||
|
off += 4
|
||||||
|
copy(buf[off:], rec.Header.Nonce[:])
|
||||||
|
off += 12
|
||||||
|
copy(buf[off:], rec.Header.AuthTag[:])
|
||||||
|
off += 16
|
||||||
|
copy(buf[off:], rec.Payload)
|
||||||
|
off += len(rec.Payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < padding; i++ {
|
||||||
|
buf[off] = 0
|
||||||
|
off++
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256(buf[:off])
|
||||||
|
copy(buf[off:], hash[:])
|
||||||
|
off += 32
|
||||||
|
|
||||||
|
return buf[:off], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalFrame(data []byte) (*Frame, error) {
|
||||||
|
if len(data) < FrameHeaderSize+32 {
|
||||||
|
return nil, fmt.Errorf("frame too short: %d", len(data))
|
||||||
|
}
|
||||||
|
dataLen := len(data)
|
||||||
|
checksumStart := dataLen - 32
|
||||||
|
storedChecksum := data[checksumStart:]
|
||||||
|
|
||||||
|
computed := sha256.Sum256(data[:checksumStart])
|
||||||
|
if !constantTimeEqual(storedChecksum, computed[:]) {
|
||||||
|
return nil, fmt.Errorf("frame checksum mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &Frame{}
|
||||||
|
off := 0
|
||||||
|
|
||||||
|
copy(f.Header.Magic[:], data[off:off+4])
|
||||||
|
off += 4
|
||||||
|
if string(f.Header.Magic[:]) != "QCCF" {
|
||||||
|
return nil, fmt.Errorf("invalid frame magic: %s", hex.EncodeToString(f.Header.Magic[:]))
|
||||||
|
}
|
||||||
|
f.Header.Version = binary.BigEndian.Uint16(data[off:])
|
||||||
|
off += 2
|
||||||
|
f.Header.FrameID = binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
f.Header.PrevFrameID = binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
copy(f.Header.PrevHash[:], data[off:off+32])
|
||||||
|
off += 32
|
||||||
|
f.Header.Timestamp = int64(binary.BigEndian.Uint64(data[off:]))
|
||||||
|
off += 8
|
||||||
|
f.Header.NumRecords = binary.BigEndian.Uint32(data[off:])
|
||||||
|
off += 4
|
||||||
|
|
||||||
|
for i := uint32(0); i < f.Header.NumRecords; i++ {
|
||||||
|
if off+RecordHeaderSize > checksumStart {
|
||||||
|
return nil, fmt.Errorf("record header %d exceeds frame bounds", i)
|
||||||
|
}
|
||||||
|
var rec Record
|
||||||
|
rec.Header.RecType = types.RecType(data[off])
|
||||||
|
off++
|
||||||
|
rec.Header.Flags = data[off]
|
||||||
|
off++
|
||||||
|
rec.Header.TableID = binary.BigEndian.Uint16(data[off:])
|
||||||
|
off += 2
|
||||||
|
rec.Header.KeyHash = binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
rec.Header.PayloadSize = binary.BigEndian.Uint32(data[off:])
|
||||||
|
off += 4
|
||||||
|
copy(rec.Header.Nonce[:], data[off:off+12])
|
||||||
|
off += 12
|
||||||
|
copy(rec.Header.AuthTag[:], data[off:off+16])
|
||||||
|
off += 16
|
||||||
|
|
||||||
|
if off+int(rec.Header.PayloadSize) > checksumStart {
|
||||||
|
return nil, fmt.Errorf("record payload %d exceeds frame bounds", i)
|
||||||
|
}
|
||||||
|
rec.Payload = make([]byte, rec.Header.PayloadSize)
|
||||||
|
copy(rec.Payload, data[off:off+int(rec.Header.PayloadSize)])
|
||||||
|
off += int(rec.Header.PayloadSize)
|
||||||
|
f.Records = append(f.Records, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ComputeFrameChecksum(frameData []byte) [32]byte {
|
||||||
|
return sha256.Sum256(frameData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeEqual(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var v byte
|
||||||
|
for i := 0; i < len(a); i++ {
|
||||||
|
v |= a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return v == 0
|
||||||
|
}
|
||||||
127
internal/store/index.go
Normal file
127
internal/store/index.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IndexFrame struct {
|
||||||
|
Entries map[uint64]IndexEntry
|
||||||
|
FrameID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildIndex(table *Table) (*IndexFrame, error) {
|
||||||
|
table.mu.RLock()
|
||||||
|
defer table.mu.RUnlock()
|
||||||
|
|
||||||
|
idx := &IndexFrame{
|
||||||
|
Entries: make(map[uint64]IndexEntry),
|
||||||
|
FrameID: table.nextID - 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
for keyHash, entry := range table.index {
|
||||||
|
idx.Entries[keyHash] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idx *IndexFrame) MarshalBinary() ([]byte, error) {
|
||||||
|
size := 8 + 8 + len(idx.Entries)*24
|
||||||
|
buf := make([]byte, size)
|
||||||
|
off := 0
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], idx.FrameID)
|
||||||
|
off += 8
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], uint64(len(idx.Entries)))
|
||||||
|
off += 8
|
||||||
|
|
||||||
|
for keyHash, entry := range idx.Entries {
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], keyHash)
|
||||||
|
off += 8
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], entry.FrameID)
|
||||||
|
off += 8
|
||||||
|
binary.BigEndian.PutUint64(buf[off:], uint64(entry.Offset))
|
||||||
|
off += 8
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalIndexFrame(data []byte) (*IndexFrame, error) {
|
||||||
|
if len(data) < 16 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
idx := &IndexFrame{
|
||||||
|
Entries: make(map[uint64]IndexEntry),
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
idx.FrameID = binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
count := binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
|
||||||
|
for i := uint64(0); i < count; i++ {
|
||||||
|
if off+24 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
keyHash := binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
frameID := binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
offset := binary.BigEndian.Uint64(data[off:])
|
||||||
|
off += 8
|
||||||
|
idx.Entries[keyHash] = IndexEntry{FrameID: frameID, Offset: uint32(offset)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppendIndexToTable(table *Table, idx *IndexFrame) error {
|
||||||
|
idxData, err := idx.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyHash := uint64(0xFFFFFFFFFFFFFFFF)
|
||||||
|
header := RecordHeader{
|
||||||
|
RecType: types.RecIndex,
|
||||||
|
Flags: 0,
|
||||||
|
TableID: 0,
|
||||||
|
KeyHash: keyHash,
|
||||||
|
PayloadSize: uint32(len(idxData)),
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := &Frame{
|
||||||
|
Header: NewFrameHeader(table.nextID-1, table.lastHash),
|
||||||
|
Records: []Record{
|
||||||
|
{Header: header, Payload: idxData},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := MarshalFrame(frame)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
frameName := pathForIndexFrame(table.name, table.nextID)
|
||||||
|
framePath := filepath.Join(table.path, frameName)
|
||||||
|
if err := os.WriteFile(framePath, data, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum := ComputeFrameChecksum(data[:len(data)-32])
|
||||||
|
table.engine.UpdateFrameState(table.name, table.nextID, checksum)
|
||||||
|
table.lastHash = checksum
|
||||||
|
table.nextID++
|
||||||
|
table.frameCount++
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathForIndexFrame(tableName string, frameID uint64) string {
|
||||||
|
return filepath.Join(tableName, "indexes", fmt.Sprintf("%016x.idx", frameID))
|
||||||
|
}
|
||||||
176
internal/store/table.go
Normal file
176
internal/store/table.go
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/niko/qcc/internal/crypto"
|
||||||
|
"github.com/niko/qcc/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IndexEntry struct {
|
||||||
|
FrameID uint64
|
||||||
|
Offset uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type Table struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
engine *Engine
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
lastHash [32]byte
|
||||||
|
nextID uint64
|
||||||
|
frameCount int
|
||||||
|
index map[uint64]IndexEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) Insert(keyHash uint64, recType types.RecType, tableID uint16, payload []byte, key [32]byte) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
nonce, ciphertext, authTag, err := crypto.EncryptRecord(payload, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var authTagArr [16]byte
|
||||||
|
copy(authTagArr[:], authTag)
|
||||||
|
header := RecordHeader{
|
||||||
|
RecType: recType,
|
||||||
|
Flags: 0x01,
|
||||||
|
TableID: tableID,
|
||||||
|
KeyHash: keyHash,
|
||||||
|
PayloadSize: uint32(len(ciphertext)),
|
||||||
|
Nonce: nonce,
|
||||||
|
AuthTag: authTagArr,
|
||||||
|
}
|
||||||
|
|
||||||
|
frame := &Frame{
|
||||||
|
Header: NewFrameHeader(t.nextID-1, t.lastHash),
|
||||||
|
Records: []Record{
|
||||||
|
{Header: header, Payload: ciphertext},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := MarshalFrame(frame)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
frameName := fmt.Sprintf("%016x.frm", t.nextID)
|
||||||
|
framePath := filepath.Join(t.path, frameName)
|
||||||
|
if err := os.WriteFile(framePath, data, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum := ComputeFrameChecksum(data[:len(data)-32])
|
||||||
|
t.engine.UpdateFrameState(t.name, t.nextID, checksum)
|
||||||
|
t.lastHash = checksum
|
||||||
|
t.index[keyHash] = IndexEntry{FrameID: t.nextID, Offset: 0}
|
||||||
|
t.nextID++
|
||||||
|
t.frameCount++
|
||||||
|
|
||||||
|
if t.frameCount%10 == 0 {
|
||||||
|
return t.saveIndex()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) Get(keyHash uint64, key [32]byte) ([]byte, error) {
|
||||||
|
t.mu.RLock()
|
||||||
|
entry, ok := t.index[keyHash]
|
||||||
|
t.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("key not found: %x", keyHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.readRecord(entry, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) readRecord(entry IndexEntry, key [32]byte) ([]byte, error) {
|
||||||
|
frameName := fmt.Sprintf("%016x.frm", entry.FrameID)
|
||||||
|
framePath := filepath.Join(t.path, frameName)
|
||||||
|
data, err := os.ReadFile(framePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := UnmarshalFrame(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.Records) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty frame")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := f.Records[0]
|
||||||
|
return crypto.DecryptRecord(rec.Payload, rec.Header.Nonce, rec.Header.AuthTag[:], key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) Has(keyHash uint64) bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
_, ok := t.index[keyHash]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) Delete(keyHash uint64) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
delete(t.index, keyHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) loadLastIndex() error {
|
||||||
|
indexPath := filepath.Join(t.engine.DataDir, "indexes", t.name+".idx")
|
||||||
|
data, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.index = make(map[uint64]IndexEntry)
|
||||||
|
off := 0
|
||||||
|
for off+16 <= len(data) {
|
||||||
|
keyHash := binary.BigEndian.Uint64(data[off:])
|
||||||
|
frameID := binary.BigEndian.Uint64(data[off+8:])
|
||||||
|
t.index[keyHash] = IndexEntry{FrameID: frameID, Offset: 0}
|
||||||
|
off += 16
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) saveIndex() error {
|
||||||
|
indexPath := filepath.Join(t.engine.DataDir, "indexes", t.name+".idx")
|
||||||
|
data := make([]byte, 0, len(t.index)*16)
|
||||||
|
for keyHash, entry := range t.index {
|
||||||
|
var buf [16]byte
|
||||||
|
binary.BigEndian.PutUint64(buf[0:8], keyHash)
|
||||||
|
binary.BigEndian.PutUint64(buf[8:16], entry.FrameID)
|
||||||
|
data = append(data, buf[:]...)
|
||||||
|
}
|
||||||
|
return os.WriteFile(indexPath, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) flushIndex() error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.saveIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) Iterate(fn func(keyHash uint64, payload []byte) bool, key [32]byte) error {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
for keyHash, entry := range t.index {
|
||||||
|
payload, err := t.readRecord(entry, key)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fn(keyHash, payload) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
141
internal/transport/server.go
Normal file
141
internal/transport/server.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/apernet/quic-go"
|
||||||
|
"github.com/caddyserver/certmagic"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
listener *quic.Listener
|
||||||
|
tr *quic.Transport
|
||||||
|
logger *zap.Logger
|
||||||
|
handler ServerHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerHandler interface {
|
||||||
|
OnConnect(ctx context.Context, conn *quic.Conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(listenAddr string, tlsConfig *tls.Config, logger *zap.Logger, handler ServerHandler) (*Server, error) {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve addr: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listen udp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := &quic.Transport{Conn: conn}
|
||||||
|
|
||||||
|
listener, err := tr.Listen(tlsConfig, &quic.Config{
|
||||||
|
Allow0RTT: true,
|
||||||
|
KeepAlivePeriod: 15,
|
||||||
|
EnableDatagrams: true,
|
||||||
|
MaxIncomingStreams: 1000,
|
||||||
|
MaxIncomingUniStreams: -1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("quic listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
listener: listener,
|
||||||
|
tr: tr,
|
||||||
|
logger: logger,
|
||||||
|
handler: handler,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTLSConfig(domains []string, email, caType string, dnsSolver *certmagic.DNS01Solver, logger *zap.Logger) (*tls.Config, error) {
|
||||||
|
dataDir := "data/acme"
|
||||||
|
cmCfg := &certmagic.Config{
|
||||||
|
RenewalWindowRatio: certmagic.DefaultRenewalWindowRatio,
|
||||||
|
KeySource: certmagic.DefaultKeyGenerator,
|
||||||
|
Storage: &certmagic.FileStorage{Path: dataDir},
|
||||||
|
Logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmIssuer := certmagic.NewACMEIssuer(cmCfg, certmagic.ACMEIssuer{
|
||||||
|
Email: email,
|
||||||
|
Agreed: true,
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
switch caType {
|
||||||
|
case "letsencrypt", "le", "":
|
||||||
|
cmIssuer.CA = certmagic.LetsEncryptProductionCA
|
||||||
|
case "zerossl", "zero":
|
||||||
|
cmIssuer.CA = certmagic.ZeroSSLProductionCA
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported CA: %s", caType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dnsSolver != nil {
|
||||||
|
cmIssuer.DisableHTTPChallenge = true
|
||||||
|
cmIssuer.DisableTLSALPNChallenge = true
|
||||||
|
cmIssuer.DNS01Solver = dnsSolver
|
||||||
|
} else {
|
||||||
|
cmIssuer.DisableHTTPChallenge = false
|
||||||
|
cmIssuer.DisableTLSALPNChallenge = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cmCfg.Issuers = []certmagic.Issuer{cmIssuer}
|
||||||
|
cmCache := certmagic.NewCache(certmagic.CacheOptions{
|
||||||
|
GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) {
|
||||||
|
return cmCfg, nil
|
||||||
|
},
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
cmCfg = certmagic.New(cmCache, *cmCfg)
|
||||||
|
|
||||||
|
if len(domains) == 0 {
|
||||||
|
return nil, fmt.Errorf("no domains configured for ACME")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmCfg.ManageSync(context.Background(), domains); err != nil {
|
||||||
|
return nil, fmt.Errorf("acme manage sync: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
GetCertificate: cmCfg.GetCertificate,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
|
s.logger.Info("QUIC server listening",
|
||||||
|
zap.String("addr", s.listener.Addr().String()))
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := s.listener.Accept(ctx)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
s.logger.Error("Accept error", zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.handler.OnConnect(ctx, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Close() error {
|
||||||
|
err1 := s.listener.Close()
|
||||||
|
err2 := s.tr.Close()
|
||||||
|
if err1 != nil {
|
||||||
|
return err1
|
||||||
|
}
|
||||||
|
return err2
|
||||||
|
}
|
||||||
27
internal/transport/stream.go
Normal file
27
internal/transport/stream.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/apernet/quic-go"
|
||||||
|
"github.com/niko/qcc/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Stream struct {
|
||||||
|
q *quic.Stream
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStream(s *quic.Stream) *Stream {
|
||||||
|
return &Stream{q: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) ReadFrame() (*protocol.Frame, error) {
|
||||||
|
return protocol.ReadFrame(s.q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) WriteFrame(opCode byte, payload []byte) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return protocol.WriteFrame(s.q, opCode, payload)
|
||||||
|
}
|
||||||
7
main.go
Normal file
7
main.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/niko/qcc/cmd/qccd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
qccd.Execute()
|
||||||
|
}
|
||||||
21
pkg/types/opcode.go
Normal file
21
pkg/types/opcode.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package types
|
||||||
|
|
||||||
|
type OpCode byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpGetChallenge OpCode = 0x01
|
||||||
|
OpChallenge OpCode = 0x02
|
||||||
|
OpSolve OpCode = 0x03
|
||||||
|
OpIdentity OpCode = 0x04
|
||||||
|
OpReroll OpCode = 0x05
|
||||||
|
|
||||||
|
OpDial OpCode = 0x10
|
||||||
|
OpRing OpCode = 0x11
|
||||||
|
OpAccept OpCode = 0x12
|
||||||
|
OpPeerAccept OpCode = 0x13
|
||||||
|
OpReject OpCode = 0x14
|
||||||
|
OpEnd OpCode = 0x15
|
||||||
|
OpPeerEnd OpCode = 0x16
|
||||||
|
|
||||||
|
OpError OpCode = 0xFF
|
||||||
|
)
|
||||||
54
pkg/types/types.go
Normal file
54
pkg/types/types.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package types
|
||||||
|
|
||||||
|
type RecType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
RecIdentity RecType = 1
|
||||||
|
RecCallLog RecType = 2
|
||||||
|
RecIndex RecType = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorCode uint16
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrNone ErrorCode = 0x00
|
||||||
|
ErrInternal ErrorCode = 0x01
|
||||||
|
ErrInvalidRequest ErrorCode = 0x02
|
||||||
|
ErrPoWInvalid ErrorCode = 0x03
|
||||||
|
ErrPoWExpired ErrorCode = 0x04
|
||||||
|
ErrPoWDifficulty ErrorCode = 0x05
|
||||||
|
ErrNumberTaken ErrorCode = 0x06
|
||||||
|
ErrCooldown ErrorCode = 0x07
|
||||||
|
ErrNumberNotFound ErrorCode = 0x08
|
||||||
|
ErrCallNotFound ErrorCode = 0x09
|
||||||
|
ErrBusy ErrorCode = 0x0A
|
||||||
|
ErrSelfCall ErrorCode = 0x0B
|
||||||
|
ErrRateLimited ErrorCode = 0x0C
|
||||||
|
)
|
||||||
|
|
||||||
|
type CodecType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodecOpus CodecType = 1
|
||||||
|
CodecVP9 CodecType = 2
|
||||||
|
CodecH264 CodecType = 3
|
||||||
|
CodecH265 CodecType = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaFlags uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
MediaKeyframe MediaFlags = 1 << 0
|
||||||
|
MediaSilence MediaFlags = 1 << 1
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxFrameSize = 67_108_864
|
||||||
|
MaxRecordsPerFrame = 1_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StreamAuth byte = 0x01
|
||||||
|
StreamCall byte = 0x02
|
||||||
|
DgramMedia byte = 0x10
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue