Added|Changed|Fixed: Refactor local socket server implementation and make client handling abstract

- Added `LocalSocketManager` to manage the server, `LocalServerSocket` to represent server socket, `LocalClientSocket` to represent client socket, `LocalSocketRunConfig` to store server run config and `ILocalSocketManager` as interface for the `LocalSocketManager` to handle callbacks from the server to handle clients.
- Added support to get full `PeerCred` for client socket, including `pid`, `pname`, `uid`, `uname`, `gid`, `gname` and `cmdline` instead of just `uid`. This should provide more info for error logs about which client failed or tried to connect in case of disallowed clients. Some data is filled in native code and some in java. Native support for added to get process name and `cmdline` of a process with a specific pid.
- Added `JniResult` to get results for JNI calls. Previously only an int was returned and incomplete errors logged. With `JniResult`, both `retval` and `errno` will be returned and full error messages in `errmsg`, including all `strerror()` output for `errno`s. This would provide more helpful info on errors.
- Added `Error` support via `LocalSocketErrno` which contains full error messages and stacktraces for all native and java calls, allowing much better error reporting to users and devs. The errors will be logged by `LocalSocketManagerClientBase` if log level is debug or higher since `PeerCred` `cmdline` may contain private info of users.
- Added support in java to check if socket path was an absolute path and not greater than `108` bytes, after canonicalizing it since otherwise it would result in creation of useless parent directories on failure.
- Added `readDataOnInputStream()` and `sendDataToOutputStream()` functions to `LocalClientSocket` so that server manager client can easily read and send data.

- Renamed the variables and functions as per convention, specially one letter variables. https://source.android.com/setup/contribute/code-style#follow-field-naming-conventions
- Rename `local-filesystem-socket` to `local-filesystem` since abstract namespace sockets can also be created.
- Previously, it was assumed that all local server would expect a shell command string that should be converted to command args with `ArgumentTokenizer` and then should be passed to `LocalSocketHandler.handle()` and then result sent back to client with exit code, stdout and stderr, but there could be any kind of servers in which behaviour is different. Such client handling should not be hard coded and the server manager client should handle the client themselves however they like, including closing the client socket. This will now be done with `ILocalSocketManager. onClientAccepted(LocalSocketManager, LocalClientSocket)`.

- Ensure app does not crash if `local-socket` library is not found or for any other exceptions in the server since anything running in the `Application` class is critical that it does not fail since user would not be able to recover from it, specially non rooted users without SAF support to disable the server with a prop.
- Make sure all reasonable JNI exceptions are caught instead of crashing the app.
- Fixed issue where client logic (`LocalSocketHandler.handle()` was being run in the same thread as the new client acceptable thread, basically blocking new clients until previous client's am command was fully processed. Now all client interface callbacks are started in new threads by `LocalSocketManager`.
- Fix bug where timeout would not be greater than `1000ms` due to only using `tv_usec` which caps at `999,999`.
This commit is contained in:
agnostic-apollo
2022-04-18 04:36:07 +05:00
parent 5f8a922201
commit 2aa7f43d1c
15 changed files with 2514 additions and 589 deletions

View File

@@ -1,273 +0,0 @@
package com.termux.shared.shell;
import android.content.Context;
import androidx.annotation.NonNull;
import com.termux.shared.logger.Logger;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public class LocalFilesystemSocket
{
static {
System.loadLibrary("local-filesystem-socket");
}
/**
* Creates as UNIX server socket at {@code path}, with a backlog of {@code backlog}.
* @return The file descriptor of the server socket or -1 if an error occurred.
*/
private static native int createserversocket(@NonNull byte[] path, int backlog);
/**
* Accepts a connection on the supplied server socket.
* @return The file descriptor of the connection socket or -1 in case of an error.
*/
private static native int accept(int fd);
private static native void closefd(int fd);
/**
* Returns the UID of the socket peer, or -1 in case of an error.
*/
private static native int getpeeruid(int fd);
/**
* Sends {@code data} over the socket and decrements the timeout by the elapsed time. If the timeout hits or an error occurs, false is returned, true otherwise.
*/
private static native boolean send(@NonNull byte[] data, int fd, long deadline);
/**
* Receives data from the socket and decrements the timeout by the elapsed time. If the timeout hits or an error occurs, -1 is returned, otherwise the number of received bytes.
*/
private static native int recv(@NonNull byte[] data, int fd, long deadline);
/**
* Sets the send and receive timeout for the socket in milliseconds.
*/
private static native void settimeout(int fd, int timeout);
/**
* Gets the number of bytes available to read on the socket.
*/
private static native int available(int fd);
static class Socket implements Closeable {
private int fd;
private long deadline = 0;
private final SocketOutputStream out = new SocketOutputStream();
private final SocketInputStream in = new SocketInputStream();
class SocketInputStream extends InputStream {
private final byte[] readb = new byte[1];
@Override
public int read() throws IOException {
int ret = recv(readb);
if (ret == -1) {
throw new IOException("Could not read from socket");
}
if (ret == 0) {
return -1;
}
return readb[0];
}
@Override
public int read(byte[] b) throws IOException {
if (b == null) {
throw new NullPointerException("Read buffer can't be null");
}
int ret = recv(b);
if (ret == -1) {
throw new IOException("Could not read from socket");
}
if (ret == 0) {
return -1;
}
return ret;
}
@Override
public int available() {
return Socket.this.available();
}
}
class SocketOutputStream extends OutputStream {
private final byte[] writeb = new byte[1];
@Override
public void write(int b) throws IOException {
writeb[0] = (byte) b;
if (! send(writeb)) {
throw new IOException("Could not write to socket");
}
}
@Override
public void write(byte[] b) throws IOException {
if (! send(b)) {
throw new IOException("Could not write to socket");
}
}
}
private Socket(int fd) {
this.fd = fd;
}
/**
* Sets the socket timeout, that makes a single send/recv error out if it triggers. For a deadline after which the socket should be finished see setDeadline()
*/
public void setTimeout(int timeout) {
if (fd != -1) {
settimeout(fd, timeout);
}
}
/**
* Sets the deadline in unix milliseconds. When the deadline has elapsed and the socket timeout triggers, all reads and writes will error.
*/
public void setDeadline(long deadline) {
this.deadline = deadline;
}
public boolean send(@NonNull byte[] data) {
if (fd == -1) {
return false;
}
return LocalFilesystemSocket.send(data, fd, deadline);
}
public int recv(@NonNull byte[] data) {
if (fd == -1) {
return -1;
}
return LocalFilesystemSocket.recv(data, fd, deadline);
}
public int available() {
if (fd == -1 || System.currentTimeMillis() > deadline) {
return 0;
}
return LocalFilesystemSocket.available(fd);
}
@Override
public void close() throws IOException {
if (fd != -1) {
closefd(fd);
fd = -1;
}
}
/**
* Returns the UID of the socket peer, or -1 in case of an error.
*/
public int getPeerUID() {
return getpeeruid(fd);
}
/**
* Returns an {@link OutputStream} for the socket. You don't need to close the stream, it's automatically closed when closing the socket.
*/
public OutputStream getOutputStream() {
return out;
}
/**
* Returns an {@link InputStream} for the socket. You don't need to close the stream, it's automatically closed when closing the socket.
*/
public InputStream getInputStream() {
return in;
}
}
static class ServerSocket implements Closeable
{
private final String path;
private final Context app;
private int fd;
public ServerSocket(Context c, String path, int backlog) throws IOException {
app = c.getApplicationContext();
if (backlog <= 0) {
throw new IllegalArgumentException("Backlog has to be at least 1");
}
if (path == null || path.length() == 0) {
throw new IllegalArgumentException("path cannot be null or empty");
}
this.path = path;
if (path.getBytes(StandardCharsets.UTF_8)[0] != 0) {
// not a socket in the abstract linux namespace, make sure the path is accessible and clear
File f = new File(path);
File parent = f.getParentFile();
if (parent != null) {
parent.mkdirs();
}
f.delete();
}
fd = createserversocket(path.getBytes(StandardCharsets.UTF_8), backlog);
if (fd == -1) {
throw new IOException("Could not create UNIX server socket at \""+path+"\"");
}
}
public ServerSocket(Context c, String path) throws IOException {
this(c, path, 50); // 50 is the default value for the Android LocalSocket implementation, so this should be good here, too
}
public Socket accept() {
if (fd == -1) {
return null;
}
int c = -1;
while (true) {
while (c == -1) {
c = LocalFilesystemSocket.accept(fd);
}
int peeruid = getpeeruid(c);
if (peeruid == -1) {
Logger.logWarn("LocalFilesystemSocket.ServerSocket", "Could not verify peer uid, closing socket");
closefd(c);
c = -1;
continue;
}
// if the peer has the same uid or is root, allow the connection
if (peeruid == app.getApplicationInfo().uid || peeruid == 0) {
break;
} else {
Logger.logWarn("LocalFilesystemSocket.ServerSocket", "WARNING: An app with the uid of "+peeruid+" tried to connect to the socket at \""+path+"\", closing connection.");
closefd(c);
c = -1;
}
}
return new Socket(c);
}
@Override
public void close() throws IOException {
if (fd != -1) {
closefd(fd);
fd = -1;
}
}
}
}

View File

@@ -1,117 +0,0 @@
package com.termux.shared.shell;
import android.app.Application;
import androidx.annotation.NonNull;
import com.termux.shared.logger.Logger;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintStream;
public class LocalSocketListener
{
public interface LocalSocketHandler {
int handle(String[] args, PrintStream out, PrintStream err);
}
private static final String LOG_TAG = "LocalSocketListener";
private final Thread thread;
private final LocalFilesystemSocket.ServerSocket server;
private final int timeoutMillis;
private final int deadlineMillis;
private LocalSocketListener(@NonNull Application a, @NonNull LocalSocketHandler h, @NonNull String path, int timeoutMillis, int deadlineMillis) throws IOException {
this.timeoutMillis = timeoutMillis;
this.deadlineMillis = deadlineMillis;
server = new LocalFilesystemSocket.ServerSocket(a, path);
thread = new Thread(new LocalSocketListenerRunnable(h));
thread.setUncaughtExceptionHandler((t, e) -> Logger.logStackTraceWithMessage(LOG_TAG, "Uncaught exception in LocalSocketListenerRunnable", e));
thread.start();
}
public static LocalSocketListener tryEstablishLocalSocketListener(@NonNull Application a, @NonNull LocalSocketHandler h, @NonNull String address, int timeoutMillis, int deadlineMillis) {
try {
return new LocalSocketListener(a, h, address, timeoutMillis, deadlineMillis);
} catch (IOException e) {
return null;
}
}
@SuppressWarnings("unused")
public void stop() {
try {
thread.interrupt();
server.close();
} catch (Exception ignored) {}
}
private class LocalSocketListenerRunnable implements Runnable {
private final LocalSocketHandler h;
public LocalSocketListenerRunnable(@NonNull LocalSocketHandler h) {
this.h = h;
}
@Override
public void run() {
try {
while (! Thread.currentThread().isInterrupted()) {
try (LocalFilesystemSocket.Socket s = server.accept();
OutputStream sockout = s.getOutputStream();
InputStreamReader r = new InputStreamReader(s.getInputStream())) {
s.setTimeout(timeoutMillis);
s.setDeadline(System.currentTimeMillis()+deadlineMillis);
StringBuilder b = new StringBuilder();
int c;
while ((c = r.read()) > 0) {
b.append((char) c);
}
String outString;
String errString;
int ret;
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
PrintStream outp = new PrintStream(out);
ByteArrayOutputStream err = new ByteArrayOutputStream();
PrintStream errp = new PrintStream(err)) {
ret = h.handle(ArgumentTokenizer.tokenize(b.toString()).toArray(new String[0]), outp, errp);
outp.flush();
outString = out.toString("UTF-8");
errp.flush();
errString = err.toString("UTF-8");
}
try (BufferedWriter w = new BufferedWriter(new OutputStreamWriter(sockout))) {
w.write(Integer.toString(ret));
w.write('\0');
w.write(outString);
w.write('\0');
w.write(errString);
w.flush();
}
} catch (Exception e) {
Logger.logStackTraceWithMessage(LOG_TAG, "Exception while handling connection", e);
}
}
}
finally {
try {
server.close();
} catch (Exception ignored) {}
}
Logger.logDebug(LOG_TAG, "LocalSocketListenerRunnable returned");
}
}
}