Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
S
sshuttle
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
pub
sshuttle
Commits
c3016f2d
Unverified
Commit
c3016f2d
authored
Oct 19, 2020
by
Brian May
Committed by
GitHub
Oct 19, 2020
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #541 from skuhl/use-all-ips
When subnets and excludes are specified with hostnames, use all IPs.
parents
a266e7a8
b7a29aca
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
212 additions
and
61 deletions
+212
-61
client.py
sshuttle/client.py
+120
-28
cmdline.py
sshuttle/cmdline.py
+10
-2
__init__.py
sshuttle/methods/__init__.py
+2
-1
options.py
sshuttle/options.py
+62
-13
test_options.py
tests/client/test_options.py
+18
-17
No files found.
sshuttle/client.py
View file @
c3016f2d
...
...
@@ -586,31 +586,76 @@ def main(listenip_v6, listenip_v4,
fw
=
FirewallClient
(
method_name
,
sudo_pythonpath
)
# Get family specific subnet lists
# If --dns is used, store the IP addresses that the client
# normally uses for DNS lookups in nslist. The firewall needs to
# redirect packets outgoing to this server to the remote host
# instead.
if
dns
:
nslist
+=
resolvconf_nameservers
()
if
to_nameserver
is
not
None
:
to_nameserver
=
"
%
s@
%
s"
%
tuple
(
to_nameserver
[
1
:])
else
:
# option doesn't make sense if we aren't proxying dns
if
to_nameserver
and
len
(
to_nameserver
)
>
0
:
print
(
"WARNING: --to-ns option is ignored because --dns was not "
"used."
)
to_nameserver
=
None
subnets
=
subnets_include
+
subnets_exclude
# we don't care here
subnets_v6
=
[
i
for
i
in
subnets
if
i
[
0
]
==
socket
.
AF_INET6
]
nslist_v6
=
[
i
for
i
in
nslist
if
i
[
0
]
==
socket
.
AF_INET6
]
subnets_v4
=
[
i
for
i
in
subnets
if
i
[
0
]
==
socket
.
AF_INET
]
# Get family specific subnet lists. Also, the user may not specify
# any subnets if they use --auto-nets. In this case, our subnets
# list will be empty and the forwarded subnets will be determined
# later by the server.
subnets_v4
=
[
i
for
i
in
subnets_include
if
i
[
0
]
==
socket
.
AF_INET
]
subnets_v6
=
[
i
for
i
in
subnets_include
if
i
[
0
]
==
socket
.
AF_INET6
]
nslist_v4
=
[
i
for
i
in
nslist
if
i
[
0
]
==
socket
.
AF_INET
]
nslist_v6
=
[
i
for
i
in
nslist
if
i
[
0
]
==
socket
.
AF_INET6
]
#
Check features available
#
Get available features from the firewall method
avail
=
fw
.
method
.
get_supported_features
()
# A feature is "required" if the user supplies us parameters which
# implies that the feature is needed.
required
=
Features
()
# Select the default addresses to bind to / listen to.
# Assume IPv4 is always available and should always be enabled. If
# a method doesn't provide IPv4 support or if we wish to run
# ipv6-only, changes to this code are required.
assert
avail
.
ipv4
required
.
ipv4
=
True
# listenip_v4 contains user specified value or it is set to "auto".
if
listenip_v4
==
"auto"
:
listenip_v4
=
(
'127.0.0.1'
,
0
)
# listenip_v6 is...
# None when IPv6 is disabled.
# "auto" when listen address is unspecified.
# The user specified address if provided by user
if
listenip_v6
is
None
:
debug1
(
"IPv6 disabled by --disable-ipv6
\n
"
)
if
listenip_v6
==
"auto"
:
if
avail
.
ipv6
:
debug1
(
"IPv6 enabled: Using default IPv6 listen address ::1
\n
"
)
listenip_v6
=
(
'::1'
,
0
)
else
:
debug1
(
"IPv6 disabled since it isn't supported by method "
"
%
s.
\n
"
%
fw
.
method
.
name
)
listenip_v6
=
None
# Make final decision about enabling IPv6:
required
.
ipv6
=
False
if
listenip_v6
:
required
.
ipv6
=
True
# If we get here, it is possible that listenip_v6 was user
# specified but not supported by the current method.
if
required
.
ipv6
and
not
avail
.
ipv6
:
raise
Fatal
(
"An IPv6 listen address was supplied, but IPv6 is "
"disabled at your request or is unsupported by the
%
s "
"method."
%
fw
.
method
.
name
)
if
user
is
not
None
:
if
getpwnam
is
None
:
raise
Fatal
(
"Routing by user not available on this system."
)
...
...
@@ -618,38 +663,66 @@ def main(listenip_v6, listenip_v4,
user
=
getpwnam
(
user
)
.
pw_uid
except
KeyError
:
raise
Fatal
(
"User
%
s does not exist."
%
user
)
required
.
user
=
False
if
user
is
None
else
True
if
fw
.
method
.
name
!=
'nat'
:
required
.
ipv6
=
len
(
subnets_v6
)
>
0
or
listenip_v6
is
not
None
required
.
ipv4
=
len
(
subnets_v4
)
>
0
or
listenip_v4
is
not
None
else
:
required
.
ipv6
=
None
required
.
ipv4
=
None
if
not
required
.
ipv6
and
len
(
subnets_v6
)
>
0
:
print
(
"WARNING: IPv6 subnets were ignored because IPv6 is disabled "
"in sshuttle."
)
subnets_v6
=
[]
subnets_include
=
subnets_v4
required
.
udp
=
avail
.
udp
required
.
udp
=
avail
.
udp
# automatically enable UDP if it is available
required
.
dns
=
len
(
nslist
)
>
0
required
.
user
=
False
if
user
is
None
else
True
# if IPv6 not supported, ignore IPv6 DNS servers
if
not
required
.
ipv6
:
# Remove DNS servers using IPv6.
if
required
.
dns
:
if
not
required
.
ipv6
and
len
(
nslist_v6
)
>
0
:
print
(
"WARNING: Your system is configured to use an IPv6 DNS "
"server but sshuttle is not using IPv6. Therefore DNS "
"traffic your system sends to the IPv6 DNS server won't "
"be redirected via sshuttle to the remote machine."
)
nslist_v6
=
[]
nslist
=
nslist_v4
fw
.
method
.
assert_features
(
required
)
if
len
(
nslist
)
==
0
:
raise
Fatal
(
"Can't redirect DNS traffic since IPv6 is not "
"enabled in sshuttle and all of the system DNS "
"servers are IPv6."
)
if
required
.
ipv6
and
listenip_v6
is
None
:
raise
Fatal
(
"IPv6 required but not listening."
)
# If we aren't using IPv6, we can safely ignore excluded IPv6 subnets.
if
not
required
.
ipv6
:
orig_len
=
len
(
subnets_exclude
)
subnets_exclude
=
[
i
for
i
in
subnets_exclude
if
i
[
0
]
==
socket
.
AF_INET
]
if
len
(
subnets_exclude
)
<
orig_len
:
print
(
"WARNING: Ignoring one or more excluded IPv6 subnets "
"because IPv6 is not enabled."
)
# This will print error messages if we required a feature that
# isn't available by the current method.
fw
.
method
.
assert_features
(
required
)
# display features enabled
debug1
(
"IPv6 enabled:
%
r
\n
"
%
required
.
ipv6
)
debug1
(
"UDP enabled:
%
r
\n
"
%
required
.
udp
)
debug1
(
"DNS enabled:
%
r
\n
"
%
required
.
dns
)
debug1
(
"User enabled:
%
r
\n
"
%
required
.
user
)
def
feature_status
(
label
,
enabled
,
available
):
msg
=
label
+
": "
if
enabled
:
msg
+=
"on"
else
:
msg
+=
"off "
if
available
:
msg
+=
"(available)"
else
:
msg
+=
"(not available with
%
s method)"
%
fw
.
method
.
name
debug1
(
msg
+
"
\n
"
)
# bind to required ports
if
listenip_v4
==
"auto"
:
listenip_v4
=
(
'127.0.0.1'
,
0
)
debug1
(
"Method:
%
s
\n
"
%
fw
.
method
.
name
)
feature_status
(
"IPv4"
,
required
.
ipv4
,
avail
.
ipv4
)
feature_status
(
"IPv6"
,
required
.
ipv6
,
avail
.
ipv6
)
feature_status
(
"UDP "
,
required
.
udp
,
avail
.
udp
)
feature_status
(
"DNS "
,
required
.
dns
,
avail
.
dns
)
feature_status
(
"User"
,
required
.
user
,
avail
.
user
)
# Exclude traffic destined to our listen addresses.
if
required
.
ipv4
and
\
not
any
(
listenip_v4
[
0
]
==
sex
[
1
]
for
sex
in
subnets_v4
):
subnets_exclude
.
append
((
socket
.
AF_INET
,
listenip_v4
[
0
],
32
,
0
,
0
))
...
...
@@ -658,6 +731,25 @@ def main(listenip_v6, listenip_v4,
not
any
(
listenip_v6
[
0
]
==
sex
[
1
]
for
sex
in
subnets_v6
):
subnets_exclude
.
append
((
socket
.
AF_INET6
,
listenip_v6
[
0
],
128
,
0
,
0
))
# We don't print the IP+port of where we are listening here
# because we do that below when we have identified the ports to
# listen on.
debug1
(
"Subnets to forward through remote host (type, IP, cidr mask "
"width, startPort, endPort):
\n
"
)
for
i
in
subnets_include
:
print
(
" "
+
str
(
i
))
if
auto_nets
:
debug1
(
"NOTE: Additional subnets to forward may be added below by "
"--auto-nets.
\n
"
)
debug1
(
"Subnets to exclude from forwarding:
\n
"
)
for
i
in
subnets_exclude
:
print
(
" "
+
str
(
i
))
if
required
.
dns
:
debug1
(
"DNS requests normally directed at these servers will be "
"redirected to remote:
\n
"
)
for
i
in
nslist
:
print
(
" "
+
str
(
i
))
if
listenip_v6
and
listenip_v6
[
1
]
and
listenip_v4
and
listenip_v4
[
1
]:
# if both ports given, no need to search for a spare port
ports
=
[
0
,
]
...
...
sshuttle/cmdline.py
View file @
c3016f2d
...
...
@@ -47,8 +47,16 @@ def main():
elif
opt
.
hostwatch
:
return
hostwatch
.
hw_main
(
opt
.
subnets
,
opt
.
auto_hosts
)
else
:
includes
=
opt
.
subnets
+
opt
.
subnets_file
excludes
=
opt
.
exclude
# parse_subnetports() is used to create a list of includes
# and excludes. It is called once for each parameter and
# returns a list of one or more items for each subnet (it
# can return more than one item when a hostname in the
# parameter resolves to multiple IP addresses. Here, we
# flatten these lists.
includes
=
[
item
for
sublist
in
opt
.
subnets
+
opt
.
subnets_file
for
item
in
sublist
]
excludes
=
[
item
for
sublist
in
opt
.
exclude
for
item
in
sublist
]
if
not
includes
and
not
opt
.
auto_nets
:
parser
.
error
(
'at least one subnet, subnet file, '
'or -N expected'
)
...
...
sshuttle/methods/__init__.py
View file @
c3016f2d
...
...
@@ -38,6 +38,7 @@ class BaseMethod(object):
@
staticmethod
def
get_supported_features
():
result
=
Features
()
result
.
ipv4
=
True
result
.
ipv6
=
False
result
.
udp
=
False
result
.
dns
=
True
...
...
@@ -68,7 +69,7 @@ class BaseMethod(object):
def
assert_features
(
self
,
features
):
avail
=
self
.
get_supported_features
()
for
key
in
[
"udp"
,
"dns"
,
"ipv6"
,
"user"
]:
for
key
in
[
"udp"
,
"dns"
,
"ipv6"
,
"
ipv4"
,
"
user"
]:
if
getattr
(
features
,
key
)
and
not
getattr
(
avail
,
key
):
raise
Fatal
(
"Feature
%
s not supported with method
%
s.
\n
"
%
...
...
sshuttle/options.py
View file @
c3016f2d
...
...
@@ -28,7 +28,14 @@ def parse_subnetport_file(s):
# 1.2.3.4/5:678, 1.2.3.4:567, 1.2.3.4/16 or just 1.2.3.4
# [1:2::3/64]:456, [1:2::3]:456, 1:2::3/64 or just 1:2::3
# example.com:123 or just example.com
#
# In addition, the port number can be specified as a range:
# 1.2.3.4:8000-8080.
#
# Can return multiple matches if the domain name used in the request
# has multiple IP addresses.
def
parse_subnetport
(
s
):
if
s
.
count
(
':'
)
>
1
:
rx
=
r'(?:\[?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$'
else
:
...
...
@@ -38,19 +45,56 @@ def parse_subnetport(s):
if
not
m
:
raise
Fatal
(
'
%
r is not a valid address/mask:port format'
%
s
)
addr
,
width
,
fport
,
lport
=
m
.
groups
()
# Ports range from fport to lport. If only one port is specified,
# fport is defined and lport is None.
#
# cidr is the mask defined with the slash notation
host
,
cidr
,
fport
,
lport
=
m
.
groups
()
try
:
addrinfo
=
socket
.
getaddrinfo
(
addr
,
0
,
0
,
socket
.
SOCK_STREAM
)
addrinfo
=
socket
.
getaddrinfo
(
host
,
0
,
0
,
socket
.
SOCK_STREAM
)
except
socket
.
gaierror
:
raise
Fatal
(
'Unable to resolve address:
%
s'
%
addr
)
raise
Fatal
(
'Unable to resolve address:
%
s'
%
host
)
family
,
_
,
_
,
_
,
addr
=
min
(
addrinfo
)
max_width
=
32
if
family
==
socket
.
AF_INET
else
128
width
=
int
(
width
or
max_width
)
if
not
0
<=
width
<=
max_width
:
raise
Fatal
(
'width
%
d is not between 0 and
%
d'
%
(
width
,
max_width
))
# If the address is a domain with multiple IPs and a mask is also
# provided, proceed cautiously:
if
cidr
is
not
None
:
addr_v6
=
[
a
for
a
in
addrinfo
if
a
[
0
]
==
socket
.
AF_INET6
]
addr_v4
=
[
a
for
a
in
addrinfo
if
a
[
0
]
==
socket
.
AF_INET
]
# Refuse to proceed if IPv4 and IPv6 addresses are present:
if
len
(
addr_v6
)
>
0
and
len
(
addr_v4
)
>
0
:
raise
Fatal
(
"
%
s has IPv4 and IPv6 addresses, so the mask "
"of /
%
s is not supported. Specify the IP "
"addresses directly if you wish to specify "
"a mask."
%
(
host
,
cidr
))
# Warn if a domain has multiple IPs of the same type (IPv4 vs
# IPv6) and the mask is applied to all of the IPs.
if
len
(
addr_v4
)
>
1
or
len
(
addr_v6
)
>
1
:
print
(
"WARNING:
%
s has multiple IP addresses. The "
"mask of /
%
s is applied to all of the addresses."
%
(
host
,
cidr
))
rv
=
[]
for
a
in
addrinfo
:
family
,
_
,
_
,
_
,
addr
=
a
return
(
family
,
addr
[
0
],
width
,
int
(
fport
or
0
),
int
(
lport
or
fport
or
0
))
# Largest possible slash value we can use with this IP:
max_cidr
=
32
if
family
==
socket
.
AF_INET
else
128
if
cidr
is
None
:
# if no mask, use largest mask
cidr_to_use
=
max_cidr
else
:
# verify user-provided mask is appropriate
cidr_to_use
=
int
(
cidr
)
if
not
0
<=
cidr_to_use
<=
max_cidr
:
raise
Fatal
(
'Slash in CIDR notation (/
%
d) is '
'not between 0 and
%
d'
%
(
cidr_to_use
,
max_cidr
))
rv
.
append
((
family
,
addr
[
0
],
cidr_to_use
,
int
(
fport
or
0
),
int
(
lport
or
fport
or
0
)))
return
rv
# 1.2.3.4:567 or just 1.2.3.4 or just 567
...
...
@@ -69,16 +113,21 @@ def parse_ipport(s):
if
not
m
:
raise
Fatal
(
'
%
r is not a valid IP:port format'
%
s
)
ip
,
port
=
m
.
groups
()
ip
=
ip
or
'0.0.0.0'
host
,
port
=
m
.
groups
()
host
=
host
or
'0.0.0.0'
port
=
int
(
port
or
0
)
try
:
addrinfo
=
socket
.
getaddrinfo
(
ip
,
port
,
0
,
socket
.
SOCK_STREAM
)
addrinfo
=
socket
.
getaddrinfo
(
host
,
port
,
0
,
socket
.
SOCK_STREAM
)
except
socket
.
gaierror
:
raise
Fatal
(
'
%
r is not a valid IP:port format'
%
s
)
raise
Fatal
(
'Unable to resolve address:
%
s'
%
host
)
if
len
(
addrinfo
)
>
1
:
print
(
"WARNING: Host
%
s has more than one IP, only using one of them."
%
host
)
family
,
_
,
_
,
_
,
addr
=
min
(
addrinfo
)
# Note: addr contains (ip, port)
return
(
family
,)
+
addr
[:
2
]
...
...
tests/client/test_options.py
View file @
c3016f2d
...
...
@@ -13,7 +13,6 @@ _ip4_reprs = {
'3098282570'
:
'184.172.10.74'
,
'0xb8.0xac.0x0a.0x4a'
:
'184.172.10.74'
,
'0270.0254.0012.0112'
:
'184.172.10.74'
,
'localhost'
:
'127.0.0.1'
}
_ip4_swidths
=
(
1
,
8
,
22
,
27
,
32
)
...
...
@@ -31,7 +30,7 @@ _ip6_swidths = (48, 64, 96, 115, 128)
def
test_parse_subnetport_ip4
():
for
ip_repr
,
ip
in
_ip4_reprs
.
items
():
assert
sshuttle
.
options
.
parse_subnetport
(
ip_repr
)
\
==
(
socket
.
AF_INET
,
ip
,
32
,
0
,
0
)
==
[(
socket
.
AF_INET
,
ip
,
32
,
0
,
0
)]
with
pytest
.
raises
(
Fatal
)
as
excinfo
:
sshuttle
.
options
.
parse_subnetport
(
'10.256.0.0'
)
assert
str
(
excinfo
.
value
)
==
'Unable to resolve address: 10.256.0.0'
...
...
@@ -42,34 +41,35 @@ def test_parse_subnetport_ip4_with_mask():
for
swidth
in
_ip4_swidths
:
assert
sshuttle
.
options
.
parse_subnetport
(
'/'
.
join
((
ip_repr
,
str
(
swidth
)))
)
==
(
socket
.
AF_INET
,
ip
,
swidth
,
0
,
0
)
)
==
[(
socket
.
AF_INET
,
ip
,
swidth
,
0
,
0
)]
assert
sshuttle
.
options
.
parse_subnetport
(
'0/0'
)
\
==
(
socket
.
AF_INET
,
'0.0.0.0'
,
0
,
0
,
0
)
==
[(
socket
.
AF_INET
,
'0.0.0.0'
,
0
,
0
,
0
)]
with
pytest
.
raises
(
Fatal
)
as
excinfo
:
sshuttle
.
options
.
parse_subnetport
(
'10.0.0.0/33'
)
assert
str
(
excinfo
.
value
)
==
'width 33 is not between 0 and 32'
assert
str
(
excinfo
.
value
)
\
==
'Slash in CIDR notation (/33) is not between 0 and 32'
def
test_parse_subnetport_ip4_with_port
():
for
ip_repr
,
ip
in
_ip4_reprs
.
items
():
assert
sshuttle
.
options
.
parse_subnetport
(
':'
.
join
((
ip_repr
,
'80'
)))
\
==
(
socket
.
AF_INET
,
ip
,
32
,
80
,
80
)
==
[(
socket
.
AF_INET
,
ip
,
32
,
80
,
80
)]
assert
sshuttle
.
options
.
parse_subnetport
(
':'
.
join
((
ip_repr
,
'80-90'
)))
\
==
(
socket
.
AF_INET
,
ip
,
32
,
80
,
90
)
==
[(
socket
.
AF_INET
,
ip
,
32
,
80
,
90
)]
def
test_parse_subnetport_ip4_with_mask_and_port
():
for
ip_repr
,
ip
in
_ip4_reprs
.
items
():
assert
sshuttle
.
options
.
parse_subnetport
(
ip_repr
+
'/32:80'
)
\
==
(
socket
.
AF_INET
,
ip
,
32
,
80
,
80
)
==
[(
socket
.
AF_INET
,
ip
,
32
,
80
,
80
)]
assert
sshuttle
.
options
.
parse_subnetport
(
ip_repr
+
'/16:80-90'
)
\
==
(
socket
.
AF_INET
,
ip
,
16
,
80
,
90
)
==
[(
socket
.
AF_INET
,
ip
,
16
,
80
,
90
)]
def
test_parse_subnetport_ip6
():
for
ip_repr
,
ip
in
_ip6_reprs
.
items
():
assert
sshuttle
.
options
.
parse_subnetport
(
ip_repr
)
\
==
(
socket
.
AF_INET6
,
ip
,
128
,
0
,
0
)
==
[(
socket
.
AF_INET6
,
ip
,
128
,
0
,
0
)]
def
test_parse_subnetport_ip6_with_mask
():
...
...
@@ -77,25 +77,26 @@ def test_parse_subnetport_ip6_with_mask():
for
swidth
in
_ip4_swidths
+
_ip6_swidths
:
assert
sshuttle
.
options
.
parse_subnetport
(
'/'
.
join
((
ip_repr
,
str
(
swidth
)))
)
==
(
socket
.
AF_INET6
,
ip
,
swidth
,
0
,
0
)
)
==
[(
socket
.
AF_INET6
,
ip
,
swidth
,
0
,
0
)]
assert
sshuttle
.
options
.
parse_subnetport
(
'::/0'
)
\
==
(
socket
.
AF_INET6
,
'::'
,
0
,
0
,
0
)
==
[(
socket
.
AF_INET6
,
'::'
,
0
,
0
,
0
)]
with
pytest
.
raises
(
Fatal
)
as
excinfo
:
sshuttle
.
options
.
parse_subnetport
(
'fc00::/129'
)
assert
str
(
excinfo
.
value
)
==
'width 129 is not between 0 and 128'
assert
str
(
excinfo
.
value
)
\
==
'Slash in CIDR notation (/129) is not between 0 and 128'
def
test_parse_subnetport_ip6_with_port
():
for
ip_repr
,
ip
in
_ip6_reprs
.
items
():
assert
sshuttle
.
options
.
parse_subnetport
(
'['
+
ip_repr
+
']:80'
)
\
==
(
socket
.
AF_INET6
,
ip
,
128
,
80
,
80
)
==
[(
socket
.
AF_INET6
,
ip
,
128
,
80
,
80
)]
assert
sshuttle
.
options
.
parse_subnetport
(
'['
+
ip_repr
+
']:80-90'
)
\
==
(
socket
.
AF_INET6
,
ip
,
128
,
80
,
90
)
==
[(
socket
.
AF_INET6
,
ip
,
128
,
80
,
90
)]
def
test_parse_subnetport_ip6_with_mask_and_port
():
for
ip_repr
,
ip
in
_ip6_reprs
.
items
():
assert
sshuttle
.
options
.
parse_subnetport
(
'['
+
ip_repr
+
'/128]:80'
)
\
==
(
socket
.
AF_INET6
,
ip
,
128
,
80
,
80
)
==
[(
socket
.
AF_INET6
,
ip
,
128
,
80
,
80
)]
assert
sshuttle
.
options
.
parse_subnetport
(
'['
+
ip_repr
+
'/16]:80-90'
)
\
==
(
socket
.
AF_INET6
,
ip
,
16
,
80
,
90
)
==
[(
socket
.
AF_INET6
,
ip
,
16
,
80
,
90
)]
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment