mirror of
https://github.com/NLnetLabs/unbound.git
synced 2025-12-21 23:31:06 -05:00
443 lines
12 KiB
C
443 lines
12 KiB
C
|
|
/*
|
||
|
|
* services/rpz.c - rpz service
|
||
|
|
*
|
||
|
|
* Copyright (c) 2019, NLnet Labs. All rights reserved.
|
||
|
|
*
|
||
|
|
* This software is open source.
|
||
|
|
*
|
||
|
|
* Redistribution and use in source and binary forms, with or without
|
||
|
|
* modification, are permitted provided that the following conditions
|
||
|
|
* are met:
|
||
|
|
*
|
||
|
|
* Redistributions of source code must retain the above copyright notice,
|
||
|
|
* this list of conditions and the following disclaimer.
|
||
|
|
*
|
||
|
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||
|
|
* this list of conditions and the following disclaimer in the documentation
|
||
|
|
* and/or other materials provided with the distribution.
|
||
|
|
*
|
||
|
|
* Neither the name of the NLNET LABS nor the names of its contributors may
|
||
|
|
* be used to endorse or promote products derived from this software without
|
||
|
|
* specific prior written permission.
|
||
|
|
*
|
||
|
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||
|
|
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||
|
|
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||
|
|
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||
|
|
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||
|
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||
|
|
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||
|
|
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||
|
|
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||
|
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||
|
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* \file
|
||
|
|
*
|
||
|
|
* This file contains functions to enable RPZ service.
|
||
|
|
*/
|
||
|
|
|
||
|
|
#include "config.h"
|
||
|
|
#include "services/rpz.h"
|
||
|
|
#include "util/config_file.h"
|
||
|
|
#include "sldns/wire2str.h"
|
||
|
|
#include "util/data/dname.h"
|
||
|
|
#include "util/net_help.h"
|
||
|
|
#include "util/log.h"
|
||
|
|
#include "util/data/dname.h"
|
||
|
|
#include "util/locks.h"
|
||
|
|
|
||
|
|
/** string for RPZ action enum */
|
||
|
|
static const char*
|
||
|
|
rpz_action_to_string(enum rpz_action a)
|
||
|
|
{
|
||
|
|
switch(a) {
|
||
|
|
case RPZ_NXDOMAIN_ACTION: return "NXDOMAIN ACTION";
|
||
|
|
case RPZ_NODATA_ACTION: return "NODATA ACTION";
|
||
|
|
case RPZ_PASSTHRU_ACTION: return "PASSTHRU ACTION";
|
||
|
|
case RPZ_DROP_ACTION: return "DROP ACTION";
|
||
|
|
case RPZ_TCP_ONLY_ACTION: return "TCP ONLY ACTION";
|
||
|
|
case RPZ_INVALID_ACTION: return "INVALID ACTION";
|
||
|
|
case RPZ_LOCAL_DATA_ACTION: return "LOCAL DATA ACTION";
|
||
|
|
}
|
||
|
|
return "UNKNOWN RPZ ACTION";
|
||
|
|
}
|
||
|
|
|
||
|
|
/** string for RPZ trigger enum */
|
||
|
|
static const char*
|
||
|
|
rpz_trigger_to_string(enum rpz_trigger r)
|
||
|
|
{
|
||
|
|
switch(r) {
|
||
|
|
case RPZ_QNAME_TRIGGER: return "QNAME TRIGGER";
|
||
|
|
case RPZ_CLIENT_IP_TRIGGER: return "CLIENT IP TRIGGER";
|
||
|
|
case RPZ_RESPONSE_IP_TRIGGER: return "RESPONSE IP TRIGGER";
|
||
|
|
case RPZ_NSDNAME_TRIGGER: return "NSDNAME TRIGGER";
|
||
|
|
case RPZ_NSIP_TRIGGER: return "NSIP TRIGGER";
|
||
|
|
}
|
||
|
|
return "UNKNOWN RPZ TRIGGER";
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the label that is just before the root label.
|
||
|
|
* @param dname: dname to work on
|
||
|
|
* @return: pointer to TLD label
|
||
|
|
*/
|
||
|
|
static uint8_t*
|
||
|
|
get_tld_label(uint8_t* dname)
|
||
|
|
{
|
||
|
|
uint8_t* prevlab = dname;
|
||
|
|
|
||
|
|
/* only root label */
|
||
|
|
if(*dname == 0)
|
||
|
|
return NULL;
|
||
|
|
|
||
|
|
while(*dname) {
|
||
|
|
dname = dname+*dname+1;
|
||
|
|
if(*dname != 0)
|
||
|
|
prevlab = dname;
|
||
|
|
}
|
||
|
|
return prevlab;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Classify RPZ action for RR type/rdata
|
||
|
|
* @param rr_type: the RR type
|
||
|
|
* @param rdatawl: RDATA with 2 bytes length
|
||
|
|
* @param rdatalen: the length of rdatawl (including its 2 bytes length)
|
||
|
|
* @return: the RPZ action
|
||
|
|
*/
|
||
|
|
static enum rpz_action
|
||
|
|
rpz_rr_to_action(uint16_t rr_type, uint8_t* rdatawl, size_t rdatalen)
|
||
|
|
{
|
||
|
|
char* endptr;
|
||
|
|
uint8_t* rdata;
|
||
|
|
int rdatalabs;
|
||
|
|
uint8_t* tldlab = NULL;
|
||
|
|
|
||
|
|
switch(rr_type) {
|
||
|
|
case LDNS_RR_TYPE_SOA:
|
||
|
|
case LDNS_RR_TYPE_NS:
|
||
|
|
case LDNS_RR_TYPE_DNAME:
|
||
|
|
/* all DNSSEC-related RRs must be ignored */
|
||
|
|
case LDNS_RR_TYPE_DNSKEY:
|
||
|
|
case LDNS_RR_TYPE_DS:
|
||
|
|
case LDNS_RR_TYPE_RRSIG:
|
||
|
|
case LDNS_RR_TYPE_NSEC:
|
||
|
|
case LDNS_RR_TYPE_NSEC3:
|
||
|
|
return RPZ_INVALID_ACTION;
|
||
|
|
case LDNS_RR_TYPE_CNAME:
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
return RPZ_LOCAL_DATA_ACTION;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* use CNAME target to determine RPZ action */
|
||
|
|
log_assert(rr_type == LDNS_RR_TYPE_CNAME);
|
||
|
|
if(rdatalen < 3)
|
||
|
|
return RPZ_INVALID_ACTION;
|
||
|
|
|
||
|
|
rdata = rdatawl + 2; /* 2 bytes of rdata length */
|
||
|
|
if(dname_valid(rdata, rdatalen-2) != rdatalen-2)
|
||
|
|
return RPZ_INVALID_ACTION;
|
||
|
|
|
||
|
|
rdatalabs = dname_count_labels(rdata);
|
||
|
|
if(rdatalabs == 1)
|
||
|
|
return RPZ_NXDOMAIN_ACTION;
|
||
|
|
else if(rdatalabs == 2) {
|
||
|
|
if(dname_subdomain_c(rdata, (uint8_t*)&"\001*\000"))
|
||
|
|
return RPZ_NODATA_ACTION;
|
||
|
|
else if(dname_subdomain_c(rdata,
|
||
|
|
(uint8_t*)&"\014rpz-passthru\000"))
|
||
|
|
return RPZ_PASSTHRU_ACTION;
|
||
|
|
else if(dname_subdomain_c(rdata, (uint8_t*)&"\010rpz-drop\000"))
|
||
|
|
return RPZ_DROP_ACTION;
|
||
|
|
else if(dname_subdomain_c(rdata,
|
||
|
|
(uint8_t*)&"\014rpz-tcp-only\000"))
|
||
|
|
return RPZ_TCP_ONLY_ACTION;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* all other TLDs starting with "rpz-" are invalid */
|
||
|
|
tldlab = get_tld_label(rdata);
|
||
|
|
if(tldlab && dname_lab_startswith(tldlab, "rpz-", &endptr))
|
||
|
|
return RPZ_INVALID_ACTION;
|
||
|
|
|
||
|
|
/* no special label found */
|
||
|
|
return RPZ_LOCAL_DATA_ACTION;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Get RPZ trigger for dname */
|
||
|
|
static enum rpz_trigger
|
||
|
|
rpz_dname_to_trigger(uint8_t* dname)
|
||
|
|
{
|
||
|
|
uint8_t* tldlab;
|
||
|
|
char* endptr;
|
||
|
|
tldlab = get_tld_label(dname);
|
||
|
|
if(!tldlab || !dname_lab_startswith(tldlab, "rpz-", &endptr))
|
||
|
|
return RPZ_QNAME_TRIGGER;
|
||
|
|
|
||
|
|
if(dname_subdomain_c(tldlab,
|
||
|
|
(uint8_t*)&"\015rpz-client-ip\000"))
|
||
|
|
return RPZ_CLIENT_IP_TRIGGER;
|
||
|
|
else if(dname_subdomain_c(tldlab, (uint8_t*)&"\006rpz-ip\000"))
|
||
|
|
return RPZ_RESPONSE_IP_TRIGGER;
|
||
|
|
else if(dname_subdomain_c(tldlab, (uint8_t*)&"\013rpz-nsdname\000"))
|
||
|
|
return RPZ_NSDNAME_TRIGGER;
|
||
|
|
else if(dname_subdomain_c(tldlab, (uint8_t*)&"\010rpz-nsip\000"))
|
||
|
|
return RPZ_NSIP_TRIGGER;
|
||
|
|
|
||
|
|
return RPZ_QNAME_TRIGGER;
|
||
|
|
}
|
||
|
|
|
||
|
|
void rpz_delete(struct rpz* r)
|
||
|
|
{
|
||
|
|
if(!r)
|
||
|
|
return;
|
||
|
|
local_zones_delete(r->local_zones);
|
||
|
|
free(r);
|
||
|
|
}
|
||
|
|
|
||
|
|
int
|
||
|
|
rpz_clear_lz(struct rpz* r)
|
||
|
|
{
|
||
|
|
/* must hold write lock on auth_zone */
|
||
|
|
local_zones_delete(r->local_zones);
|
||
|
|
if(!(r->local_zones = local_zones_create())){
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
struct rpz*
|
||
|
|
rpz_create(struct config_auth* p)
|
||
|
|
{
|
||
|
|
struct rpz* r = calloc(1, sizeof(*r));
|
||
|
|
if(!r)
|
||
|
|
return 0;
|
||
|
|
|
||
|
|
if(!(r->local_zones = local_zones_create())){
|
||
|
|
free(r);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
r->taglist = memdup(p->rpz_taglist, p->rpz_taglistlen);
|
||
|
|
r->taglistlen = p->rpz_taglistlen;
|
||
|
|
return r;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Remove RPZ zone name from dname */
|
||
|
|
static size_t
|
||
|
|
strip_dname_origin(uint8_t* dname, size_t dnamelen, size_t originlen,
|
||
|
|
uint8_t* newdname)
|
||
|
|
{
|
||
|
|
size_t newdnamelen;
|
||
|
|
if(dnamelen < originlen)
|
||
|
|
return 0;
|
||
|
|
newdnamelen = dnamelen - originlen;
|
||
|
|
memmove(newdname, dname, newdnamelen);
|
||
|
|
return newdnamelen + 1; /* + 1 for root label */
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Insert RR into RPZ's local-zone */
|
||
|
|
static int
|
||
|
|
rpz_insert_qname_trigger(struct rpz* r, uint8_t* dname, size_t dnamelen,
|
||
|
|
enum rpz_action a, uint16_t rrtype, uint16_t rrclass, uint32_t ttl,
|
||
|
|
uint8_t* rdata, size_t rdata_len, uint8_t* rr, size_t rr_len)
|
||
|
|
{
|
||
|
|
struct local_zone* z;
|
||
|
|
enum localzone_type tp = local_zone_always_transparent;
|
||
|
|
int dnamelabs = dname_count_labels(dname);
|
||
|
|
char* rrstr;
|
||
|
|
|
||
|
|
if(a == RPZ_NXDOMAIN_ACTION)
|
||
|
|
tp = local_zone_always_nxdomain;
|
||
|
|
else if(a == RPZ_NODATA_ACTION)
|
||
|
|
tp = local_zone_always_nodata;
|
||
|
|
else if(a == RPZ_DROP_ACTION)
|
||
|
|
tp = local_zone_deny;
|
||
|
|
else if(a == RPZ_PASSTHRU_ACTION)
|
||
|
|
tp = local_zone_always_transparent;
|
||
|
|
else if(a == RPZ_LOCAL_DATA_ACTION)
|
||
|
|
tp = local_zone_redirect;
|
||
|
|
else {
|
||
|
|
verbose(VERB_ALGO, "RPZ: skipping unusupported action: %s",
|
||
|
|
rpz_action_to_string(a));
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
lock_rw_wrlock(&r->local_zones->lock);
|
||
|
|
/* exact match */
|
||
|
|
z = local_zones_find(r->local_zones, dname, dnamelen, dnamelabs,
|
||
|
|
LDNS_RR_CLASS_IN);
|
||
|
|
if(z && a != RPZ_LOCAL_DATA_ACTION) {
|
||
|
|
rrstr = sldns_wire2str_rr(rr, rr_len);
|
||
|
|
verbose(VERB_ALGO, "RPZ: skipping duplicate record: '%s'",
|
||
|
|
rrstr);
|
||
|
|
free(rrstr);
|
||
|
|
lock_rw_unlock(&r->local_zones->lock);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
if(!z) {
|
||
|
|
z = local_zones_add_zone(r->local_zones, dname, dnamelen,
|
||
|
|
dnamelabs, rrclass, tp);
|
||
|
|
}
|
||
|
|
if(!z) {
|
||
|
|
log_warn("RPZ create failed");
|
||
|
|
lock_rw_unlock(&r->local_zones->lock);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
if(a == RPZ_LOCAL_DATA_ACTION) {
|
||
|
|
/* insert data. TODO synth wildcard cname target on
|
||
|
|
* lookup */
|
||
|
|
rrstr = sldns_wire2str_rr(rr, rr_len);
|
||
|
|
local_zone_enter_rr(z, dname, dnamelen, dnamelabs,
|
||
|
|
rrtype, rrclass, ttl, rdata, rdata_len, rrstr);
|
||
|
|
free(rrstr);
|
||
|
|
}
|
||
|
|
lock_rw_unlock(&r->local_zones->lock);
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
void
|
||
|
|
rpz_insert_rr(struct rpz* r, size_t aznamelen, uint8_t* dname,
|
||
|
|
size_t dnamelen, uint16_t rr_type, uint16_t rr_class, uint32_t rr_ttl,
|
||
|
|
uint8_t* rdatawl, size_t rdatalen, uint8_t* rr, size_t rr_len)
|
||
|
|
{
|
||
|
|
size_t policydnamelen;
|
||
|
|
/* name is free'd in local_zone delete */
|
||
|
|
uint8_t* policydname = calloc(1, LDNS_MAX_DOMAINLEN + 1);
|
||
|
|
enum rpz_trigger t;
|
||
|
|
enum rpz_action a;
|
||
|
|
|
||
|
|
a = rpz_rr_to_action(rr_type, rdatawl, rdatalen);
|
||
|
|
if(!(policydnamelen = strip_dname_origin(dname, dnamelen, aznamelen,
|
||
|
|
policydname))) {
|
||
|
|
free(policydname);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
t = rpz_dname_to_trigger(policydname);
|
||
|
|
if(t == RPZ_QNAME_TRIGGER) {
|
||
|
|
rpz_insert_qname_trigger(r, policydname, policydnamelen,
|
||
|
|
a, rr_type, rr_class, rr_ttl, rdatawl, rdatalen, rr,
|
||
|
|
rr_len);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
free(policydname);
|
||
|
|
verbose(VERB_ALGO, "RPZ: skipping unusupported trigger: %s",
|
||
|
|
rpz_trigger_to_string(t));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void
|
||
|
|
rpz_remove_rr(struct rpz* r, size_t aznamelen, uint8_t* dname,
|
||
|
|
size_t dnamelen, uint16_t rr_type, uint16_t rr_class, uint8_t* rdatawl,
|
||
|
|
size_t rdatalen, uint8_t* rr, size_t rr_len)
|
||
|
|
{
|
||
|
|
/* TODO: remove RR, used for IXFR */
|
||
|
|
}
|
||
|
|
|
||
|
|
struct local_zone*
|
||
|
|
rpz_find_zone(struct rpz* r, struct query_info* qinfo)
|
||
|
|
{
|
||
|
|
uint8_t* ce;
|
||
|
|
size_t ce_len, ce_labs;
|
||
|
|
uint8_t wc[LDNS_MAX_DOMAINLEN];
|
||
|
|
int exact;
|
||
|
|
struct local_zone* z = NULL;
|
||
|
|
lock_rw_rdlock(&r->local_zones->lock);
|
||
|
|
z = local_zones_find_le(r->local_zones, qinfo->qname,
|
||
|
|
qinfo->qname_len, dname_count_labels(qinfo->qname),
|
||
|
|
LDNS_RR_CLASS_IN, &exact);
|
||
|
|
if(!z) {
|
||
|
|
lock_rw_unlock(&r->local_zones->lock);
|
||
|
|
return NULL;
|
||
|
|
}
|
||
|
|
lock_rw_unlock(&r->local_zones->lock);
|
||
|
|
|
||
|
|
if(exact)
|
||
|
|
return z;
|
||
|
|
|
||
|
|
/* No exact match found, lookup wildcard. closest encloser must
|
||
|
|
* be the shared parent between the qname and the best local
|
||
|
|
* zone match, append '*' to that and do another lookup. */
|
||
|
|
|
||
|
|
ce = dname_get_shared_topdomain(z->name, qinfo->qname);
|
||
|
|
if(!ce /* should not happen */ || !*ce /* root */) {
|
||
|
|
lock_rw_unlock(&z->lock);
|
||
|
|
return NULL;
|
||
|
|
}
|
||
|
|
ce_labs = dname_count_size_labels(ce, &ce_len);
|
||
|
|
if(ce_len+2 > sizeof(wc)) {
|
||
|
|
lock_rw_unlock(&z->lock);
|
||
|
|
return NULL;
|
||
|
|
}
|
||
|
|
wc[0] = 1; /* length of wildcard label */
|
||
|
|
wc[1] = (uint8_t)'*'; /* wildcard label */
|
||
|
|
memmove(wc+2, ce, ce_len);
|
||
|
|
lock_rw_unlock(&z->lock);
|
||
|
|
|
||
|
|
lock_rw_rdlock(&r->local_zones->lock);
|
||
|
|
z = local_zones_find_le(r->local_zones, wc,
|
||
|
|
ce_len+2, ce_labs+1, qinfo->qclass, &exact);
|
||
|
|
if(!z || !exact) {
|
||
|
|
lock_rw_unlock(&r->local_zones->lock);
|
||
|
|
return NULL;
|
||
|
|
}
|
||
|
|
lock_rw_rdlock(&z->lock);
|
||
|
|
lock_rw_unlock(&r->local_zones->lock);
|
||
|
|
return z;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** print log information for an applied RPZ policy. Based on local-zone's
|
||
|
|
* lz_inform_print().
|
||
|
|
*/
|
||
|
|
static void
|
||
|
|
rpz_inform_print(struct local_zone* z, struct query_info* qinfo,
|
||
|
|
struct comm_reply* repinfo)
|
||
|
|
{
|
||
|
|
char ip[128], txt[512];
|
||
|
|
char zname[LDNS_MAX_DOMAINLEN+1];
|
||
|
|
uint16_t port = ntohs(((struct sockaddr_in*)&repinfo->addr)->sin_port);
|
||
|
|
dname_str(z->name, zname);
|
||
|
|
addr_to_str(&repinfo->addr, repinfo->addrlen, ip, sizeof(ip));
|
||
|
|
snprintf(txt, sizeof(txt), "RPZ applied %s %s %s@%u", zname,
|
||
|
|
local_zone_type2str(z->type), ip, (unsigned)port);
|
||
|
|
log_nametypeclass(0, txt, qinfo->qname, qinfo->qtype, qinfo->qclass);
|
||
|
|
}
|
||
|
|
|
||
|
|
int
|
||
|
|
rpz_apply_qname_trigger(struct auth_zones* az, struct module_env* env,
|
||
|
|
struct query_info* qinfo, struct edns_data* edns, sldns_buffer* buf,
|
||
|
|
struct regional* temp, struct comm_reply* repinfo,
|
||
|
|
uint8_t* taglist, size_t taglen)
|
||
|
|
{
|
||
|
|
struct rpz* r;
|
||
|
|
int ret;
|
||
|
|
struct local_zone* z = NULL;
|
||
|
|
struct local_data* ld = NULL;
|
||
|
|
lock_rw_rdlock(&az->rpz_lock);
|
||
|
|
for(r = az->rpz_first; r && !z; r = r->next) {
|
||
|
|
if(!r->taglist || taglist_intersect(r->taglist,
|
||
|
|
r->taglistlen, taglist, taglen))
|
||
|
|
z = rpz_find_zone(r, qinfo);
|
||
|
|
}
|
||
|
|
lock_rw_unlock(&az->rpz_lock);
|
||
|
|
if(!z)
|
||
|
|
return 0;
|
||
|
|
|
||
|
|
if(z->type == local_zone_redirect && local_data_answer(z, env, qinfo,
|
||
|
|
edns, repinfo, buf, temp, dname_count_labels(qinfo->qname),
|
||
|
|
&ld, z->type, -1, NULL, 0, NULL, 0)) {
|
||
|
|
rpz_inform_print(z, qinfo, repinfo);
|
||
|
|
return !qinfo->local_alias;
|
||
|
|
}
|
||
|
|
|
||
|
|
ret = local_zones_zone_answer(z, env, qinfo, edns, repinfo, buf, temp,
|
||
|
|
0 /* no local data used */, z->type);
|
||
|
|
lock_rw_unlock(&z->lock);
|
||
|
|
if(ret)
|
||
|
|
rpz_inform_print(z, qinfo, repinfo);
|
||
|
|
|
||
|
|
return ret;
|
||
|
|
}
|