1# zeroconf.py - zeroconf support for Mercurial
2#
3# Copyright 2005-2007 Olivia Mackall <olivia@selenic.com>
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2 or any later version.
7'''discover and advertise repositories on the local network
8
9The zeroconf extension will advertise :hg:`serve` instances over
10DNS-SD so that they can be discovered using the :hg:`paths` command
11without knowing the server's IP address.
12
13To allow other people to discover your repository using run
14:hg:`serve` in your repository::
15
16  $ cd test
17  $ hg serve
18
19You can discover Zeroconf-enabled repositories by running
20:hg:`paths`::
21
22  $ hg paths
23  zc-test = http://example.com:8000/test
24'''
25from __future__ import absolute_import
26
27import os
28import socket
29import time
30
31from . import Zeroconf
32from mercurial import (
33    dispatch,
34    encoding,
35    extensions,
36    hg,
37    pycompat,
38    rcutil,
39    ui as uimod,
40)
41from mercurial.hgweb import server as servermod
42
43# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
44# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
45# be specifying the version(s) of Mercurial they are tested with, or
46# leave the attribute unspecified.
47testedwith = b'ships-with-hg-core'
48
49# publish
50
51server = None
52localip = None
53
54
55def getip():
56    # finds external-facing interface without sending any packets (Linux)
57    try:
58        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
59        s.connect(('1.0.0.1', 0))
60        ip = s.getsockname()[0]
61        return ip
62    except socket.error:
63        pass
64
65    # Generic method, sometimes gives useless results
66    try:
67        dumbip = socket.gethostbyaddr(socket.gethostname())[2][0]
68        if ':' in dumbip:
69            dumbip = '127.0.0.1'
70        if not dumbip.startswith('127.'):
71            return dumbip
72    except (socket.gaierror, socket.herror):
73        dumbip = '127.0.0.1'
74
75    # works elsewhere, but actually sends a packet
76    try:
77        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
78        s.connect(('1.0.0.1', 1))
79        ip = s.getsockname()[0]
80        return ip
81    except socket.error:
82        pass
83
84    return dumbip
85
86
87def publish(name, desc, path, port):
88    global server, localip
89    if not server:
90        ip = getip()
91        if ip.startswith('127.'):
92            # if we have no internet connection, this can happen.
93            return
94        localip = socket.inet_aton(ip)
95        server = Zeroconf.Zeroconf(ip)
96
97    hostname = socket.gethostname().split('.')[0]
98    host = hostname + ".local"
99    name = "%s-%s" % (hostname, name)
100
101    # advertise to browsers
102    svc = Zeroconf.ServiceInfo(
103        b'_http._tcp.local.',
104        pycompat.bytestr(name + '._http._tcp.local.'),
105        server=host,
106        port=port,
107        properties={b'description': desc, b'path': b"/" + path},
108        address=localip,
109        weight=0,
110        priority=0,
111    )
112    server.registerService(svc)
113
114    # advertise to Mercurial clients
115    svc = Zeroconf.ServiceInfo(
116        b'_hg._tcp.local.',
117        pycompat.bytestr(name + '._hg._tcp.local.'),
118        server=host,
119        port=port,
120        properties={b'description': desc, b'path': b"/" + path},
121        address=localip,
122        weight=0,
123        priority=0,
124    )
125    server.registerService(svc)
126
127
128def zc_create_server(create_server, ui, app):
129    httpd = create_server(ui, app)
130    port = httpd.port
131
132    try:
133        repos = app.repos
134    except AttributeError:
135        # single repo
136        with app._obtainrepo() as repo:
137            name = app.reponame or os.path.basename(repo.root)
138            path = repo.ui.config(b"web", b"prefix", b"").strip(b'/')
139            desc = repo.ui.config(b"web", b"description")
140            if not desc:
141                desc = name
142        publish(name, desc, path, port)
143    else:
144        # webdir
145        prefix = app.ui.config(b"web", b"prefix", b"").strip(b'/') + b'/'
146        for repo, path in repos:
147            u = app.ui.copy()
148            if rcutil.use_repo_hgrc():
149                u.readconfig(os.path.join(path, b'.hg', b'hgrc'))
150            name = os.path.basename(repo)
151            path = (prefix + repo).strip(b'/')
152            desc = u.config(b'web', b'description')
153            if not desc:
154                desc = name
155            publish(name, desc, path, port)
156    return httpd
157
158
159# listen
160
161
162class listener(object):
163    def __init__(self):
164        self.found = {}
165
166    def removeService(self, server, type, name):
167        if repr(name) in self.found:
168            del self.found[repr(name)]
169
170    def addService(self, server, type, name):
171        self.found[repr(name)] = server.getServiceInfo(type, name)
172
173
174def getzcpaths():
175    ip = getip()
176    if ip.startswith('127.'):
177        return
178    server = Zeroconf.Zeroconf(ip)
179    l = listener()
180    Zeroconf.ServiceBrowser(server, b"_hg._tcp.local.", l)
181    time.sleep(1)
182    server.close()
183    for value in l.found.values():
184        name = value.name[: value.name.index(b'.')]
185        url = "http://%s:%s%s" % (
186            socket.inet_ntoa(value.address),
187            value.port,
188            value.properties.get("path", "/"),
189        )
190        yield b"zc-" + name, pycompat.bytestr(url)
191
192
193def config(orig, self, section, key, *args, **kwargs):
194    if section == b"paths" and key.startswith(b"zc-"):
195        for name, path in getzcpaths():
196            if name == key:
197                return path
198    return orig(self, section, key, *args, **kwargs)
199
200
201def configitems(orig, self, section, *args, **kwargs):
202    repos = orig(self, section, *args, **kwargs)
203    if section == b"paths":
204        repos += getzcpaths()
205    return repos
206
207
208def configsuboptions(orig, self, section, name, *args, **kwargs):
209    opt, sub = orig(self, section, name, *args, **kwargs)
210    if section == b"paths" and name.startswith(b"zc-"):
211        # We have to find the URL in the zeroconf paths.  We can't cons up any
212        # suboptions, so we use any that we found in the original config.
213        for zcname, zcurl in getzcpaths():
214            if zcname == name:
215                return zcurl, sub
216    return opt, sub
217
218
219def defaultdest(orig, source):
220    for name, path in getzcpaths():
221        if path == source:
222            return name.encode(encoding.encoding)
223    return orig(source)
224
225
226def cleanupafterdispatch(orig, ui, options, cmd, cmdfunc):
227    try:
228        return orig(ui, options, cmd, cmdfunc)
229    finally:
230        # we need to call close() on the server to notify() the various
231        # threading Conditions and allow the background threads to exit
232        global server
233        if server:
234            server.close()
235
236
237extensions.wrapfunction(dispatch, b'_runcommand', cleanupafterdispatch)
238
239extensions.wrapfunction(uimod.ui, b'config', config)
240extensions.wrapfunction(uimod.ui, b'configitems', configitems)
241extensions.wrapfunction(uimod.ui, b'configsuboptions', configsuboptions)
242extensions.wrapfunction(hg, b'defaultdest', defaultdest)
243extensions.wrapfunction(servermod, b'create_server', zc_create_server)
244