strongswan/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java

624 lines
16 KiB
Java

/*
* Copyright (C) 2012-2017 Tobias Brunner
* HSR Hochschule fuer Technik Rapperswil
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.logic;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.logic.imc.ImcState;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.ui.VpnProfileControlActivity;
import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.Callable;
import androidx.core.content.ContextCompat;
public class VpnStateService extends Service
{
private final HashSet<VpnStateListener> mListeners = new HashSet<VpnStateListener>();
private final IBinder mBinder = new LocalBinder();
private long mConnectionID = 0;
private Handler mHandler;
private VpnProfile mProfile;
private State mState = State.DISABLED;
private ErrorState mError = ErrorState.NO_ERROR;
private ImcState mImcState = ImcState.UNKNOWN;
private final LinkedList<RemediationInstruction> mRemediationInstructions = new LinkedList<RemediationInstruction>();
private static long RETRY_INTERVAL = 1000;
/* cap the retry interval at 2 minutes */
private static long MAX_RETRY_INTERVAL = 120000;
private static int RETRY_MSG = 1;
private RetryTimeoutProvider mTimeoutProvider = new RetryTimeoutProvider();
private long mRetryTimeout;
private long mRetryIn;
public enum State
{
DISABLED,
CONNECTING,
CONNECTED,
DISCONNECTING,
}
public enum ErrorState
{
NO_ERROR,
AUTH_FAILED,
PEER_AUTH_FAILED,
LOOKUP_FAILED,
UNREACHABLE,
GENERIC_ERROR,
PASSWORD_MISSING,
CERTIFICATE_UNAVAILABLE,
}
/**
* Listener interface for bound clients that are interested in changes to
* this Service.
*/
public interface VpnStateListener
{
public void stateChanged();
}
/**
* Simple Binder that allows to directly access this Service class itself
* after binding to it.
*/
public class LocalBinder extends Binder
{
public VpnStateService getService()
{
return VpnStateService.this;
}
}
@Override
public void onCreate()
{
/* this handler allows us to notify listeners from the UI thread and
* not from the threads that actually report any state changes */
mHandler = new RetryHandler(getMainLooper(), this);
}
@Override
public IBinder onBind(Intent intent)
{
return mBinder;
}
@Override
public void onDestroy()
{
}
/**
* Register a listener with this Service. We assume this is called from
* the main thread so no synchronization is happening.
*
* @param listener listener to register
*/
public void registerListener(VpnStateListener listener)
{
mListeners.add(listener);
}
/**
* Unregister a listener from this Service.
*
* @param listener listener to unregister
*/
public void unregisterListener(VpnStateListener listener)
{
mListeners.remove(listener);
}
/**
* Get the current VPN profile.
*
* @return profile
*/
public VpnProfile getProfile()
{ /* only updated from the main thread so no synchronization needed */
return mProfile;
}
/**
* Get the current connection ID. May be used to track which state
* changes have already been handled.
*
* Is increased when startConnection() is called.
*
* @return connection ID
*/
public long getConnectionID()
{ /* only updated from the main thread so no synchronization needed */
return mConnectionID;
}
/**
* Get the total number of seconds until there is an automatic retry to reconnect.
* @return total number of seconds until the retry
*/
public int getRetryTimeout()
{
return (int)(mRetryTimeout / 1000);
}
/**
* Get the number of seconds until there is an automatic retry to reconnect.
* @return number of seconds until the retry
*/
public int getRetryIn()
{
return (int)(mRetryIn / 1000);
}
/**
* Get the current state.
*
* @return state
*/
public State getState()
{ /* only updated from the main thread so no synchronization needed */
return mState;
}
/**
* Get the current error, if any.
*
* @return error
*/
public ErrorState getErrorState()
{ /* only updated from the main thread so no synchronization needed */
return mError;
}
/**
* Get a description of the current error, if any.
*
* @return error description text id
*/
public int getErrorText()
{
switch (mError)
{
case AUTH_FAILED:
if (mImcState == ImcState.BLOCK)
{
return R.string.error_assessment_failed;
}
else
{
return R.string.error_auth_failed;
}
case PEER_AUTH_FAILED:
return R.string.error_peer_auth_failed;
case LOOKUP_FAILED:
return R.string.error_lookup_failed;
case UNREACHABLE:
return R.string.error_unreachable;
case PASSWORD_MISSING:
return R.string.error_password_missing;
case CERTIFICATE_UNAVAILABLE:
return R.string.error_certificate_unavailable;
default:
return R.string.error_generic;
}
}
/**
* Get the current IMC state, if any.
*
* @return imc state
*/
public ImcState getImcState()
{ /* only updated from the main thread so no synchronization needed */
return mImcState;
}
/**
* Get the remediation instructions, if any.
*
* @return read-only list of instructions
*/
public List<RemediationInstruction> getRemediationInstructions()
{ /* only updated from the main thread so no synchronization needed */
return Collections.unmodifiableList(mRemediationInstructions);
}
/**
* Disconnect any existing connection and shutdown the daemon, the
* VpnService is not stopped but it is reset so new connections can be
* started.
*/
public void disconnect()
{
/* reset any potential retry timer and error state */
resetRetryTimer();
setError(ErrorState.NO_ERROR);
/* as soon as the TUN device is created by calling establish() on the
* VpnService.Builder object the system binds to the service and keeps
* bound until the file descriptor of the TUN device is closed. thus
* calling stopService() here would not stop (destroy) the service yet,
* instead we call startService() with a specific action which shuts down
* the daemon (and closes the TUN device, if any) */
Context context = getApplicationContext();
Intent intent = new Intent(context, CharonVpnService.class);
intent.setAction(CharonVpnService.DISCONNECT_ACTION);
context.startService(intent);
}
/**
* Connect (or reconnect) a profile
* @param profileInfo optional profile info (basically the UUID and password), taken from the
* previous profile if null
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
*/
public void connect(Bundle profileInfo, boolean fromScratch)
{
/* we assume we have the necessary permission */
Context context = getApplicationContext();
Intent intent = new Intent(context, CharonVpnService.class);
if (profileInfo == null)
{
profileInfo = new Bundle();
profileInfo.putString(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString());
/* pass the previous password along */
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, mProfile.getPassword());
}
if (fromScratch)
{
/* reset if this is a manual retry or a new connection */
mTimeoutProvider.reset();
}
else
{ /* mark this as an automatic retry */
profileInfo.putBoolean(CharonVpnService.KEY_IS_RETRY, true);
}
intent.putExtras(profileInfo);
ContextCompat.startForegroundService(context, intent);
}
/**
* Reconnect to the previous profile.
*/
public void reconnect()
{
if (mProfile == null)
{
return;
}
if (mProfile.getVpnType().has(VpnType.VpnTypeFeature.USER_PASS))
{
if (mProfile.getPassword() == null ||
mError == ErrorState.AUTH_FAILED)
{ /* show a dialog if we either don't have the password or if it might be the wrong
* one (which is or isn't stored with the profile, let the activity decide) */
Intent intent = new Intent(this, VpnProfileControlActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction(VpnProfileControlActivity.START_PROFILE);
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_ID, mProfile.getUUID().toString());
startActivity(intent);
/* reset the retry timer immediately in case the user needs more time to enter the password */
notifyListeners(() -> {
resetRetryTimer();
return true;
});
return;
}
}
connect(null, true);
}
/**
* Update state and notify all listeners about the change. By using a Handler
* this is done from the main UI thread and not the initial reporter thread.
* Also, in doing the actual state change from the main thread, listeners
* see all changes and none are skipped.
*
* @param change the state update to perform before notifying listeners, returns true if state changed
*/
private void notifyListeners(final Callable<Boolean> change)
{
mHandler.post(new Runnable() {
@Override
public void run()
{
try
{
if (change.call())
{ /* otherwise there is no need to notify the listeners */
for (VpnStateListener listener : mListeners)
{
listener.stateChanged();
}
}
}
catch (Exception e)
{
e.printStackTrace();
}
}
});
}
/**
* Called when a connection is started. Sets the currently active VPN
* profile, resets IMC and Error state variables, sets the State to
* CONNECTING, increases the connection ID, and notifies all listeners.
*
* May be called from threads other than the main thread.
*
* @param profile current profile
*/
public void startConnection(final VpnProfile profile)
{
notifyListeners(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception
{
resetRetryTimer();
VpnStateService.this.mConnectionID++;
VpnStateService.this.mProfile = profile;
VpnStateService.this.mState = State.CONNECTING;
VpnStateService.this.mError = ErrorState.NO_ERROR;
VpnStateService.this.mImcState = ImcState.UNKNOWN;
VpnStateService.this.mRemediationInstructions.clear();
return true;
}
});
}
/**
* Update the state and notify all listeners, if changed.
*
* May be called from threads other than the main thread.
*
* @param state new state
*/
public void setState(final State state)
{
notifyListeners(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception
{
if (state == State.CONNECTED)
{ /* reset counter in case there is an error later on */
mTimeoutProvider.reset();
}
if (VpnStateService.this.mState != state)
{
VpnStateService.this.mState = state;
return true;
}
return false;
}
});
}
/**
* Set the current error state and notify all listeners, if changed.
*
* May be called from threads other than the main thread.
*
* @param error error state
*/
public void setError(final ErrorState error)
{
notifyListeners(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception
{
if (VpnStateService.this.mError != error)
{
if (VpnStateService.this.mError == ErrorState.NO_ERROR)
{
setRetryTimer(error);
}
else if (error == ErrorState.NO_ERROR)
{
resetRetryTimer();
}
VpnStateService.this.mError = error;
return true;
}
return false;
}
});
}
/**
* Set the current IMC state and notify all listeners, if changed.
*
* Setting the state to UNKNOWN clears all remediation instructions.
*
* May be called from threads other than the main thread.
*
* @param state IMC state
*/
public void setImcState(final ImcState state)
{
notifyListeners(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception
{
if (state == ImcState.UNKNOWN)
{
VpnStateService.this.mRemediationInstructions.clear();
}
if (VpnStateService.this.mImcState != state)
{
VpnStateService.this.mImcState = state;
return true;
}
return false;
}
});
}
/**
* Add the given remediation instruction to the internal list. Listeners
* are not notified.
*
* Instructions are cleared if the IMC state is set to UNKNOWN.
*
* May be called from threads other than the main thread.
*
* @param instruction remediation instruction
*/
public void addRemediationInstruction(final RemediationInstruction instruction)
{
mHandler.post(new Runnable() {
@Override
public void run()
{
VpnStateService.this.mRemediationInstructions.add(instruction);
}
});
}
/**
* Sets the retry timer
*/
private void setRetryTimer(ErrorState error)
{
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
if (mRetryTimeout <= 0)
{
return;
}
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_INTERVAL);
}
/**
* Reset the retry timer
*/
private void resetRetryTimer()
{
mRetryTimeout = 0;
mRetryIn = 0;
}
/**
* Special Handler subclass that handles the retry countdown (more accurate than CountDownTimer)
*/
private static class RetryHandler extends Handler {
WeakReference<VpnStateService> mService;
public RetryHandler(Looper looper, VpnStateService service)
{
super(looper);
mService = new WeakReference<>(service);
}
@Override
public void handleMessage(Message msg)
{
/* handle retry countdown */
if (mService.get().mRetryTimeout <= 0)
{
return;
}
mService.get().mRetryIn -= RETRY_INTERVAL;
if (mService.get().mRetryIn > 0)
{
/* calculate next interval before notifying listeners */
long next = SystemClock.uptimeMillis() + RETRY_INTERVAL;
for (VpnStateListener listener : mService.get().mListeners)
{
listener.stateChanged();
}
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
}
else
{
mService.get().connect(null, false);
}
}
}
/**
* Class that handles an exponential backoff for retry timeouts
*/
private static class RetryTimeoutProvider
{
private long mRetry;
private long getBaseTimeout(ErrorState error)
{
switch (error)
{
case AUTH_FAILED:
return 10000;
case PEER_AUTH_FAILED:
return 5000;
case LOOKUP_FAILED:
return 5000;
case UNREACHABLE:
return 5000;
case PASSWORD_MISSING:
/* this needs user intervention (entering the password) */
return 0;
case CERTIFICATE_UNAVAILABLE:
/* if this is because the device has to be unlocked we might be able to reconnect */
return 5000;
default:
return 10000;
}
}
/**
* Called each time a new retry timeout is started. The timeout increases until reset() is
* called and the base timeout is returned again.
* @param error Error state
*/
public long getTimeout(ErrorState error)
{
long timeout = (long)(getBaseTimeout(error) * Math.pow(2, mRetry++));
/* return the result rounded to seconds */
return Math.min((timeout / 1000) * 1000, MAX_RETRY_INTERVAL);
}
/**
* Reset the retry counter.
*/
public void reset()
{
mRetry = 0;
}
}
}