001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.named.ipc;
020
021import java.io.BufferedReader;
022import java.io.DataInputStream;
023import java.io.DataOutputStream;
024import java.io.IOException;
025import java.io.InputStreamReader;
026import java.net.SocketAddress;
027import java.nio.channels.ByteChannel;
028import java.nio.channels.Channels;
029import java.nio.channels.ServerSocketChannel;
030import java.nio.channels.SocketChannel;
031import java.nio.charset.StandardCharsets;
032import java.util.ArrayList;
033import java.util.Iterator;
034import java.util.List;
035import java.util.Map;
036import java.util.concurrent.CompletableFuture;
037import java.util.concurrent.ConcurrentHashMap;
038import java.util.concurrent.CopyOnWriteArrayList;
039import java.util.concurrent.TimeUnit;
040import java.util.concurrent.atomic.AtomicInteger;
041
042/**
043 * Implementation of the server side.
044 * The server instance is bound to a given maven repository.
045 *
046 * @since 2.0.1
047 */
048public class IpcServer {
049    /**
050     * Should the IPC server not fork? (i.e. for testing purposes)
051     *
052     * @configurationSource {@link System#getProperty(String, String)}
053     * @configurationType {@link java.lang.Boolean}
054     * @configurationDefaultValue {@link #DEFAULT_NO_FORK}
055     */
056    public static final String SYSTEM_PROP_NO_FORK = "aether.named.ipc.nofork";
057
058    public static final boolean DEFAULT_NO_FORK = false;
059
060    /**
061     * IPC idle timeout in seconds. If there is no IPC request during idle time, it will stop.
062     *
063     * @configurationSource {@link System#getProperty(String, String)}
064     * @configurationType {@link java.lang.Integer}
065     * @configurationDefaultValue {@link #DEFAULT_IDLE_TIMEOUT}
066     */
067    public static final String SYSTEM_PROP_IDLE_TIMEOUT = "aether.named.ipc.idleTimeout";
068
069    public static final int DEFAULT_IDLE_TIMEOUT = 300;
070
071    /**
072     * IPC socket family to use.
073     *
074     * @configurationSource {@link System#getProperty(String, String)}
075     * @configurationType {@link java.lang.String}
076     * @configurationDefaultValue {@link #DEFAULT_FAMILY}
077     */
078    public static final String SYSTEM_PROP_FAMILY = "aether.named.ipc.family";
079
080    public static final String DEFAULT_FAMILY = "unix";
081
082    /**
083     * Should the IPC server not use native executable?
084     *
085     * @configurationSource {@link System#getProperty(String, String)}
086     * @configurationType {@link java.lang.Boolean}
087     * @configurationDefaultValue {@link #DEFAULT_NO_NATIVE}
088     */
089    public static final String SYSTEM_PROP_NO_NATIVE = "aether.named.ipc.nonative";
090
091    public static final boolean DEFAULT_NO_NATIVE = true;
092
093    /**
094     * The name if the IPC server native executable (without file extension like ".exe")
095     *
096     * @configurationSource {@link System#getProperty(String, String)}
097     * @configurationType {@link java.lang.String}
098     * @configurationDefaultValue {@link #DEFAULT_NATIVE_NAME}
099     */
100    public static final String SYSTEM_PROP_NATIVE_NAME = "aether.named.ipc.nativeName";
101
102    public static final String DEFAULT_NATIVE_NAME = "ipc-sync";
103
104    /**
105     * Should the IPC server log debug messages? (i.e. for testing purposes)
106     *
107     * @configurationSource {@link System#getProperty(String, String)}
108     * @configurationType {@link java.lang.Boolean}
109     * @configurationDefaultValue {@link #DEFAULT_DEBUG}
110     */
111    public static final String SYSTEM_PROP_DEBUG = "aether.named.ipc.debug";
112
113    public static final boolean DEFAULT_DEBUG = false;
114
115    private final ServerSocketChannel serverSocket;
116    private final Map<SocketChannel, Thread> clients = new ConcurrentHashMap<>();
117    private final AtomicInteger counter = new AtomicInteger();
118    private final Map<String, Lock> locks = new ConcurrentHashMap<>();
119    private final Map<String, Context> contexts = new ConcurrentHashMap<>();
120    private static final boolean DEBUG =
121            Boolean.parseBoolean(System.getProperty(SYSTEM_PROP_DEBUG, Boolean.toString(DEFAULT_DEBUG)));
122    private final long idleTimeout;
123    private final String bootstrapToken;
124    private volatile long lastUsed;
125    private volatile boolean closing;
126
127    /**
128     * @deprecated a server created without a bootstrap token refuses {@link IpcMessages#REQUEST_STOP} requests;
129     * use {@link #IpcServer(SocketFamily, String)} instead.
130     */
131    @Deprecated
132    public IpcServer(SocketFamily family) throws IOException {
133        this(family, null);
134    }
135
136    /**
137     * Creates a server that honors a remote stop request only when it carries the given bootstrap token, which is
138     * shared exclusively with the client that spawned this server. The rest of the protocol is unauthenticated,
139     * but destructive cross-client operations (closing foreign contexts, stopping the daemon) are refused.
140     *
141     * @since 2.0.23
142     */
143    public IpcServer(SocketFamily family, String bootstrapToken) throws IOException {
144        this.bootstrapToken = bootstrapToken;
145        serverSocket = family.openServerSocket();
146        long timeout = TimeUnit.SECONDS.toNanos(DEFAULT_IDLE_TIMEOUT);
147        String str = System.getProperty(SYSTEM_PROP_IDLE_TIMEOUT);
148        if (str != null) {
149            try {
150                TimeUnit unit = TimeUnit.SECONDS;
151                if (str.endsWith("ms")) {
152                    unit = TimeUnit.MILLISECONDS;
153                    str = str.substring(0, str.length() - 2);
154                }
155                long dur = Long.parseLong(str);
156                timeout = unit.toNanos(dur);
157            } catch (NumberFormatException e) {
158                error("Property " + SYSTEM_PROP_IDLE_TIMEOUT + " specified with invalid value: " + str, e);
159            }
160        }
161        idleTimeout = timeout;
162    }
163
164    public static void main(String[] args) throws Exception {
165        // When spawning a new process, the child process is create within
166        // the same process group.  This means that a few signals are sent
167        // to the whole group.  This is the case for SIGINT (Ctrl-C) and
168        // SIGTSTP (Ctrl-Z) which are both sent to all the processed in the
169        // group when initiated from the controlling terminal.
170        // This is only a problem when the client creates the daemon, but
171        // without ignoring those signals, a client being interrupted will
172        // also interrupt and kill the daemon.
173        try {
174            sun.misc.Signal.handle(new sun.misc.Signal("INT"), sun.misc.SignalHandler.SIG_IGN);
175            if (!IpcClient.IS_WINDOWS) {
176                sun.misc.Signal.handle(new sun.misc.Signal("TSTP"), sun.misc.SignalHandler.SIG_IGN);
177            }
178        } catch (Throwable t) {
179            error("Unable to ignore INT and TSTP signals", t);
180        }
181
182        String family = args[0];
183        String tmpAddress = args[1];
184        String rand = args[2];
185        if ("-".equals(rand)) {
186            // the bootstrap token is passed via stdin instead of argv: process arguments are commonly visible
187            // to other local users (e.g. /proc/<pid>/cmdline), and this token authorizes stopping the daemon
188            BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
189            rand = reader.readLine();
190            if (rand == null || rand.isEmpty()) {
191                throw new IOException("Expected the bootstrap token on standard input");
192            }
193        }
194
195        runServer(SocketFamily.valueOf(family), tmpAddress, rand);
196    }
197
198    static IpcServer runServer(SocketFamily family, String tmpAddress, String rand) throws IOException {
199        IpcServer server = new IpcServer(family, rand);
200        run(server::run, false); // this is one-off
201        String address = SocketFamily.toString(server.getLocalAddress());
202        SocketAddress socketAddress = SocketFamily.fromString(tmpAddress);
203        try (SocketChannel socket = SocketChannel.open(socketAddress)) {
204            try (DataOutputStream dos = new DataOutputStream(Channels.newOutputStream(socket))) {
205                dos.writeUTF(rand);
206                dos.writeUTF(address);
207                dos.flush();
208            }
209        }
210
211        return server;
212    }
213
214    private static void debug(String msg, Object... args) {
215        if (DEBUG) {
216            System.out.printf("[ipc] [debug] " + msg + "\n", args);
217        }
218    }
219
220    private static void info(String msg, Object... args) {
221        System.out.printf("[ipc] [info] " + msg + "\n", args);
222    }
223
224    private static void error(String msg, Throwable t) {
225        System.out.println("[ipc] [error] " + msg);
226        t.printStackTrace(System.out);
227    }
228
229    private static void run(Runnable runnable, boolean daemon) {
230        Thread thread = new Thread(runnable);
231        if (daemon) {
232            thread.setDaemon(true);
233        }
234        thread.start();
235    }
236
237    public SocketAddress getLocalAddress() throws IOException {
238        return serverSocket.getLocalAddress();
239    }
240
241    public void run() {
242        try {
243            info("IpcServer started at %s", getLocalAddress().toString());
244            use();
245            run(this::expirationCheck, true);
246            while (!closing) {
247                SocketChannel socket = this.serverSocket.accept();
248                run(() -> client(socket), false);
249            }
250        } catch (Throwable t) {
251            if (!closing) {
252                error("Error running sync server loop", t);
253            }
254        }
255    }
256
257    private void client(SocketChannel socket) {
258        int c;
259        synchronized (clients) {
260            clients.put(socket, Thread.currentThread());
261            c = clients.size();
262        }
263        info("New client connected (%d connected)", c);
264        use();
265        Map<String, Context> clientContexts = new ConcurrentHashMap<>();
266        try {
267            ByteChannel wrapper = new ByteChannelWrapper(socket);
268            DataInputStream input = new DataInputStream(Channels.newInputStream(wrapper));
269            DataOutputStream output = new DataOutputStream(Channels.newOutputStream(wrapper));
270            while (!closing) {
271                int requestId = input.readInt();
272                int sz = input.readInt();
273                if (sz < 0) {
274                    throw new IOException("Received invalid request size: " + sz);
275                }
276                // do not preallocate from an unauthenticated wire-supplied size; grow with actually received data
277                List<String> request = new ArrayList<>(Math.min(sz, 1024));
278                for (int i = 0; i < sz; i++) {
279                    request.add(input.readUTF());
280                }
281                if (request.isEmpty()) {
282                    throw new IOException("Received invalid request");
283                }
284                use();
285                String contextId;
286                Context context;
287                String command = request.remove(0);
288                switch (command) {
289                    case IpcMessages.REQUEST_CONTEXT:
290                        if (request.size() != 1) {
291                            throw new IOException("Expected one argument for " + command + " but got " + request);
292                        }
293                        boolean shared = Boolean.parseBoolean(request.remove(0));
294                        context = new Context(shared);
295                        contexts.put(context.id, context);
296                        clientContexts.put(context.id, context);
297                        synchronized (output) {
298                            debug("Created context %s", context.id);
299                            output.writeInt(requestId);
300                            output.writeInt(2);
301                            output.writeUTF(IpcMessages.RESPONSE_CONTEXT);
302                            output.writeUTF(context.id);
303                            output.flush();
304                        }
305                        break;
306                    case IpcMessages.REQUEST_ACQUIRE:
307                        if (request.isEmpty()) {
308                            throw new IOException(
309                                    "Expected at least one argument for " + command + " but got " + request);
310                        }
311                        contextId = request.remove(0);
312                        // contexts are scoped per connection: a client may only use contexts it created itself
313                        context = clientContexts.get(contextId);
314                        if (context == null) {
315                            throw new IOException(
316                                    "Unknown context: " + contextId + ". Known contexts = " + clientContexts.keySet());
317                        }
318                        context.lock(request).thenRun(() -> sendAcquireResponse(output, socket, requestId, context));
319                        break;
320                    case IpcMessages.REQUEST_CLOSE:
321                        if (request.size() != 1) {
322                            throw new IOException("Expected one argument for " + command + " but got " + request);
323                        }
324                        contextId = request.remove(0);
325                        // contexts are scoped per connection: a client may only close contexts it created itself
326                        context = clientContexts.remove(contextId);
327                        if (context == null) {
328                            throw new IOException(
329                                    "Unknown context: " + contextId + ". Known contexts = " + clientContexts.keySet());
330                        }
331                        contexts.remove(contextId);
332                        context.unlock();
333                        synchronized (output) {
334                            debug("Closing context %s", context.id);
335                            output.writeInt(requestId);
336                            output.writeInt(1);
337                            output.writeUTF(IpcMessages.RESPONSE_CLOSE);
338                            output.flush();
339                        }
340                        break;
341                    case IpcMessages.REQUEST_STOP:
342                        if (request.size() > 1) {
343                            throw new IOException(
344                                    "Expected at most one argument for " + command + " but got " + request);
345                        }
346                        String stopToken = request.isEmpty() ? null : request.remove(0);
347                        if (bootstrapToken == null || !bootstrapToken.equals(stopToken)) {
348                            // the protocol is otherwise unauthenticated: only the client that spawned this
349                            // server (and thus knows the bootstrap token) may stop it for everybody else
350                            throw new IOException("Stop request rejected: missing or invalid bootstrap token");
351                        }
352                        synchronized (output) {
353                            debug("Stopping server");
354                            output.writeInt(requestId);
355                            output.writeInt(1);
356                            output.writeUTF(IpcMessages.RESPONSE_STOP);
357                            output.flush();
358                        }
359                        close();
360                        break;
361                    default:
362                        throw new IOException("Unknown request: " + request.get(0));
363                }
364            }
365        } catch (Throwable t) {
366            if (!closing) {
367                error("Error processing request", t);
368            }
369        } finally {
370            if (!closing) {
371                info("Client disconnecting...");
372            }
373            clientContexts.values().forEach(context -> {
374                contexts.remove(context.id);
375                context.unlock();
376            });
377            try {
378                socket.close();
379            } catch (IOException ioException) {
380                // ignore
381            }
382            synchronized (clients) {
383                clients.remove(socket);
384                c = clients.size();
385            }
386            if (!closing) {
387                info("%d clients remained", c);
388            }
389        }
390    }
391
392    private void sendAcquireResponse(DataOutputStream output, SocketChannel socket, int requestId, Context context) {
393        try {
394            synchronized (output) {
395                debug("Locking in context %s", context.id);
396                output.writeInt(requestId);
397                output.writeInt(1);
398                output.writeUTF(IpcMessages.RESPONSE_ACQUIRE);
399                output.flush();
400            }
401        } catch (IOException e) {
402            try {
403                socket.close();
404            } catch (IOException ioException) {
405                e.addSuppressed(ioException);
406            }
407            error("Error writing lock response", e);
408        }
409    }
410
411    private void use() {
412        lastUsed = System.nanoTime();
413    }
414
415    private void expirationCheck() {
416        while (true) {
417            long current = System.nanoTime();
418            long left = (lastUsed + idleTimeout) - current;
419            if (clients.isEmpty() && left < 0) {
420                info("IpcServer expired, closing");
421                close();
422                break;
423            } else {
424                try {
425                    Thread.sleep(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left)));
426                } catch (InterruptedException e) {
427                    info("IpcServer expiration check interrupted, closing");
428                    close();
429                    break;
430                }
431            }
432        }
433    }
434
435    void close() {
436        closing = true;
437        try {
438            serverSocket.close();
439        } catch (IOException e) {
440            error("Error closing server socket", e);
441        }
442        clients.forEach((s, t) -> {
443            try {
444                s.close();
445            } catch (IOException e) {
446                // ignore
447            }
448            t.interrupt();
449        });
450    }
451
452    static class Waiter {
453        final Context context;
454        final CompletableFuture<Void> future;
455
456        Waiter(Context context, CompletableFuture<Void> future) {
457            this.context = context;
458            this.future = future;
459        }
460    }
461
462    static class Lock {
463
464        final String key;
465
466        List<Context> holders;
467        List<Waiter> waiters;
468
469        Lock(String key) {
470            this.key = key;
471        }
472
473        public synchronized CompletableFuture<Void> lock(Context context) {
474            if (holders == null) {
475                holders = new ArrayList<>();
476            }
477            if (holders.isEmpty() || holders.get(0).shared && context.shared) {
478                holders.add(context);
479                return CompletableFuture.completedFuture(null);
480            }
481            if (waiters == null) {
482                waiters = new ArrayList<>();
483            }
484
485            CompletableFuture<Void> future = new CompletableFuture<>();
486            waiters.add(new Waiter(context, future));
487            return future;
488        }
489
490        public void unlock(Context context) {
491            List<CompletableFuture<Void>> toComplete;
492            synchronized (this) {
493                toComplete = new ArrayList<>();
494                if (holders.remove(context)) {
495                    while (waiters != null
496                            && !waiters.isEmpty()
497                            && (holders.isEmpty() || holders.get(0).shared && waiters.get(0).context.shared)) {
498                        Waiter waiter = waiters.remove(0);
499                        holders.add(waiter.context);
500                        toComplete.add(waiter.future);
501                    }
502                } else if (waiters != null) {
503                    for (Iterator<Waiter> it = waiters.iterator(); it.hasNext(); ) {
504                        Waiter waiter = it.next();
505                        if (waiter.context == context) {
506                            it.remove();
507                            waiter.future.cancel(false);
508                        }
509                    }
510                }
511            }
512            toComplete.forEach(f -> f.complete(null));
513        }
514
515        public synchronized boolean isEmpty() {
516            return (holders == null || holders.isEmpty()) && (waiters == null || waiters.isEmpty());
517        }
518    }
519
520    class Context {
521
522        final String id;
523        final boolean shared;
524        final List<String> locks = new CopyOnWriteArrayList<>();
525
526        Context(boolean shared) {
527            this.id = String.format("%08x", counter.incrementAndGet());
528            this.shared = shared;
529        }
530
531        public CompletableFuture<?> lock(List<String> keys) {
532            locks.addAll(keys);
533            CompletableFuture<?>[] futures = keys.stream()
534                    .map(k -> IpcServer.this.locks.computeIfAbsent(k, Lock::new))
535                    .map(l -> l.lock(this))
536                    .toArray(CompletableFuture[]::new);
537            return CompletableFuture.allOf(futures);
538        }
539
540        public void unlock() {
541            locks.stream()
542                    .map(k -> IpcServer.this.locks.computeIfAbsent(k, Lock::new))
543                    .forEach(l -> {
544                        l.unlock(this);
545                        IpcServer.this.locks.compute(l.key, (k, v) -> (v == l && v.isEmpty()) ? null : v);
546                    });
547        }
548    }
549}