Merge branch 'vici-python'
Introduce a Python Egg for the vici plugin, contributed by Björn Schuberg.
This commit is contained in:
@@ -8,6 +8,7 @@ before_install:
|
||||
- sudo apt-get update -qq
|
||||
- sudo apt-get install -qq bison flex gperf gettext
|
||||
- ./scripts/test.sh deps
|
||||
- ./scripts/test.sh pydeps
|
||||
|
||||
script:
|
||||
- ./autogen.sh
|
||||
|
||||
@@ -35,6 +35,10 @@ strongswan-5.3.0
|
||||
Windows 7 IKEv2 clients, which announces its services over the tunnel if the
|
||||
negotiated IPsec policy allows it.
|
||||
|
||||
- For the vici plugin a Python Egg has been added to allow Python applications
|
||||
to control or monitor the IKE daemon using the VICI interface, similar to the
|
||||
existing ruby gem. The Python library has been contributed by Björn Schuberg.
|
||||
|
||||
- EAP server methods now can fulfill public key constraints, such as rightcert
|
||||
or rightca. Additionally, public key and signature constraints can be
|
||||
specified for EAP methods in the rightauth keyword. Currently the EAP-TLS and
|
||||
|
||||
@@ -69,6 +69,7 @@ ARG_WITH_SET([mpz_powm_sec], [yes], [use the more side-channel resistant
|
||||
ARG_WITH_SET([dev-headers], [no], [install strongSwan development headers to directory.])
|
||||
ARG_WITH_SET([printf-hooks], [auto], [force the use of a specific printf hook implementation (auto, builtin, glibc, vstr).])
|
||||
ARG_WITH_SET([rubygemdir], ["gem environment gemdir"], [path to install ruby gems to])
|
||||
ARG_WITH_SET([pythoneggdir], ["main site-packages directory"], [path to install python eggs to to])
|
||||
|
||||
if test -n "$PKG_CONFIG"; then
|
||||
systemdsystemunitdir_default=$($PKG_CONFIG --variable=systemdsystemunitdir systemd)
|
||||
@@ -290,6 +291,7 @@ ARG_DISBL_SET([load-warning], [disable the charon plugin load option warning i
|
||||
ARG_ENABL_SET([mediation], [enable IKEv2 Mediation Extension.])
|
||||
ARG_ENABL_SET([unwind-backtraces],[use libunwind to create backtraces for memory leaks and segfaults.])
|
||||
ARG_ENABL_SET([ruby-gems], [enable installation of provided ruby gems.])
|
||||
ARG_ENABL_SET([python-eggs], [enable installation of provided python eggs.])
|
||||
# compile options
|
||||
ARG_ENABL_SET([coverage], [enable lcov coverage report generation.])
|
||||
ARG_ENABL_SET([leak-detective], [enable malloc hooks to find memory leaks.])
|
||||
@@ -1186,6 +1188,19 @@ if test x$ruby_gems = xtrue; then
|
||||
AC_SUBST(RUBYGEMDIR, "$rubygemdir")
|
||||
fi
|
||||
|
||||
if test x$python_eggs = xtrue; then
|
||||
AC_PATH_PROG([EASY_INSTALL], [easy_install], [], [$PATH:/bin:/usr/bin:/usr/local/bin])
|
||||
if test x$EASY_INSTALL = x; then
|
||||
AC_MSG_ERROR(Python easy_install not found)
|
||||
fi
|
||||
if test "x$pythoneggdir" = "xmain site-packages directory"; then
|
||||
AC_SUBST(PYTHONEGGINSTALLDIR, "")
|
||||
else
|
||||
AC_SUBST(PYTHONEGGINSTALLDIR, "--install-dir $pythoneggdir")
|
||||
fi
|
||||
AC_PATH_PROG([PY_TEST], [py.test], [], [$PATH:/bin:/usr/bin:/usr/local/bin])
|
||||
fi
|
||||
|
||||
# ===============================================
|
||||
# collect plugin list for strongSwan components
|
||||
# ===============================================
|
||||
@@ -1552,6 +1567,8 @@ AM_CONDITIONAL(USE_SVC, test x$svc = xtrue)
|
||||
AM_CONDITIONAL(USE_SYSTEMD, test x$systemd = xtrue)
|
||||
AM_CONDITIONAL(USE_LEGACY_SYSTEMD, test -n "$systemdsystemunitdir" -a "x$systemdsystemunitdir" != xno)
|
||||
AM_CONDITIONAL(USE_RUBY_GEMS, test x$ruby_gems = xtrue)
|
||||
AM_CONDITIONAL(USE_PYTHON_EGGS, test x$python_eggs = xtrue)
|
||||
AM_CONDITIONAL(USE_PY_TEST, test "x$PY_TEST" != x)
|
||||
|
||||
# ========================
|
||||
# set global definitions
|
||||
@@ -1758,6 +1775,7 @@ AC_CONFIG_FILES([
|
||||
src/libcharon/plugins/stroke/Makefile
|
||||
src/libcharon/plugins/vici/Makefile
|
||||
src/libcharon/plugins/vici/ruby/Makefile
|
||||
src/libcharon/plugins/vici/python/Makefile
|
||||
src/libcharon/plugins/updown/Makefile
|
||||
src/libcharon/plugins/dhcp/Makefile
|
||||
src/libcharon/plugins/load_tester/Makefile
|
||||
|
||||
+7
-1
@@ -52,7 +52,8 @@ all)
|
||||
DEPS="$DEPS libcurl4-gnutls-dev libsoup2.4-dev libunbound-dev libldns-dev
|
||||
libmysqlclient-dev libsqlite3-dev clearsilver-dev libfcgi-dev
|
||||
libnm-glib-dev libnm-glib-vpn-dev libpcsclite-dev libpam0g-dev
|
||||
binutils-dev libunwind7-dev libjson0-dev iptables-dev"
|
||||
binutils-dev libunwind7-dev libjson0-dev iptables-dev python-pip"
|
||||
PYDEPS="pytest"
|
||||
;;
|
||||
win*)
|
||||
CONFIG="--disable-defaults --enable-svc --enable-ikev2
|
||||
@@ -95,6 +96,11 @@ if test "$1" = "deps"; then
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if test "$1" = "pydeps"; then
|
||||
test -z "$PYDEPS" || sudo pip -q install $PYDEPS
|
||||
exit $?
|
||||
fi
|
||||
|
||||
CONFIG="$CONFIG
|
||||
--enable-silent-rules
|
||||
--enable-test-vectors
|
||||
|
||||
@@ -74,3 +74,7 @@ SUBDIRS =
|
||||
if USE_RUBY_GEMS
|
||||
SUBDIRS += ruby
|
||||
endif
|
||||
|
||||
if USE_PYTHON_EGGS
|
||||
SUBDIRS += python
|
||||
endif
|
||||
|
||||
@@ -821,9 +821,9 @@ during encoding.
|
||||
|
||||
## Connecting to the daemon ##
|
||||
|
||||
To create a connection to the daemon, a socket must be passed to the
|
||||
_Connection_ constructor. There is no default, but on Unix systems usually
|
||||
a Unix socket over _/var/run/charon.vici_ is used:
|
||||
To create a connection to the daemon, a socket can be passed to the
|
||||
_Connection_ constructor. If none is passed, a default Unix socket at
|
||||
_/var/run/charon.vici_ is used:
|
||||
|
||||
require "vici"
|
||||
require "socket"
|
||||
@@ -855,3 +855,73 @@ _list-conns_ command and implicitly the _list-conn_ event:
|
||||
|
||||
For more details about the ruby gem refer to the comments in the gem source
|
||||
code or the generated documentation.
|
||||
|
||||
# vici Python egg #
|
||||
|
||||
The _vici Python egg_ is a pure Python implementation of the VICI protocol to
|
||||
implement client applications. It is provided in the _python_ subdirectory, and
|
||||
gets built and installed if strongSwan has been _./configure_'d with
|
||||
_--enable-vici_ and _--enable-python-eggs_.
|
||||
|
||||
The _vici_ module provides a _Session()_ constructor for a high level interface,
|
||||
the underlying classes are usually not required to build Python applications
|
||||
using VICI. The _Session_ class provides methods for the supported VICI
|
||||
commands.
|
||||
|
||||
To represent the VICI message data tree, the library converts the binary
|
||||
encoding to Python data types. The _Session_ class takes and returns Python
|
||||
objects for the exchanged message data:
|
||||
* Sections get encoded as OrderedDict, containing other sections, or
|
||||
* Key/Values, where the values are strings as dictionary values
|
||||
* Lists get encoded as Python Lists with string values
|
||||
Values that do not conform to Python dict or list get converted to strings using
|
||||
str().
|
||||
|
||||
## Connecting to the daemon ##
|
||||
|
||||
To create a connection to the daemon, a socket can be passed to the _Session_
|
||||
constructor. If none is passed, a default Unix socket at _/var/run/charon.vici_
|
||||
is used:
|
||||
|
||||
import vici
|
||||
import socket
|
||||
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.connect("/var/run/charon.vici")
|
||||
v = vici.Session(s)
|
||||
|
||||
## A simple client request ##
|
||||
|
||||
An example to print the daemon version information is as simple as:
|
||||
|
||||
ver = v.version()
|
||||
|
||||
print "{daemon} {version} ({sysname}, {release}, {machine})".format(**ver)
|
||||
|
||||
## A request with response iteration ##
|
||||
|
||||
The _Session_ class returns an iterable Python generator for streamed events to
|
||||
continuously stream objects to the caller. The following example lists all
|
||||
loaded connections using the _list-conns_ command and implicitly the _list-conn_
|
||||
event:
|
||||
|
||||
for conn in v.list_conns():
|
||||
for key in conn:
|
||||
print key
|
||||
|
||||
Please note that if the returned generator is not iterated completely, it must
|
||||
be closed using _close()_. This is implicitly done when breaking from a loop,
|
||||
but an explicit call may be required when directly iterating the generator with
|
||||
_next()_.
|
||||
|
||||
## Sorting in dictionaries ##
|
||||
|
||||
In VICI, in some message trees the order of objects in dictionary matters. In
|
||||
contrast to ruby Hashes, Python dictionaries do not preserve order of added
|
||||
objects. It is therefore recommended to use OrderedDicts instead of the default
|
||||
dictionaries. Objects returned by the library use OrderedDicts.
|
||||
|
||||
## API documentation ##
|
||||
|
||||
For more details about the Python egg refer to the comments in the Python source
|
||||
code.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
*.pyc
|
||||
build
|
||||
dist
|
||||
vici.egg-info
|
||||
setup.py
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2015 Björn Schuberg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
include LICENSE
|
||||
@@ -0,0 +1,33 @@
|
||||
EXTRA_DIST = LICENSE MANIFEST.in \
|
||||
setup.py.in \
|
||||
vici/test/__init__.py \
|
||||
vici/test/test_protocol.py \
|
||||
vici/__init__.py \
|
||||
vici/compat.py \
|
||||
vici/exception.py \
|
||||
vici/protocol.py \
|
||||
vici/session.py
|
||||
|
||||
setup.py: $(srcdir)/setup.py.in
|
||||
$(AM_V_GEN) sed \
|
||||
-e "s:@EGG_VERSION@:$(PACKAGE_VERSION):" \
|
||||
$(srcdir)/setup.py.in > $@
|
||||
|
||||
all-local: dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg
|
||||
|
||||
dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg: $(EXTRA_DIST) setup.py
|
||||
(cd $(srcdir); $(PYTHON) setup.py bdist_egg \
|
||||
-b $(shell readlink -f $(builddir))/build \
|
||||
-d $(shell readlink -f $(builddir))/dist)
|
||||
|
||||
clean-local: setup.py
|
||||
$(PYTHON) setup.py clean -a
|
||||
rm -rf vici.egg-info dist setup.py
|
||||
|
||||
install-exec-local: dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg
|
||||
$(EASY_INSTALL) $(PYTHONEGGINSTALLDIR) \
|
||||
dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg
|
||||
|
||||
if USE_PY_TEST
|
||||
TESTS = $(PY_TEST)
|
||||
endif
|
||||
@@ -0,0 +1,34 @@
|
||||
from setuptools import setup
|
||||
|
||||
|
||||
long_description = (
|
||||
"The strongSwan VICI protocol allows external application to monitor, "
|
||||
"configure and control the IKE daemon charon. This python package provides "
|
||||
"a native client side implementation of the VICI protocol, well suited to "
|
||||
"script automated tasks in a reliable way."
|
||||
)
|
||||
|
||||
setup(
|
||||
name="vici",
|
||||
version="@EGG_VERSION@",
|
||||
description="Native python interface for strongSwan VICI",
|
||||
author="Bjorn Schuberg",
|
||||
url="https://wiki.strongswan.org/projects/strongswan/wiki/Vici",
|
||||
license="MIT",
|
||||
packages=["vici"],
|
||||
long_description=long_description,
|
||||
include_package_data=True,
|
||||
classifiers=(
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3.2",
|
||||
"Programming Language :: Python :: 3.3",
|
||||
"Programming Language :: Python :: 3.4",
|
||||
"Topic :: Security",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
from .session import Session
|
||||
@@ -0,0 +1,14 @@
|
||||
# Help functions for compatibility between python version 2 and 3
|
||||
|
||||
|
||||
# From http://legacy.python.org/dev/peps/pep-0469
|
||||
try:
|
||||
dict.iteritems
|
||||
except AttributeError:
|
||||
# python 3
|
||||
def iteritems(d):
|
||||
return iter(d.items())
|
||||
else:
|
||||
# python 2
|
||||
def iteritems(d):
|
||||
return d.iteritems()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Exception types that may be thrown by this library."""
|
||||
|
||||
class DeserializationException(Exception):
|
||||
"""Encountered an unexpected byte sequence or missing element type."""
|
||||
|
||||
class SessionException(Exception):
|
||||
"""Session request exception."""
|
||||
|
||||
class CommandException(Exception):
|
||||
"""Command result exception."""
|
||||
@@ -0,0 +1,196 @@
|
||||
import io
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from collections import namedtuple
|
||||
from collections import OrderedDict
|
||||
|
||||
from .compat import iteritems
|
||||
from .exception import DeserializationException
|
||||
|
||||
|
||||
class Transport(object):
|
||||
HEADER_LENGTH = 4
|
||||
MAX_SEGMENT = 512 * 1024
|
||||
|
||||
def __init__(self, sock):
|
||||
self.socket = sock
|
||||
|
||||
def send(self, packet):
|
||||
self.socket.sendall(struct.pack("!I", len(packet)) + packet)
|
||||
|
||||
def receive(self):
|
||||
raw_length = self.socket.recv(self.HEADER_LENGTH)
|
||||
length, = struct.unpack("!I", raw_length)
|
||||
payload = self.socket.recv(length)
|
||||
return payload
|
||||
|
||||
def close(self):
|
||||
self.socket.shutdown(socket.SHUT_RDWR)
|
||||
self.socket.close()
|
||||
|
||||
|
||||
class Packet(object):
|
||||
CMD_REQUEST = 0 # Named request message
|
||||
CMD_RESPONSE = 1 # Unnamed response message for a request
|
||||
CMD_UNKNOWN = 2 # Unnamed response if requested command is unknown
|
||||
EVENT_REGISTER = 3 # Named event registration request
|
||||
EVENT_UNREGISTER = 4 # Named event de-registration request
|
||||
EVENT_CONFIRM = 5 # Unnamed confirmation for event (de-)registration
|
||||
EVENT_UNKNOWN = 6 # Unnamed response if event (de-)registration failed
|
||||
EVENT = 7 # Named event message
|
||||
|
||||
ParsedPacket = namedtuple(
|
||||
"ParsedPacket",
|
||||
["response_type", "payload"]
|
||||
)
|
||||
|
||||
ParsedEventPacket = namedtuple(
|
||||
"ParsedEventPacket",
|
||||
["response_type", "event_type", "payload"]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _named_request(cls, request_type, request, message=None):
|
||||
request = request.encode()
|
||||
payload = struct.pack("!BB", request_type, len(request)) + request
|
||||
if message is not None:
|
||||
return payload + message
|
||||
else:
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def request(cls, command, message=None):
|
||||
return cls._named_request(cls.CMD_REQUEST, command, message)
|
||||
|
||||
@classmethod
|
||||
def register_event(cls, event_type):
|
||||
return cls._named_request(cls.EVENT_REGISTER, event_type)
|
||||
|
||||
@classmethod
|
||||
def unregister_event(cls, event_type):
|
||||
return cls._named_request(cls.EVENT_UNREGISTER, event_type)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, packet):
|
||||
stream = FiniteStream(packet)
|
||||
response_type, = struct.unpack("!B", stream.read(1))
|
||||
|
||||
if response_type == cls.EVENT:
|
||||
length, = struct.unpack("!B", stream.read(1))
|
||||
event_type = stream.read(length)
|
||||
return cls.ParsedEventPacket(response_type, event_type, stream)
|
||||
else:
|
||||
return cls.ParsedPacket(response_type, stream)
|
||||
|
||||
|
||||
class Message(object):
|
||||
SECTION_START = 1 # Begin a new section having a name
|
||||
SECTION_END = 2 # End a previously started section
|
||||
KEY_VALUE = 3 # Define a value for a named key in the section
|
||||
LIST_START = 4 # Begin a named list for list items
|
||||
LIST_ITEM = 5 # Define an unnamed item value in the current list
|
||||
LIST_END = 6 # End a previously started list
|
||||
|
||||
@classmethod
|
||||
def serialize(cls, message):
|
||||
def encode_named_type(marker, name):
|
||||
name = name.encode()
|
||||
return struct.pack("!BB", marker, len(name)) + name
|
||||
|
||||
def encode_blob(value):
|
||||
if not isinstance(value, bytes):
|
||||
value = str(value).encode()
|
||||
return struct.pack("!H", len(value)) + value
|
||||
|
||||
def serialize_list(lst):
|
||||
segment = bytes()
|
||||
for item in lst:
|
||||
segment += struct.pack("!B", cls.LIST_ITEM) + encode_blob(item)
|
||||
return segment
|
||||
|
||||
def serialize_dict(d):
|
||||
segment = bytes()
|
||||
for key, value in iteritems(d):
|
||||
if isinstance(value, dict):
|
||||
segment += (
|
||||
encode_named_type(cls.SECTION_START, key)
|
||||
+ serialize_dict(value)
|
||||
+ struct.pack("!B", cls.SECTION_END)
|
||||
)
|
||||
elif isinstance(value, list):
|
||||
segment += (
|
||||
encode_named_type(cls.LIST_START, key)
|
||||
+ serialize_list(value)
|
||||
+ struct.pack("!B", cls.LIST_END)
|
||||
)
|
||||
else:
|
||||
segment += (
|
||||
encode_named_type(cls.KEY_VALUE, key)
|
||||
+ encode_blob(value)
|
||||
)
|
||||
return segment
|
||||
|
||||
return serialize_dict(message)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, stream):
|
||||
def decode_named_type(stream):
|
||||
length, = struct.unpack("!B", stream.read(1))
|
||||
return stream.read(length).decode()
|
||||
|
||||
def decode_blob(stream):
|
||||
length, = struct.unpack("!H", stream.read(2))
|
||||
return stream.read(length)
|
||||
|
||||
def decode_list_item(stream):
|
||||
marker, = struct.unpack("!B", stream.read(1))
|
||||
while marker == cls.LIST_ITEM:
|
||||
yield decode_blob(stream)
|
||||
marker, = struct.unpack("!B", stream.read(1))
|
||||
|
||||
if marker != cls.LIST_END:
|
||||
raise DeserializationException(
|
||||
"Expected end of list at {pos}".format(pos=stream.tell())
|
||||
)
|
||||
|
||||
section = OrderedDict()
|
||||
section_stack = []
|
||||
while stream.has_more():
|
||||
element_type, = struct.unpack("!B", stream.read(1))
|
||||
if element_type == cls.SECTION_START:
|
||||
section_name = decode_named_type(stream)
|
||||
new_section = OrderedDict()
|
||||
section[section_name] = new_section
|
||||
section_stack.append(section)
|
||||
section = new_section
|
||||
|
||||
elif element_type == cls.LIST_START:
|
||||
list_name = decode_named_type(stream)
|
||||
section[list_name] = [item for item in decode_list_item(stream)]
|
||||
|
||||
elif element_type == cls.KEY_VALUE:
|
||||
key = decode_named_type(stream)
|
||||
section[key] = decode_blob(stream)
|
||||
|
||||
elif element_type == cls.SECTION_END:
|
||||
if len(section_stack):
|
||||
section = section_stack.pop()
|
||||
else:
|
||||
raise DeserializationException(
|
||||
"Unexpected end of section at {pos}".format(
|
||||
pos=stream.tell()
|
||||
)
|
||||
)
|
||||
|
||||
if len(section_stack):
|
||||
raise DeserializationException("Expected end of section")
|
||||
return section
|
||||
|
||||
|
||||
class FiniteStream(io.BytesIO):
|
||||
def __len__(self):
|
||||
return len(self.getvalue())
|
||||
|
||||
def has_more(self):
|
||||
return self.tell() < len(self)
|
||||
@@ -0,0 +1,327 @@
|
||||
import collections
|
||||
import socket
|
||||
|
||||
from .exception import SessionException, CommandException
|
||||
from .protocol import Transport, Packet, Message
|
||||
|
||||
|
||||
class Session(object):
|
||||
def __init__(self, sock=None):
|
||||
if sock is None:
|
||||
sock = socket.socket(socket.AF_UNIX)
|
||||
sock.connect("/var/run/charon.vici")
|
||||
self.handler = SessionHandler(Transport(sock))
|
||||
|
||||
def version(self):
|
||||
"""Retrieve daemon and system specific version information.
|
||||
|
||||
:return: daemon and system specific version information
|
||||
:rtype: dict
|
||||
"""
|
||||
return self.handler.request("version")
|
||||
|
||||
def stats(self):
|
||||
"""Retrieve IKE daemon statistics and load information.
|
||||
|
||||
:return: IKE daemon statistics and load information
|
||||
:rtype: dict
|
||||
"""
|
||||
return self.handler.request("stats")
|
||||
|
||||
def reload_settings(self):
|
||||
"""Reload strongswan.conf settings and any plugins supporting reload.
|
||||
"""
|
||||
self.handler.request("reload-settings")
|
||||
|
||||
def initiate(self, sa):
|
||||
"""Initiate an SA.
|
||||
|
||||
:param sa: the SA to initiate
|
||||
:type sa: dict
|
||||
:return: generator for logs emitted as dict
|
||||
:rtype: generator
|
||||
"""
|
||||
return self.handler.streamed_request("initiate", "control-log", sa)
|
||||
|
||||
def terminate(self, sa):
|
||||
"""Terminate an SA.
|
||||
|
||||
:param sa: the SA to terminate
|
||||
:type sa: dict
|
||||
:return: generator for logs emitted as dict
|
||||
:rtype: generator
|
||||
"""
|
||||
return self.handler.streamed_request("terminate", "control-log", sa)
|
||||
|
||||
def install(self, policy):
|
||||
"""Install a trap, drop or bypass policy defined by a CHILD_SA config.
|
||||
|
||||
:param policy: policy to install
|
||||
:type policy: dict
|
||||
"""
|
||||
self.handler.request("install", policy)
|
||||
|
||||
def uninstall(self, policy):
|
||||
"""Uninstall a trap, drop or bypass policy defined by a CHILD_SA config.
|
||||
|
||||
:param policy: policy to uninstall
|
||||
:type policy: dict
|
||||
"""
|
||||
self.handler.request("uninstall", policy)
|
||||
|
||||
def list_sas(self, filters=None):
|
||||
"""Retrieve active IKE_SAs and associated CHILD_SAs.
|
||||
|
||||
:param filters: retrieve only matching IKE_SAs (optional)
|
||||
:type filters: dict
|
||||
:return: generator for active IKE_SAs and associated CHILD_SAs as dict
|
||||
:rtype: generator
|
||||
"""
|
||||
return self.handler.streamed_request("list-sas", "list-sa", filters)
|
||||
|
||||
def list_policies(self, filters=None):
|
||||
"""Retrieve installed trap, drop and bypass policies.
|
||||
|
||||
:param filters: retrieve only matching policies (optional)
|
||||
:type filters: dict
|
||||
:return: generator for installed trap, drop and bypass policies as dict
|
||||
:rtype: generator
|
||||
"""
|
||||
return self.handler.streamed_request("list-policies", "list-policy",
|
||||
filters)
|
||||
|
||||
def list_conns(self, filters=None):
|
||||
"""Retrieve loaded connections.
|
||||
|
||||
:param filters: retrieve only matching configuration names (optional)
|
||||
:type filters: dict
|
||||
:return: generator for loaded connections as dict
|
||||
:rtype: generator
|
||||
"""
|
||||
return self.handler.streamed_request("list-conns", "list-conn",
|
||||
filters)
|
||||
|
||||
def get_conns(self):
|
||||
"""Retrieve connection names loaded exclusively over vici.
|
||||
|
||||
:return: connection names
|
||||
:rtype: dict
|
||||
"""
|
||||
return self.handler.request("get-conns")
|
||||
|
||||
def list_certs(self, filters=None):
|
||||
"""Retrieve loaded certificates.
|
||||
|
||||
:param filters: retrieve only matching certificates (optional)
|
||||
:type filters: dict
|
||||
:return: generator for loaded certificates as dict
|
||||
:rtype: generator
|
||||
"""
|
||||
return self.handler.streamed_request("list-certs", "list-cert", filters)
|
||||
|
||||
def load_conn(self, connection):
|
||||
"""Load a connection definition into the daemon.
|
||||
|
||||
:param connection: connection definition
|
||||
:type connection: dict
|
||||
"""
|
||||
self.handler.request("load-conn", connection)
|
||||
|
||||
def unload_conn(self, name):
|
||||
"""Unload a connection definition.
|
||||
|
||||
:param name: connection definition name
|
||||
:type name: dict
|
||||
"""
|
||||
self.handler.request("unload-conn", name)
|
||||
|
||||
def load_cert(self, certificate):
|
||||
"""Load a certificate into the daemon.
|
||||
|
||||
:param certificate: PEM or DER encoded certificate
|
||||
:type certificate: dict
|
||||
"""
|
||||
self.handler.request("load-cert", certificate)
|
||||
|
||||
def load_key(self, private_key):
|
||||
"""Load a private key into the daemon.
|
||||
|
||||
:param private_key: PEM or DER encoded key
|
||||
"""
|
||||
self.handler.request("load-key", private_key)
|
||||
|
||||
def load_shared(self, secret):
|
||||
"""Load a shared IKE PSK, EAP or XAuth secret into the daemon.
|
||||
|
||||
:param secret: shared IKE PSK, EAP or XAuth secret
|
||||
:type secret: dict
|
||||
"""
|
||||
self.handler.request("load-shared", secret)
|
||||
|
||||
def clear_creds(self):
|
||||
"""Clear credentials loaded over vici.
|
||||
|
||||
Clear all loaded certificate, private key and shared key credentials.
|
||||
This affects only credentials loaded over vici, but additionally
|
||||
flushes the credential cache.
|
||||
"""
|
||||
self.handler.request("clear-creds")
|
||||
|
||||
def load_pool(self, pool):
|
||||
"""Load a virtual IP pool.
|
||||
|
||||
Load an in-memory virtual IP and configuration attribute pool.
|
||||
Existing pools with the same name get updated, if possible.
|
||||
|
||||
:param pool: virtual IP and configuration attribute pool
|
||||
:type pool: dict
|
||||
"""
|
||||
return self.handler.request("load-pool", pool)
|
||||
|
||||
def unload_pool(self, pool_name):
|
||||
"""Unload a virtual IP pool.
|
||||
|
||||
Unload a previously loaded virtual IP and configuration attribute pool.
|
||||
Unloading fails for pools with leases currently online.
|
||||
|
||||
:param pool_name: pool by name
|
||||
:type pool_name: dict
|
||||
"""
|
||||
self.handler.request("unload-pool", pool_name)
|
||||
|
||||
def get_pools(self):
|
||||
"""Retrieve loaded pools.
|
||||
|
||||
:return: loaded pools
|
||||
:rtype: dict
|
||||
"""
|
||||
return self.handler.request("get-pools")
|
||||
|
||||
|
||||
class SessionHandler(object):
|
||||
"""Handles client command execution requests over vici."""
|
||||
|
||||
def __init__(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def _communicate(self, packet):
|
||||
"""Send packet over transport and parse response.
|
||||
|
||||
:param packet: packet to send
|
||||
:type packet: :py:class:`vici.protocol.Packet`
|
||||
:return: parsed packet in a tuple with message type and payload
|
||||
:rtype: :py:class:`collections.namedtuple`
|
||||
"""
|
||||
self.transport.send(packet)
|
||||
return Packet.parse(self.transport.receive())
|
||||
|
||||
def request(self, command, message=None):
|
||||
"""Send request with an optional message.
|
||||
|
||||
:param command: command to send
|
||||
:type command: str
|
||||
:param message: message (optional)
|
||||
:type message: str
|
||||
:return: command result
|
||||
:rtype: dict
|
||||
"""
|
||||
if message is not None:
|
||||
message = Message.serialize(message)
|
||||
packet = Packet.request(command, message)
|
||||
response = self._communicate(packet)
|
||||
|
||||
if response.response_type != Packet.CMD_RESPONSE:
|
||||
raise SessionException(
|
||||
"Unexpected response type {type}, "
|
||||
"expected '{response}' (CMD_RESPONSE)".format(
|
||||
type=response.response_type,
|
||||
response=Packet.CMD_RESPONSE
|
||||
)
|
||||
)
|
||||
|
||||
command_response = Message.deserialize(response.payload)
|
||||
if "success" in command_response:
|
||||
if command_response["success"] != b"yes":
|
||||
raise CommandException(
|
||||
"Command failed: {errmsg}".format(
|
||||
errmsg=command_response["errmsg"]
|
||||
)
|
||||
)
|
||||
|
||||
return command_response
|
||||
|
||||
def streamed_request(self, command, event_stream_type, message=None):
|
||||
"""Send command request and collect and return all emitted events.
|
||||
|
||||
:param command: command to send
|
||||
:type command: str
|
||||
:param event_stream_type: event type emitted on command execution
|
||||
:type event_stream_type: str
|
||||
:param message: message (optional)
|
||||
:type message: str
|
||||
:return: generator for streamed event responses as dict
|
||||
:rtype: generator
|
||||
"""
|
||||
if message is not None:
|
||||
message = Message.serialize(message)
|
||||
|
||||
# subscribe to event stream
|
||||
packet = Packet.register_event(event_stream_type)
|
||||
response = self._communicate(packet)
|
||||
|
||||
if response.response_type != Packet.EVENT_CONFIRM:
|
||||
raise SessionException(
|
||||
"Unexpected response type {type}, "
|
||||
"expected '{confirm}' (EVENT_CONFIRM)".format(
|
||||
type=response.response_type,
|
||||
confirm=Packet.EVENT_CONFIRM,
|
||||
)
|
||||
)
|
||||
|
||||
# issue command, and read any event messages
|
||||
packet = Packet.request(command, message)
|
||||
self.transport.send(packet)
|
||||
exited = False
|
||||
while True:
|
||||
response = Packet.parse(self.transport.receive())
|
||||
if response.response_type == Packet.EVENT:
|
||||
if not exited:
|
||||
try:
|
||||
yield Message.deserialize(response.payload)
|
||||
except GeneratorExit:
|
||||
exited = True
|
||||
pass
|
||||
else:
|
||||
break
|
||||
|
||||
if response.response_type == Packet.CMD_RESPONSE:
|
||||
command_response = Message.deserialize(response.payload)
|
||||
else:
|
||||
raise SessionException(
|
||||
"Unexpected response type {type}, "
|
||||
"expected '{response}' (CMD_RESPONSE)".format(
|
||||
type=response.response_type,
|
||||
response=Packet.CMD_RESPONSE
|
||||
)
|
||||
)
|
||||
|
||||
# unsubscribe from event stream
|
||||
packet = Packet.unregister_event(event_stream_type)
|
||||
response = self._communicate(packet)
|
||||
if response.response_type != Packet.EVENT_CONFIRM:
|
||||
raise SessionException(
|
||||
"Unexpected response type {type}, "
|
||||
"expected '{confirm}' (EVENT_CONFIRM)".format(
|
||||
type=response.response_type,
|
||||
confirm=Packet.EVENT_CONFIRM,
|
||||
)
|
||||
)
|
||||
|
||||
# evaluate command result, if any
|
||||
if "success" in command_response:
|
||||
if command_response["success"] != b"yes":
|
||||
raise CommandException(
|
||||
"Command failed: {errmsg}".format(
|
||||
errmsg=command_response["errmsg"]
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
import pytest
|
||||
|
||||
from ..protocol import Packet, Message, FiniteStream
|
||||
from ..exception import DeserializationException
|
||||
|
||||
|
||||
class TestPacket(object):
|
||||
# test data definitions for outgoing packet types
|
||||
cmd_request = b"\x00\x0c" b"command_type"
|
||||
cmd_request_msg = b"\x00\x07" b"command" b"payload"
|
||||
event_register = b"\x03\x0a" b"event_type"
|
||||
event_unregister = b"\x04\x0a" b"event_type"
|
||||
|
||||
# test data definitions for incoming packet types
|
||||
cmd_response = b"\x01" b"reply"
|
||||
cmd_unknown = b"\x02"
|
||||
event_confirm = b"\x05"
|
||||
event_unknown = b"\x06"
|
||||
event = b"\x07\x03" b"log" b"message"
|
||||
|
||||
def test_request(self):
|
||||
assert Packet.request("command_type") == self.cmd_request
|
||||
assert Packet.request("command", b"payload") == self.cmd_request_msg
|
||||
|
||||
def test_register_event(self):
|
||||
assert Packet.register_event("event_type") == self.event_register
|
||||
|
||||
def test_unregister_event(self):
|
||||
assert Packet.unregister_event("event_type") == self.event_unregister
|
||||
|
||||
def test_parse(self):
|
||||
parsed_cmd_response = Packet.parse(self.cmd_response)
|
||||
assert parsed_cmd_response.response_type == Packet.CMD_RESPONSE
|
||||
assert parsed_cmd_response.payload.getvalue() == self.cmd_response
|
||||
|
||||
parsed_cmd_unknown = Packet.parse(self.cmd_unknown)
|
||||
assert parsed_cmd_unknown.response_type == Packet.CMD_UNKNOWN
|
||||
assert parsed_cmd_unknown.payload.getvalue() == self.cmd_unknown
|
||||
|
||||
parsed_event_confirm = Packet.parse(self.event_confirm)
|
||||
assert parsed_event_confirm.response_type == Packet.EVENT_CONFIRM
|
||||
assert parsed_event_confirm.payload.getvalue() == self.event_confirm
|
||||
|
||||
parsed_event_unknown = Packet.parse(self.event_unknown)
|
||||
assert parsed_event_unknown.response_type == Packet.EVENT_UNKNOWN
|
||||
assert parsed_event_unknown.payload.getvalue() == self.event_unknown
|
||||
|
||||
parsed_event = Packet.parse(self.event)
|
||||
assert parsed_event.response_type == Packet.EVENT
|
||||
assert parsed_event.payload.getvalue() == self.event
|
||||
|
||||
|
||||
class TestMessage(object):
|
||||
"""Message (de)serialization test."""
|
||||
|
||||
# data definitions for test of de(serialization)
|
||||
# serialized messages holding a section
|
||||
ser_sec_unclosed = b"\x01\x08unclosed"
|
||||
ser_sec_single = b"\x01\x07section\x02"
|
||||
ser_sec_nested = b"\x01\x05outer\x01\x0asubsection\x02\x02"
|
||||
|
||||
# serialized messages holding a list
|
||||
ser_list_invalid = b"\x04\x07invalid\x05\x00\x02e1\x02\x03sec\x06"
|
||||
ser_list_0_item = b"\x04\x05empty\x06"
|
||||
ser_list_1_item = b"\x04\x01l\x05\x00\x02e1\x06"
|
||||
ser_list_2_item = b"\x04\x01l\x05\x00\x02e1\x05\x00\x02e2\x06"
|
||||
|
||||
# serialized messages with key value pairs
|
||||
ser_kv_pair = b"\x03\x03key\x00\x05value"
|
||||
ser_kv_zero = b"\x03\x0azerolength\x00\x00"
|
||||
|
||||
# deserialized messages holding a section
|
||||
des_sec_single = { "section": {} }
|
||||
des_sec_nested = { "outer": { "subsection": {} } }
|
||||
|
||||
# deserialized messages holding a list
|
||||
des_list_0_item = { "empty": [] }
|
||||
des_list_1_item = { "l": [ b"e1" ] }
|
||||
des_list_2_item = { "l": [ b"e1", b"e2" ] }
|
||||
|
||||
# deserialized messages with key value pairs
|
||||
des_kv_pair = { "key": b"value" }
|
||||
des_kv_zero = { "zerolength": b"" }
|
||||
|
||||
def test_section_serialization(self):
|
||||
assert Message.serialize(self.des_sec_single) == self.ser_sec_single
|
||||
assert Message.serialize(self.des_sec_nested) == self.ser_sec_nested
|
||||
|
||||
def test_list_serialization(self):
|
||||
assert Message.serialize(self.des_list_0_item) == self.ser_list_0_item
|
||||
assert Message.serialize(self.des_list_1_item) == self.ser_list_1_item
|
||||
assert Message.serialize(self.des_list_2_item) == self.ser_list_2_item
|
||||
|
||||
def test_key_serialization(self):
|
||||
assert Message.serialize(self.des_kv_pair) == self.ser_kv_pair
|
||||
assert Message.serialize(self.des_kv_zero) == self.ser_kv_zero
|
||||
|
||||
def test_section_deserialization(self):
|
||||
single = Message.deserialize(FiniteStream(self.ser_sec_single))
|
||||
nested = Message.deserialize(FiniteStream(self.ser_sec_nested))
|
||||
|
||||
assert single == self.des_sec_single
|
||||
assert nested == self.des_sec_nested
|
||||
|
||||
with pytest.raises(DeserializationException):
|
||||
Message.deserialize(FiniteStream(self.ser_sec_unclosed))
|
||||
|
||||
def test_list_deserialization(self):
|
||||
l0 = Message.deserialize(FiniteStream(self.ser_list_0_item))
|
||||
l1 = Message.deserialize(FiniteStream(self.ser_list_1_item))
|
||||
l2 = Message.deserialize(FiniteStream(self.ser_list_2_item))
|
||||
|
||||
assert l0 == self.des_list_0_item
|
||||
assert l1 == self.des_list_1_item
|
||||
assert l2 == self.des_list_2_item
|
||||
|
||||
with pytest.raises(DeserializationException):
|
||||
Message.deserialize(FiniteStream(self.ser_list_invalid))
|
||||
|
||||
def test_key_deserialization(self):
|
||||
pair = Message.deserialize(FiniteStream(self.ser_kv_pair))
|
||||
zerolength = Message.deserialize(FiniteStream(self.ser_kv_zero))
|
||||
|
||||
assert pair == self.des_kv_pair
|
||||
assert zerolength == self.des_kv_zero
|
||||
|
||||
def test_roundtrip(self):
|
||||
message = {
|
||||
"key1": "value1",
|
||||
"section1": {
|
||||
"sub-section": {
|
||||
"key2": b"value2",
|
||||
},
|
||||
"list1": [ "item1", "item2" ],
|
||||
},
|
||||
}
|
||||
serialized_message = FiniteStream(Message.serialize(message))
|
||||
deserialized_message = Message.deserialize(serialized_message)
|
||||
|
||||
# ensure that list items and key values remain as undecoded bytes
|
||||
deserialized_section = deserialized_message["section1"]
|
||||
assert deserialized_message["key1"] == b"value1"
|
||||
assert deserialized_section["sub-section"]["key2"] == b"value2"
|
||||
assert deserialized_section["list1"] == [ b"item1", b"item2" ]
|
||||
@@ -5,7 +5,7 @@ vici.gemspec: $(srcdir)/vici.gemspec.in
|
||||
-e "s:@GEM_VERSION@:$(PACKAGE_VERSION):" \
|
||||
$(srcdir)/vici.gemspec.in > $@
|
||||
|
||||
vici-$(PACKAGE_VERSION).gem: vici.gemspec
|
||||
vici-$(PACKAGE_VERSION).gem: vici.gemspec $(EXTRA_DIST)
|
||||
(cd $(srcdir); $(GEM) build $(abs_builddir)/vici.gemspec)
|
||||
[ "$(srcdir)" = "$(builddir)" ] || \
|
||||
mv $(srcdir)/vici-$(PACKAGE_VERSION).gem $(builddir)
|
||||
|
||||
@@ -390,7 +390,10 @@ module Vici
|
||||
# during encoding.
|
||||
class Connection
|
||||
|
||||
def initialize(socket)
|
||||
def initialize(socket = nil)
|
||||
if socket == nil
|
||||
socket = UNIXSocket.new("/var/run/charon.vici")
|
||||
end
|
||||
@transp = Transport.new(socket)
|
||||
end
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ METHOD(vici_builder_t, add, void,
|
||||
|
||||
if (value.len > 0xffff)
|
||||
{
|
||||
DBG1(DBG_ENC, "vici value exceeds size limit (%zu > %u)",
|
||||
value.len, 0xffff);
|
||||
this->error++;
|
||||
return;
|
||||
}
|
||||
@@ -125,22 +127,56 @@ METHOD(vici_builder_t, add, void,
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(vici_builder_t, vadd_kv, void,
|
||||
private_vici_builder_t *this, char *key, char *fmt, va_list args)
|
||||
/**
|
||||
* Add a list item or a key/value, if key given
|
||||
*/
|
||||
static void vadd_kv_or_li(private_vici_builder_t *this, char *key,
|
||||
char *fmt, va_list args)
|
||||
{
|
||||
char buf[2048];
|
||||
u_char buf[512];
|
||||
chunk_t value;
|
||||
ssize_t len;
|
||||
va_list copy;
|
||||
|
||||
len = vsnprintf(buf, sizeof(buf), fmt, args);
|
||||
if (len < 0 || len >= sizeof(buf))
|
||||
va_copy(copy, args);
|
||||
len = vsnprintf(buf, sizeof(buf), fmt, copy);
|
||||
va_end(copy);
|
||||
if (len >= sizeof(buf))
|
||||
{
|
||||
DBG1(DBG_ENC, "vici builder format buffer exceeds limit");
|
||||
value = chunk_alloc(len + 1);
|
||||
len = vsnprintf(value.ptr, value.len, fmt, args);
|
||||
}
|
||||
else
|
||||
{
|
||||
value = chunk_create(buf, len);
|
||||
}
|
||||
|
||||
if (len < 0)
|
||||
{
|
||||
DBG1(DBG_ENC, "vici builder format print failed");
|
||||
this->error++;
|
||||
}
|
||||
else
|
||||
{
|
||||
add(this, VICI_KEY_VALUE, key, chunk_create(buf, len));
|
||||
if (key)
|
||||
{
|
||||
add(this, VICI_KEY_VALUE, key, value);
|
||||
}
|
||||
else
|
||||
{
|
||||
add(this, VICI_LIST_ITEM, value);
|
||||
}
|
||||
}
|
||||
if (value.ptr != buf)
|
||||
{
|
||||
free(value.ptr);
|
||||
}
|
||||
}
|
||||
|
||||
METHOD(vici_builder_t, vadd_kv, void,
|
||||
private_vici_builder_t *this, char *key, char *fmt, va_list args)
|
||||
{
|
||||
vadd_kv_or_li(this, key, fmt, args);
|
||||
}
|
||||
|
||||
METHOD(vici_builder_t, add_kv, void,
|
||||
@@ -153,23 +189,10 @@ METHOD(vici_builder_t, add_kv, void,
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
|
||||
METHOD(vici_builder_t, vadd_li, void,
|
||||
private_vici_builder_t *this, char *fmt, va_list args)
|
||||
{
|
||||
char buf[2048];
|
||||
ssize_t len;
|
||||
|
||||
len = vsnprintf(buf, sizeof(buf), fmt, args);
|
||||
if (len < 0 || len >= sizeof(buf))
|
||||
{
|
||||
DBG1(DBG_ENC, "vici builder format buffer exceeds limit");
|
||||
this->error++;
|
||||
}
|
||||
else
|
||||
{
|
||||
add(this, VICI_LIST_ITEM, chunk_create(buf, len));
|
||||
}
|
||||
vadd_kv_or_li(this, NULL, fmt, args);
|
||||
}
|
||||
|
||||
METHOD(vici_builder_t, add_li, void,
|
||||
|
||||
@@ -508,11 +508,14 @@ static void build_auth_cfgs(peer_cfg_t *peer_cfg, bool local, vici_builder_t *b)
|
||||
certificate_t *cert;
|
||||
char *str;
|
||||
} v;
|
||||
char buf[32];
|
||||
int i = 0;
|
||||
|
||||
enumerator = peer_cfg->create_auth_cfg_enumerator(peer_cfg, local);
|
||||
while (enumerator->enumerate(enumerator, &auth))
|
||||
{
|
||||
b->begin_section(b, local ? "local" : "remote");
|
||||
snprintf(buf, sizeof(buf), "%s-%d", local ? "local" : "remote", ++i);
|
||||
b->begin_section(b, buf);
|
||||
|
||||
rules = auth->create_enumerator(auth);
|
||||
while (rules->enumerate(rules, &rule, &v))
|
||||
|
||||
@@ -103,7 +103,7 @@ CALLBACK(conn_sn, int,
|
||||
{
|
||||
return vici_parse_cb(res, children_sn, NULL, NULL, NULL);
|
||||
}
|
||||
if (streq(name, "local") || streq(name, "remote"))
|
||||
if (strpfx(name, "local") || strpfx(name, "remote"))
|
||||
{
|
||||
hashtable_t *auth;
|
||||
|
||||
@@ -112,7 +112,8 @@ CALLBACK(conn_sn, int,
|
||||
if (ret == 0)
|
||||
{
|
||||
printf(" %s %s authentication:\n",
|
||||
name, auth->get(auth, "class") ?: "unspecified");
|
||||
strpfx(name, "local") ? "local" : "remote",
|
||||
auth->get(auth, "class") ?: "unspecified");
|
||||
if (auth->get(auth, "id"))
|
||||
{
|
||||
printf(" id: %s\n", auth->get(auth, "id"));
|
||||
|
||||
Reference in New Issue
Block a user