diff --git a/b2g/installer/package-manifest.in b/b2g/installer/package-manifest.in index 3ed7c07ed824..4db37d5fa1e3 100644 --- a/b2g/installer/package-manifest.in +++ b/b2g/installer/package-manifest.in @@ -286,6 +286,9 @@ @BINPATH@/components/services-crypto.xpt #endif @BINPATH@/components/services-crypto-component.xpt +#ifdef MOZ_CAPTIVEDETECT +@BINPATH@/components/captivedetect.xpt +#endif @BINPATH@/components/shellservice.xpt @BINPATH@/components/shistory.xpt @BINPATH@/components/spellchecker.xpt @@ -492,6 +495,10 @@ @BINPATH@/components/HealthReportComponents.manifest @BINPATH@/components/HealthReportService.js #endif +#ifdef MOZ_CAPTIVEDETECT ++@BINPATH@/components/CaptivePortalDetectComponents.manifest ++@BINPATH@/components/captivedetect.js ++#endif @BINPATH@/components/TelemetryPing.js @BINPATH@/components/TelemetryPing.manifest @BINPATH@/components/Webapps.js diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in index e6e3ed087c5f..522fe937a196 100644 --- a/browser/installer/package-manifest.in +++ b/browser/installer/package-manifest.in @@ -285,6 +285,9 @@ @BINPATH@/components/saxparser.xpt @BINPATH@/browser/components/sessionstore.xpt @BINPATH@/components/services-crypto-component.xpt +#ifdef MOZ_CAPTIVEDETECT +@BINPATH@/components/captivedetect.xpt +#endif @BINPATH@/browser/components/shellservice.xpt @BINPATH@/components/shistory.xpt @BINPATH@/components/spellchecker.xpt @@ -483,6 +486,10 @@ @BINPATH@/components/SyncComponents.manifest @BINPATH@/components/Weave.js #endif +#ifdef MOZ_CAPTIVEDETECT +@BINPATH@/components/CaptivePortalDetectComponents.manifest +@BINPATH@/components/captivedetect.js +#endif @BINPATH@/components/servicesComponents.manifest @BINPATH@/components/cryptoComponents.manifest @BINPATH@/components/TelemetryPing.js diff --git a/configure.in b/configure.in index 8807100878f1..ede3a758aec7 100644 --- a/configure.in +++ b/configure.in @@ -8378,6 +8378,12 @@ if test -n "$MOZ_SERVICES_SYNC"; then AC_DEFINE(MOZ_SERVICES_SYNC) fi +dnl Build Captive Portal Detector if required +AC_SUBST(MOZ_CAPTIVEDETECT) +if test -n "$MOZ_CAPTIVEDETECT"; then + AC_DEFINE(MOZ_CAPTIVEDETECT) +fi + dnl ======================================================== if test "$MOZ_DEBUG" -o "$NS_TRACE_MALLOC" -o "$MOZ_DMD"; then MOZ_COMPONENTS_VERSION_SCRIPT_LDFLAGS= diff --git a/mobile/android/installer/package-manifest.in b/mobile/android/installer/package-manifest.in index 9dedc82cef90..25614199a396 100644 --- a/mobile/android/installer/package-manifest.in +++ b/mobile/android/installer/package-manifest.in @@ -210,6 +210,9 @@ @BINPATH@/components/saxparser.xpt @BINPATH@/components/sessionstore.xpt @BINPATH@/components/services-crypto-component.xpt +#ifdef MOZ_CAPTIVEDETECT +@BINPATH@/components/captivedetect.xpt +#endif @BINPATH@/components/shellservice.xpt @BINPATH@/components/shistory.xpt @BINPATH@/components/spellchecker.xpt @@ -377,6 +380,11 @@ @BINPATH@/components/HealthReportService.js #endif +#ifdef MOZ_CAPTIVEDETECT +@BINPATH@/components/CaptivePortalDetectComponents.manifest +@BINPATH@/components/captivedetect.js +#endif + ; Modules @BINPATH@/modules/* diff --git a/modules/libpref/src/init/all.js b/modules/libpref/src/init/all.js index f9dccd244915..e017daee95ac 100644 --- a/modules/libpref/src/init/all.js +++ b/modules/libpref/src/init/all.js @@ -4111,3 +4111,9 @@ pref("ui.touch_activation.delay_ms", 100); // nsMemoryInfoDumper can watch a fifo in the temp directory and take various // actions when the fifo is written to. Disable this in general. pref("memory_info_dumper.watch_fifo", false); + +#ifdef MOZ_CAPTIVEDETECT +pref("captivedetect.maxWaitingTime", 5000); +pref("captivedetect.pollingTime", 3000); +pref("captivedetect.maxRetryCount", 5); +#endif \ No newline at end of file diff --git a/toolkit/components/Makefile.in b/toolkit/components/Makefile.in index beec774035ee..1cb59529dacf 100644 --- a/toolkit/components/Makefile.in +++ b/toolkit/components/Makefile.in @@ -85,6 +85,10 @@ ifdef MOZ_URL_CLASSIFIER PARALLEL_DIRS += url-classifier endif +ifdef MOZ_CAPTIVEDETECT +PARALLEL_DIRS += captivedetect +endif + DIRS += \ build \ $(NULL) diff --git a/toolkit/components/captivedetect/CaptivePortalDetectComponents.manifest b/toolkit/components/captivedetect/CaptivePortalDetectComponents.manifest new file mode 100644 index 000000000000..69c505edac20 --- /dev/null +++ b/toolkit/components/captivedetect/CaptivePortalDetectComponents.manifest @@ -0,0 +1,2 @@ +component {d9cd00ba-aa4d-47b1-8792-b1fe0cd35060} captivedetect.js +contract @mozilla.org/services/captive-detector;1 {d9cd00ba-aa4d-47b1-8792-b1fe0cd35060} diff --git a/toolkit/components/captivedetect/Makefile.in b/toolkit/components/captivedetect/Makefile.in new file mode 100644 index 000000000000..8190f00d1881 --- /dev/null +++ b/toolkit/components/captivedetect/Makefile.in @@ -0,0 +1,25 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +DEPTH = @DEPTH@ +topsrcdir = @top_srcdir@ +srcdir = @srcdir@ +VPATH = @srcdir@ +relativesrcdir = @relativesrcdir@ + +include $(DEPTH)/config/autoconf.mk + +MODULE = captivedetect +XPIDL_MODULE = captivedetect + +XPIDLSRCS = \ + nsICaptivePortalDetector.idl \ + $(NULL) + +EXTRA_COMPONENTS = \ + CaptivePortalDetectComponents.manifest \ + captivedetect.js \ + $(NULL) + +include $(topsrcdir)/config/rules.mk diff --git a/toolkit/components/captivedetect/captivedetect.js b/toolkit/components/captivedetect/captivedetect.js new file mode 100644 index 000000000000..f7537e7ee217 --- /dev/null +++ b/toolkit/components/captivedetect/captivedetect.js @@ -0,0 +1,439 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +Cu.import('resource://gre/modules/Services.jsm'); + +const DEBUG = false; // set to true to show debug messages + +const kCAPTIVEPORTALDETECTOR_CONTRACTID = '@mozilla.org/services/captive-detector;1'; +const kCAPTIVEPORTALDETECTOR_CID = Components.ID('{d9cd00ba-aa4d-47b1-8792-b1fe0cd35060}'); + +const kOpenCaptivePortalLoginEvent = 'captive-portal-login'; +const kAbortCaptivePortalLoginEvent = 'captive-portal-login-abort'; + +function URLFetcher(url, timeout) { + let self = this; + let xhr = Cc['@mozilla.org/xmlextras/xmlhttprequest;1'] + .createInstance(Ci.nsIXMLHttpRequest); + xhr.open('GET', url, true); + // Prevent the request from reading from the cache. + xhr.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + xhr.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + xhr.setRequestHeader("Pragma", "no-cache"); + + xhr.timeout = timeout; + xhr.ontimeout = function () { self.ontimeout(); }; + xhr.onerror = function () { self.onerror(); }; + xhr.onreadystatechange = function(oEvent) { + if (xhr.readyState === 4) { + if (self._isAborted) { + return; + } + if (xhr.status === 200) { + self.onsuccess(xhr.responseText); + } else { + self.onredirectorerror(xhr.status); + } + } + }; + xhr.send(); + this._xhr = xhr; +} + +URLFetcher.prototype = { + _isAborted: false, + ontimeout: function() {}, + onerror: function() {}, + abort: function() { + if (!this._isAborted) { + this._isAborted = true; + this._xhr.abort(); + } + }, +} + +function LoginObserver(captivePortalDetector) { + const LOGIN_OBSERVER_STATE_DETACHED = 0; /* Should not monitor network activity since no ongoing login procedure */ + const LOGIN_OBSERVER_STATE_IDLE = 1; /* No network activity currently, waiting for a longer enough idle period */ + const LOGIN_OBSERVER_STATE_BURST = 2; /* Network activity is detected, probably caused by a login procedure */ + const LOGIN_OBSERVER_STATE_VERIFY_NEEDED = 3; /* Verifing network accessiblity is required after a long enough idle */ + const LOGIN_OBSERVER_STATE_VERIFYING = 4; /* LoginObserver is probing if public network is available */ + + let state = LOGIN_OBSERVER_STATE_DETACHED; + + let timer = Cc['@mozilla.org/timer;1'].createInstance(Ci.nsITimer); + let activityDistributor = Cc['@mozilla.org/network/http-activity-distributor;1'] + .getService(Ci.nsIHttpActivityDistributor); + let urlFetcher = null; + + let pageCheckingDone = function pageCheckingDone() { + if (state === LOGIN_OBSERVER_STATE_VERIFYING) { + urlFetcher = null; + // Finish polling the canonical site, switch back to idle state and + // waiting for next burst + state = LOGIN_OBSERVER_STATE_IDLE; + timer.initWithCallback(observer, + captivePortalDetector._pollingTime, + timer.TYPE_ONE_SHOT); + } + }; + + let checkPageContent = function checkPageContent() { + debug("checking if public network is available after the login procedure"); + + urlFetcher = new URLFetcher(captivePortalDetector._canonicalSiteURL, + captivePortalDetector._maxWaitingTime); + urlFetcher.ontimeout = pageCheckingDone; + urlFetcher.onerror = pageCheckingDone; + urlFetcher.onsuccess = function (content) { + if (captivePortalDetector.validateContent(content)) { + urlFetcher = null; + captivePortalDetector.executeCallback(true); + } else { + pageCheckingDone(); + } + }; + urlFetcher.onredirectorerror = pageCheckingDone; + }; + + // Public interface of LoginObserver + let observer = { + QueryInterface: XPCOMUtils.generateQI([Ci.nsIHttpActivityOberver, + Ci.nsITimerCallback]), + + attach: function attach() { + if (state === LOGIN_OBSERVER_STATE_DETACHED) { + activityDistributor.addObserver(this); + state = LOGIN_OBSERVER_STATE_IDLE; + timer.initWithCallback(this, + captivePortalDetector._pollingTime, + timer.TYPE_ONE_SHOT); + debug('attach HttpObserver for login activity'); + } + }, + + detach: function detach() { + if (state !== LOGIN_OBSERVER_STATE_DETACHED) { + if (urlFetcher) { + urlFetcher.abort(); + urlFetcher = null; + } + activityDistributor.removeObserver(this); + timer.cancel(); + state = LOGIN_OBSERVER_STATE_DETACHED; + debug('detach HttpObserver for login activity'); + } + }, + + /* + * Treat all HTTP transactions as captive portal login activities. + */ + observeActivity: function observeActivity(aHttpChannel, aActivityType, + aActivitySubtype, aTimestamp, + aExtraSizeData, aExtraStringData) { + if (aActivityType === Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION + && aActivitySubtype === Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE) { + switch (state) { + case LOGIN_OBSERVER_STATE_IDLE: + case LOGIN_OBSERVER_STATE_VERIFY_NEEDED: + state = LOGIN_OBSERVER_STATE_BURST; + break; + default: + break; + } + } + }, + + /* + * Check if login activity is finished according to HTTP burst. + */ + notify : function notify() { + switch(state) { + case LOGIN_OBSERVER_STATE_BURST: + // Wait while network stays idle for a short period + state = LOGIN_OBSERVER_STATE_VERIFY_NEEDED; + // Fall though to start polling timer + case LOGIN_OBSERVER_STATE_IDLE: + timer.initWithCallback(this, + captivePortalDetector._pollingTime, + timer.TYPE_ONE_SHOT); + break; + case LOGIN_OBSERVER_STATE_VERIFY_NEEDED: + // Polling the canonical website since network stays idle for a while + state = LOGIN_OBSERVER_STATE_VERIFYING; + checkPageContent(); + break; + + default: + break; + } + }, + }; + + return observer; +} + +function CaptivePortalDetector() { + // Load preference + this._canonicalSiteURL = null; + this._canonicalSiteExpectedContent = null; + + try { + this._canonicalSiteURL = + Services.prefs.getCharPref('captivedetect.canonicalURL'); + this._canonicalSiteExpectedContent = + Services.prefs.getCharPref('captivedetect.canonicalContent'); + } catch(e) { + debug('canonicalURL or canonicalContent not set.') + } + + this._maxWaitingTime = + Services.prefs.getIntPref('captivedetect.maxWaitingTime'); + this._pollingTime = + Services.prefs.getIntPref('captivedetect.pollingTime'); + this._maxRetryCount = + Services.prefs.getIntPref('captivedetect.maxRetryCount'); + debug('Load Prefs {site=' + this._canonicalSiteURL + ',content=' + + this._canonicalSiteExpectedContent + ',time=' + this._maxWaitingTime + + "max-retry=" + this._maxRetryCount + '}'); + + // Create HttpObserver for monitoring the login procedure + this._loginObserver = LoginObserver(this); + + this._nextRequestId = 0; + this._runningRequest = null; + this._requestQueue = []; // Maintain a progress table, store callbacks and the ongoing XHR + this._interfaceNames = {}; // Maintain names of the requested network interfaces + + debug('CaptiveProtalDetector initiated, waitng for network connection established'); +} + +CaptivePortalDetector.prototype = { + classID: kCAPTIVEPORTALDETECTOR_CID, + classInfo: XPCOMUtils.generateCI({classID: kCAPTIVEPORTALDETECTOR_CID, + contractID: kCAPTIVEPORTALDETECTOR_CONTRACTID, + classDescription: 'Captive Portal Detector', + interfaces: [Ci.nsICaptivePortalDetector]}), + QueryInterface: XPCOMUtils.generateQI([Ci.nsICaptivePortalDetector]), + + // nsICaptivePortalDetector + checkCaptivePortal: function checkCaptivePortal(aInterfaceName, aCallback) { + if (!this._canonicalSiteURL) { + throw Components.Exception('No canonical URL set up.'); + } + + // Prevent multiple requests on a single network interface + if (this._interfaceNames[aInterfaceName]) { + throw Components.Exception('Do not allow multiple request on one interface: ' + aInterface); + } + + let request = {interfaceName: aInterfaceName}; + if (aCallback) { + let callback = aCallback.QueryInterface(Ci.nsICaptivePortalCallback); + request['callback'] = callback; + request['retryCount'] = 0; + } + this._addRequest(request); + }, + + abort: function abort(aInterfaceName) { + debug('abort for ' + aInterfaceName); + this._removeRequest(aInterfaceName); + }, + + finishPreparation: function finishPreparation(aInterfaceName) { + debug('finish preparation phase for interface "' + aInterfaceName + '"'); + if (!this._runningRequest + || this._runningRequest.interfaceName !== aInterfaceName) { + debug('invalid finishPreparation for ' + aInterfaceName); + throw Components.Exception('only first request is allowed to invoke |finishPreparation|'); + return; + } + + this._startDetection(); + }, + + cancelLogin: function cancelLogin(eventId) { + debug('login canceled by user for request "' + eventId + '"'); + // Captive portal login procedure is canceled by user + if (this._runningRequest && this._runningRequest.hasOwnProperty('eventId')) { + let id = this._runningRequest.eventId; + if (eventId === id) { + this.executeCallback(false); + } + } + }, + + _applyDetection: function _applyDetection() { + debug('enter applyDetection('+ this._runningRequest.interfaceName + ')'); + + // Execute network interface preparation + if (this._runningRequest.hasOwnProperty('callback')) { + this._runningRequest.callback.prepare(); + } else { + this._startDetection(); + } + }, + + _startDetection: function _startDetection() { + debug('startDetection {site=' + this._canonicalSiteURL + ',content=' + + this._canonicalSiteExpectedContent + ',time=' + this._maxWaitingTime + '}'); + let self = this; + + let urlFetcher = new URLFetcher(this._canonicalSiteURL, this._maxWaitingTime); + + let requestDone = this.executeCallback.bind(this, true); + urlFetcher.ontimeout = requestDone; + urlFetcher.onerror = requestDone; + urlFetcher.onsuccess = function (content) { + if (self.validateContent(content)) { + requestDone(); + } else { + // Content of the canonical website has been overwrite + self._startLogin(); + } + }; + urlFetcher.onredirectorerror = function (status) { + if (status >= 300 && status <= 399) { + // The canonical website has been redirected to an unknown location + self._startLogin(); + } else if (self._runningRequest.retryCount++ < self._maxRetryCount) { + debug('startDetection-retry: ' + self._runningRequest.retryCount); + self._startDetection(); + } else { + requestDone(); + } + }; + + this._runningRequest['urlFetcher'] = urlFetcher; + }, + + _startLogin: function _startLogin() { + let id = this._allocateRequestId(); + let details = { + type: kOpenCaptivePortalLoginEvent, + id: id, + url: this._canonicalSiteURL, + }; + this._loginObserver.attach(); + this._runningRequest['eventId'] = id; + this._sendEvent(kOpenCaptivePortalLoginEvent, details); + }, + + executeCallback: function executeCallback(success) { + if (this._runningRequest) { + debug('callback executed'); + if (this._runningRequest.hasOwnProperty('callback')) { + this._runningRequest.callback.complete(success); + } + + // Continue the following request + this._runningRequest['complete'] = true; + this._removeRequest(this._runningRequest.interfaceName); + } + }, + + _sendEvent: function _sendEvent(topic, details) { + debug('sendEvent "' + JSON.stringify(details) + '"'); + Services.obs.notifyObservers(this, + topic, + JSON.stringify(details)); + }, + + validateContent: function validateContent(content) { + debug('received content: ' + content); + return (content === this._canonicalSiteExpectedContent); + }, + + _allocateRequestId: function _allocateRequestId() { + let newId = this._nextRequestId++; + return newId.toString(); + }, + + _runNextRequest: function _runNextRequest() { + let nextRequest = this._requestQueue.shift(); + if (nextRequest) { + this._runningRequest = nextRequest; + this._applyDetection(); + } + }, + + _addRequest: function _addRequest(request) { + this._interfaceNames[request.interfaceName] = true; + this._requestQueue.push(request); + if (!this._runningRequest) { + this._runNextRequest(); + } + }, + + _removeRequest: function _removeRequest(aInterfaceName) { + if (!this._interfaceNames[aInterfaceName]) { + return; + } + + delete this._interfaceNames[aInterfaceName]; + + if (this._runningRequest + && this._runningRequest.interfaceName === aInterfaceName) { + this._loginObserver.detach(); + + if (!this._runningRequest.complete) { + // Abort the user login procedure + if (this._runningRequest.hasOwnProperty('eventId')) { + let details = { + type: kAbortCaptivePortalLoginEvent, + id: this._runningRequest.eventId + }; + this._sendEvent(kAbortCaptivePortalLoginEvent, details); + } + + // Abort the ongoing HTTP request + if (this._runningRequest.hasOwnProperty('urlFetcher')) { + this._runningRequest.urlFetcher.abort(); + } + } + + debug('remove running request'); + this._runningRequest = null; + + // Continue next pending reqeust if the ongoing one has been aborted + this._runNextRequest(); + return; + } + + // Check if a pending request has been aborted + for (let i = 0; i < this._requestQueue.length; i++) { + if (this._requestQueue[i].interfaceName == aInterfaceName) { + this._requestQueue.splice(i, 1); + + debug('remove pending request #' + i + ', remaining ' + this._requestQueue.length); + break; + } + } + }, +}; + +let debug; +if (DEBUG) { + debug = function (s) { + dump('-*- CaptivePortalDetector component: ' + s + '\n'); + }; +} else { + debug = function (s) {}; +} + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([CaptivePortalDetector]); diff --git a/toolkit/components/captivedetect/nsICaptivePortalDetector.idl b/toolkit/components/captivedetect/nsICaptivePortalDetector.idl new file mode 100644 index 000000000000..63168545174a --- /dev/null +++ b/toolkit/components/captivedetect/nsICaptivePortalDetector.idl @@ -0,0 +1,53 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(593fdeec-6284-4de8-b416-8e63cbdc695e)] +interface nsICaptivePortalCallback : nsISupports +{ + /** + * Preparation for network interface before captive portal detection started. + */ + void prepare(); + + /** + * Invoke callbacks after captive portal detection finished. + */ + void complete(in bool success); +}; + +[scriptable, uuid(2f827c5a-f551-477f-af09-71adbfbd854a)] +interface nsICaptivePortalDetector : nsISupports +{ + /** + * Perform captive portal detection on specific network interface. + * @param ifname The name of network interface, exception will be thrwon + * if the same interface has unfinished request. + * @param callback Callbacks when detection procedure starts and finishes. + */ + void checkCaptivePortal(in wstring ifname, + in nsICaptivePortalCallback callback); + + /** + * Abort captive portal detection for specific network interface + * due to system failure, callback will not be invoked. + * @param ifname The name of network interface. + */ + void abort(in wstring ifname); + + /** + * Cancel captive portal login procedure by user, callback will be invoked. + * @param eventId Login event id provided in |captive-portal-login| event. + */ + void cancelLogin(in wstring eventId); + + /** + * Notify prepare phase is finished, routing and dns must be ready for sending + * out XMLHttpRequest. this is callback for CaptivePortalDetector API user. + * @param ifname The name of network interface, must be unique. + */ + void finishPreparation(in wstring ifname); +}; diff --git a/toolkit/toolkit-makefiles.sh b/toolkit/toolkit-makefiles.sh index 2095fe810507..9c1f256c17f8 100644 --- a/toolkit/toolkit-makefiles.sh +++ b/toolkit/toolkit-makefiles.sh @@ -459,6 +459,7 @@ MAKEFILES_xulapp=" toolkit/components/apppicker/Makefile toolkit/components/Makefile toolkit/components/build/Makefile + toolkit/components/captivedetect/Makefile toolkit/components/commandlines/Makefile toolkit/components/console/Makefile toolkit/components/contentprefs/Makefile